diff --git a/application/single_app/app.py b/application/single_app/app.py
index b577689b8..1f72fd2ce 100644
--- a/application/single_app/app.py
+++ b/application/single_app/app.py
@@ -62,6 +62,7 @@
from route_frontend_notifications import *
from route_frontend_terms_of_use import register_route_frontend_terms_of_use
from route_frontend_v2 import register_route_frontend_v2
+from route_access_restriction import register_route_access_restriction
from route_custom_pages import register_route_custom_pages
from route_backend_chats import *
@@ -927,6 +928,11 @@ def _is_idle_timeout_exempt(path):
'/api/v2/terms-of-use',
'/api/v2/terms-of-use/accept',
'/api/v2/terms-of-use/decline',
+ # The Access restricted screen. The Terms of Use pages require an unrestricted account,
+ # so gating this screen on the terms would bounce a restricted user between the two.
+ '/access-restricted',
+ '/v2/access-restricted',
+ '/api/v2/access-restriction',
'/robots933456.txt',
'/favicon.ico',
'/acceptable_use_policy.html',
@@ -1384,6 +1390,11 @@ def list_semantic_kernel_plugins():
# ------------------- Terms of Use Routes --
register_route_blueprint('frontend_terms_of_use', register_route_frontend_terms_of_use)
+# ------------------- Access Restricted Routes -----------
+# Login-only on purpose: user_required sends a suspended or blocked user here, so these
+# pages must not require an unrestricted account themselves.
+register_route_blueprint('access_restriction', register_route_access_restriction, login_required_blueprint)
+
# ------------------- User Profile Routes ----------------
register_route_blueprint('frontend_profile', register_route_frontend_profile, login_required_blueprint)
diff --git a/application/single_app/config.py b/application/single_app/config.py
index 031f58018..c19ed149c 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.296"
+VERSION = "0.261.298"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/single_app/functions_access_restriction.py b/application/single_app/functions_access_restriction.py
new file mode 100644
index 000000000..501e1ed7f
--- /dev/null
+++ b/application/single_app/functions_access_restriction.py
@@ -0,0 +1,213 @@
+# functions_access_restriction.py
+"""Describe a user's access restriction for the access gate and the Access restricted screen.
+
+Control Center and safety remediation restrict a user by writing
+``settings.access = {'status': 'deny', 'datetime_to_allow': }``. A
+suspension or block applied for a safety violation also stores a ``notice`` beside them:
+the title and message the user was sent, so the screen a restricted user lands on can say
+why. Control Center writes replace the whole ``access`` value, so restoring access there
+clears the notice too.
+
+This module only reads and builds those values. It imports nothing from ``config`` or
+``functions_settings``: reading the signed-in user's settings, and restoring an expired
+suspension, happen in ``functions_authentication.get_user_access_restriction``.
+"""
+
+from datetime import datetime, timezone
+
+
+ACCESS_RESTRICTION_KIND_SUSPENDED = 'suspended'
+ACCESS_RESTRICTION_KIND_BLOCKED = 'blocked'
+ACCESS_RESTRICTION_KINDS = (
+ ACCESS_RESTRICTION_KIND_SUSPENDED,
+ ACCESS_RESTRICTION_KIND_BLOCKED,
+)
+ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION = 'safety_violation'
+
+# What the gate answers an API call from a restricted user with, and where it sends a page.
+ACCESS_RESTRICTED_ERROR = 'access_restricted'
+V2_ACCESS_RESTRICTED_PATH = '/v2/access-restricted'
+CLASSIC_ACCESS_RESTRICTED_PATH = '/access-restricted'
+V2_ACCESS_RESTRICTION_API_PATH = '/api/v2/access-restriction'
+ACCESS_RESTRICTION_PATHS = frozenset({
+ V2_ACCESS_RESTRICTED_PATH,
+ CLASSIC_ACCESS_RESTRICTED_PATH,
+ V2_ACCESS_RESTRICTION_API_PATH,
+})
+
+ACCESS_STATE_ALLOW = 'allow'
+ACCESS_STATE_EXPIRED = 'expired'
+ACCESS_STATE_RESTRICTED = 'restricted'
+
+NOTICE_TITLE_MAX_LENGTH = 200
+NOTICE_MESSAGE_MAX_LENGTH = 4000
+NOTICE_REFERENCE_MAX_LENGTH = 200
+
+LEGACY_PERMANENT_DENY_REASON = 'Access denied by administrator'
+LEGACY_TIMED_DENY_PREFIX = 'Access denied until '
+
+# Shown when a restriction carries no notice, such as one applied from Control Center.
+_DEFAULT_COPY = {
+ ACCESS_RESTRICTION_KIND_SUSPENDED: (
+ 'Your access is temporarily suspended',
+ 'An administrator has temporarily suspended your access to this application. '
+ 'Your access is restored automatically at the time shown.',
+ ),
+ ACCESS_RESTRICTION_KIND_BLOCKED: (
+ 'Your access has been blocked',
+ 'An administrator has blocked your access to this application. '
+ 'Contact your administrator if you have questions about this decision.',
+ ),
+}
+
+_API_SENTENCES = {
+ ACCESS_RESTRICTION_KIND_SUSPENDED: 'Your access to this application is temporarily suspended.',
+ ACCESS_RESTRICTION_KIND_BLOCKED: 'Your access to this application has been blocked by an administrator.',
+}
+
+PUBLIC_RESTRICTION_FIELDS = ('kind', 'until', 'title', 'message', 'reference_id')
+
+
+def _clean_text(value, max_length):
+ """Return trimmed text no longer than ``max_length``, or '' for anything that isn't text."""
+ if not isinstance(value, str):
+ return ''
+ return value.strip()[:max_length]
+
+
+def parse_access_restore_time(value):
+ """Return a stored restore time as an aware UTC datetime, or None when it can't be read.
+
+ A value without a UTC offset is read as UTC, as Control Center reads it.
+ """
+ if not isinstance(value, str) or not value.strip():
+ return None
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def _restriction(kind, until=None, title='', message='', reference_id=None):
+ default_title, default_message = _DEFAULT_COPY[kind]
+ return {
+ 'kind': kind,
+ 'until': until if kind == ACCESS_RESTRICTION_KIND_SUSPENDED else None,
+ 'title': title or default_title,
+ 'message': message or default_message,
+ 'reference_id': reference_id or None,
+ }
+
+
+def _notice_copy(notice, kind):
+ """Return ``(title, message, reference_id)`` from a stored notice that matches ``kind``.
+
+ A notice written for a different kind of restriction describes something that no longer
+ applies, so it is ignored and the generic copy is used instead.
+ """
+ if not isinstance(notice, dict) or notice.get('kind') != kind:
+ return '', '', None
+ return (
+ _clean_text(notice.get('title'), NOTICE_TITLE_MAX_LENGTH),
+ _clean_text(notice.get('message'), NOTICE_MESSAGE_MAX_LENGTH),
+ _clean_text(notice.get('reference_id'), NOTICE_REFERENCE_MAX_LENGTH) or None,
+ )
+
+
+def describe_access_restriction(access_settings, now=None):
+ """Return ``(state, restriction)`` for a stored ``settings.access`` value.
+
+ ``state`` is ``'allow'``, ``'expired'`` -- a suspension whose restore time has passed,
+ which the caller restores -- or ``'restricted'``, the only state that comes with a
+ restriction. A deny whose restore time can't be read stays in force with no end date,
+ as it always has, and is described as a block.
+
+ The restriction holds ``kind``, ``until`` (ISO 8601 UTC, suspensions only), ``title``,
+ ``message`` and ``reference_id``.
+ """
+ if not isinstance(access_settings, dict) or access_settings.get('status') != 'deny':
+ return ACCESS_STATE_ALLOW, None
+
+ kind = ACCESS_RESTRICTION_KIND_BLOCKED
+ until = None
+ if access_settings.get('datetime_to_allow'):
+ restore_at = parse_access_restore_time(access_settings.get('datetime_to_allow'))
+ if restore_at is not None:
+ if (now or datetime.now(timezone.utc)) >= restore_at:
+ return ACCESS_STATE_EXPIRED, None
+ kind = ACCESS_RESTRICTION_KIND_SUSPENDED
+ until = restore_at.isoformat()
+
+ title, message, reference_id = _notice_copy(access_settings.get('notice'), kind)
+ return ACCESS_STATE_RESTRICTED, _restriction(kind, until, title, message, reference_id)
+
+
+def legacy_access_denied_reason(access_settings, restriction):
+ """Return the reason ``check_user_access_status`` has always given for a restriction."""
+ if restriction.get('kind') == ACCESS_RESTRICTION_KIND_SUSPENDED:
+ return f"{LEGACY_TIMED_DENY_PREFIX}{access_settings.get('datetime_to_allow')}"
+ return LEGACY_PERMANENT_DENY_REASON
+
+
+def fallback_access_restriction(reason=None):
+ """Describe a restriction from its legacy reason alone, with the generic copy.
+
+ Used when the access check refused a user but the stored details could not be read
+ again, so the response still says whether access returns on its own.
+ """
+ text = reason if isinstance(reason, str) else ''
+ if text.startswith(LEGACY_TIMED_DENY_PREFIX):
+ restore_at = parse_access_restore_time(text[len(LEGACY_TIMED_DENY_PREFIX):])
+ if restore_at is not None:
+ return _restriction(ACCESS_RESTRICTION_KIND_SUSPENDED, restore_at.isoformat())
+ return _restriction(ACCESS_RESTRICTION_KIND_BLOCKED)
+
+
+def public_access_restriction(restriction):
+ """Return only the fields of a restriction that are shown to the restricted user."""
+ restriction = restriction if isinstance(restriction, dict) else {}
+ return {field: restriction.get(field) for field in PUBLIC_RESTRICTION_FIELDS}
+
+
+def access_restricted_sentence(restriction):
+ """Return the plain sentence an API response gives a restricted user."""
+ kind = (restriction or {}).get('kind')
+ return _API_SENTENCES.get(kind, _API_SENTENCES[ACCESS_RESTRICTION_KIND_BLOCKED])
+
+
+def build_access_restriction_notice(
+ kind,
+ title,
+ message,
+ until=None,
+ reference_id=None,
+ source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION,
+ applied_at=None,
+):
+ """Return the ``notice`` stored beside a restriction so the restricted user can see why."""
+ if kind not in ACCESS_RESTRICTION_KINDS:
+ raise ValueError(f'Unsupported access restriction kind: {kind}')
+ return {
+ 'kind': kind,
+ 'title': _clean_text(title, NOTICE_TITLE_MAX_LENGTH),
+ 'message': _clean_text(message, NOTICE_MESSAGE_MAX_LENGTH),
+ 'until': until if kind == ACCESS_RESTRICTION_KIND_SUSPENDED and until else None,
+ 'source': source,
+ 'reference_id': _clean_text(reference_id, NOTICE_REFERENCE_MAX_LENGTH) or None,
+ 'applied_at': applied_at or datetime.now(timezone.utc).isoformat(),
+ }
+
+
+def is_v2_request_path(path):
+ """True for V2 pages and V2 API calls. Mirrors ``_is_v2_request_path`` in app.py."""
+ return path in ('/v2', '/api/v2') or path.startswith('/v2/') or path.startswith('/api/v2/')
+
+
+def access_restricted_page_path(request_path):
+ """Return the Access restricted page for the interface ``request_path`` belongs to."""
+ if is_v2_request_path(str(request_path or '')):
+ return V2_ACCESS_RESTRICTED_PATH
+ return CLASSIC_ACCESS_RESTRICTED_PATH
diff --git a/application/single_app/functions_approvals.py b/application/single_app/functions_approvals.py
index 22fe17adf..01ed35a7a 100644
--- a/application/single_app/functions_approvals.py
+++ b/application/single_app/functions_approvals.py
@@ -11,6 +11,7 @@
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any
from urllib.parse import quote
+from azure.core import MatchConditions
from content_screening.contracts import (
ScreeningConflictError,
ScreeningError,
@@ -35,6 +36,11 @@
is_m365_approval_subject,
sanitize_m365_approval,
)
+from functions_safety_remediation import (
+ SAFETY_REQUEST_DENIED,
+ SAFETY_REQUEST_EXPIRED,
+ release_safety_log_after_approval_decision,
+)
# Approval request statuses
STATUS_PENDING = "pending"
@@ -715,6 +721,9 @@ def deny_request(
debug_print(f"Request denied: {approval_id}")
_clear_pending_admin_notifications(approval_id)
+
+ if approval.get('request_type') in SAFETY_USER_APPROVAL_TYPES:
+ _release_denied_safety_request(approval, auto_denied)
# Create notification for requester (only if not auto-denied)
if not auto_denied:
@@ -755,6 +764,83 @@ def deny_request(
raise
+def _release_denied_safety_request(approval: Dict[str, Any], auto_denied: bool) -> None:
+ """Unlock the violation a denied or expired warn, suspend or block request was for.
+
+ The denial itself has already been saved. A failure here is logged rather than raised:
+ the violation is settled again the next time a reviewer lists or opens it.
+ """
+ try:
+ release_safety_log_after_approval_decision(
+ approval,
+ SAFETY_REQUEST_EXPIRED if auto_denied else SAFETY_REQUEST_DENIED,
+ )
+ except Exception as exc:
+ log_event("[APPROVALS] The safety violation could not be released after its request was denied.", {
+ 'approval_id': approval.get('id'),
+ 'request_type': approval.get('request_type'),
+ 'auto_denied': auto_denied,
+ 'error_type': type(exc).__name__,
+ }, level=logging.WARNING)
+
+
+def withdraw_approval_request(
+ approval_id: str,
+ group_id: str,
+ withdrawn_by_id: str,
+ withdrawn_by_email: str,
+ withdrawn_by_name: str,
+ comment: str,
+) -> Optional[Dict[str, Any]]:
+ """Deny a pending request its own creator could not record, and remove its notices.
+
+ Used when the record a request was raised from changed while the request was being
+ created, so nothing links the two and the request must never be approved. The denial is
+ written only while the request is still pending, conditionally on the version read, so it
+ never overwrites a decision made meanwhile. The notices the request sent -- that it waits
+ for reviewers, and that it was submitted -- are removed: the requester was already told
+ nothing was requested. Returns the denied request, or None when it was already decided.
+ """
+ current = cosmos_approvals_container.read_item(item=approval_id, partition_key=group_id)
+ if current.get('status') != STATUS_PENDING:
+ log_event("[APPROVALS] A request to withdraw was already decided, so it was left as it is.", {
+ 'approval_id': approval_id,
+ 'request_type': current.get('request_type'),
+ 'status': current.get('status'),
+ }, level=logging.WARNING)
+ return None
+
+ current.update({
+ 'status': STATUS_DENIED,
+ 'approved_by_id': withdrawn_by_id,
+ 'approved_by_email': withdrawn_by_email,
+ 'approved_by_name': withdrawn_by_name,
+ 'approved_at': datetime.utcnow().isoformat(),
+ 'approval_comment': comment,
+ 'ttl': -1,
+ })
+ etag = current.get('_etag')
+ if etag:
+ stored = cosmos_approvals_container.replace_item(
+ item=approval_id,
+ body=current,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ stored = cosmos_approvals_container.upsert_item(current)
+
+ _clear_pending_admin_notifications(approval_id)
+ _clear_requester_pending_notification(approval_id)
+ log_event("[APPROVALS] Request withdrawn", {
+ 'approval_id': approval_id,
+ 'request_type': current.get('request_type'),
+ 'group_id': group_id,
+ 'withdrawn_by': withdrawn_by_email,
+ })
+ return stored if isinstance(stored, dict) else current
+
+
def mark_approval_executed(
approval_id: str,
group_id: str,
@@ -1104,6 +1190,104 @@ def _can_user_deny(
return _can_user_approve(approval, user_id, user_roles)
+APPROVAL_STATS_OLDEST_LIMIT = 5
+APPROVAL_STATS_SCAN_LIMIT = 10000
+APPROVAL_STATS_OUTCOMES = {
+ STATUS_APPROVED: 'approved',
+ STATUS_DENIED: 'denied',
+ STATUS_EXECUTED: 'executed',
+ STATUS_FAILED: 'failed',
+ STATUS_AUTO_DENIED: 'expired',
+ 'expired': 'expired',
+}
+
+
+def _parse_approval_time(value: Any) -> Optional[datetime]:
+ """Return a stored approval time as naive UTC, as the approval records write it."""
+ if isinstance(value, datetime):
+ parsed = value
+ elif isinstance(value, str) and value.strip():
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ else:
+ return None
+ if parsed.tzinfo is not None:
+ parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+ return parsed
+
+
+def summarize_visible_approvals(
+ approvals: List[Dict[str, Any]],
+ user_id: str,
+ user_roles: List[str],
+ days: int,
+ now: Optional[datetime] = None,
+) -> Dict[str, Any]:
+ """Summarize approval requests for the Approvals dashboard.
+
+ ``approvals`` must be what ``get_pending_approvals`` returned for this user, which has
+ already applied the visibility rules; nothing here widens them, so a request the caller
+ cannot see is never counted. "Waiting on me" counts pending requests the caller may
+ approve; decided requests are counted when their decision falls in the last ``days``.
+ """
+ current = now or datetime.utcnow()
+ window_start = current - timedelta(days=days)
+ soon = current + timedelta(hours=24)
+ safe_roles = _normalize_user_roles(user_roles)
+
+ pending = [approval for approval in approvals if approval.get('status') == STATUS_PENDING]
+ actionable = [approval for approval in pending if _can_user_approve(approval, user_id, safe_roles)]
+
+ decided = {'approved': 0, 'denied': 0, 'executed': 0, 'failed': 0, 'expired': 0}
+ for approval in approvals:
+ outcome = APPROVAL_STATS_OUTCOMES.get(str(approval.get('status') or '').lower())
+ if not outcome:
+ continue
+ decided_at = None
+ for field in ('executed_at', 'approved_at', 'decided_at', 'updated_at'):
+ decided_at = _parse_approval_time(approval.get(field))
+ if decided_at:
+ break
+ if decided_at and decided_at >= window_start:
+ decided[outcome] += 1
+
+ pending_by_type: Dict[str, int] = {}
+ for approval in pending:
+ request_type = str(approval.get('request_type') or 'unknown')
+ pending_by_type[request_type] = pending_by_type.get(request_type, 0) + 1
+
+ def expires_soon(approval):
+ expires_at = _parse_approval_time(approval.get('expires_at'))
+ return bool(expires_at and expires_at <= soon)
+
+ oldest = sorted(actionable, key=_get_approval_sort_value)[:APPROVAL_STATS_OLDEST_LIMIT]
+ return {
+ 'window': {'days': days},
+ 'waiting_on_me': len(actionable),
+ 'my_pending_requests': sum(1 for approval in pending if approval.get('requester_id') == user_id),
+ 'expiring_within_24h': sum(1 for approval in actionable if expires_soon(approval)),
+ 'pending_visible': len(pending),
+ 'decided_in_window': decided,
+ 'pending_by_type': [
+ {'request_type': request_type, 'count': count}
+ for request_type, count in sorted(pending_by_type.items(), key=lambda pair: (-pair[1], pair[0]))
+ ],
+ 'oldest_actionable': [
+ {
+ 'id': approval.get('id'),
+ 'group_id': approval.get('group_id'),
+ 'request_type': approval.get('request_type'),
+ 'group_name': approval.get('group_name'),
+ 'created_at': approval.get('created_at'),
+ 'expires_at': approval.get('expires_at'),
+ }
+ for approval in oldest
+ ],
+ }
+
+
def _create_approval_notifications(
approval: Dict[str, Any],
group: Optional[Dict[str, Any]]
@@ -1283,6 +1467,20 @@ def _create_requester_pending_notification(approval: Dict[str, Any]) -> None:
debug_print(f"Error notifying requester of pending approval {approval['id']}: {e}")
+def _clear_requester_pending_notification(approval_id: str) -> None:
+ """Remove the requester's "request submitted" notice for a request that was withdrawn."""
+ try:
+ delete_notifications_by_metadata(
+ metadata_filters={'approval_id': approval_id},
+ notification_types=['approval_request_pending_submitter'],
+ )
+ except Exception as e:
+ log_event("[APPROVALS] Error clearing the requester's pending notification", {
+ 'error_type': type(e).__name__,
+ 'approval_id': approval_id,
+ }, level=logging.WARNING)
+
+
def _clear_pending_admin_notifications(approval_id: str, *, safe_errors=False) -> None:
"""Remove stale pending-review notifications once an approval is resolved."""
try:
diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py
index 1d466e4a4..203b64024 100644
--- a/application/single_app/functions_authentication.py
+++ b/application/single_app/functions_authentication.py
@@ -6,6 +6,17 @@
from flask import has_request_context
from config import *
+from functions_access_restriction import (
+ ACCESS_RESTRICTED_ERROR,
+ ACCESS_STATE_EXPIRED,
+ ACCESS_STATE_RESTRICTED,
+ access_restricted_page_path,
+ access_restricted_sentence,
+ describe_access_restriction,
+ fallback_access_restriction,
+ legacy_access_denied_reason,
+ public_access_restriction,
+)
from functions_appinsights import log_event
from functions_settings import *
from functions_debug import debug_print
@@ -743,53 +754,98 @@ def decorated_function(*args, **kwargs):
return f(*args, **kwargs)
return decorated_function
+def _read_user_access_restriction(user_id):
+ """Return ``(restriction, reason)`` for the user's stored access setting.
+
+ ``restriction`` is None when access is allowed. A suspension whose restore time has
+ passed is restored here, which also clears its notice. Raises when the settings can't
+ be read; callers allow access then, so a storage fault never locks a user out.
+ """
+ # Imported at call time, as the access check always has been, so the check uses the
+ # settings functions functions_settings holds when it runs.
+ from functions_settings import get_user_settings, update_user_settings
+
+ user_settings = get_user_settings(user_id) or {}
+ stored_settings = user_settings.get('settings') if isinstance(user_settings, dict) else None
+ access_settings = stored_settings.get('access') if isinstance(stored_settings, dict) else None
+
+ state, restriction = describe_access_restriction(access_settings)
+ if state == ACCESS_STATE_EXPIRED:
+ update_user_settings(user_id, {
+ 'access': {
+ 'status': 'allow',
+ 'datetime_to_allow': None
+ }
+ })
+ return None, None
+ if state != ACCESS_STATE_RESTRICTED:
+ return None, None
+ return restriction, legacy_access_denied_reason(access_settings, restriction)
+
+
+def get_user_access_restriction(user_id):
+ """Return the user's current access restriction, or None when access is allowed.
+
+ The restriction holds ``kind`` ('suspended' or 'blocked'), ``until`` (ISO 8601 UTC,
+ suspensions only), ``title``, ``message`` and ``reference_id`` -- the user's own data
+ only. Like ``check_user_access_status``, an expired suspension is restored and a failure
+ to read the settings allows access.
+ """
+ if not user_id:
+ return None
+ try:
+ restriction, _reason = _read_user_access_restriction(user_id)
+ except Exception as e:
+ log_event(
+ "[ACCESS_RESTRICTION] Access restriction could not be read; access allowed.",
+ extra={"user_id": user_id, "error_type": type(e).__name__},
+ level=logging.WARNING,
+ )
+ return None
+ return restriction
+
+
def check_user_access_status(user_id):
"""
Check if user access is currently allowed based on Control Center settings.
Returns (is_allowed: bool, reason: str)
"""
try:
- from functions_settings import get_user_settings
- user_settings = get_user_settings(user_id)
-
- access_settings = user_settings.get('settings', {}).get('access', {})
- status = access_settings.get('status', 'allow')
-
- if status == 'allow':
- return True, None
-
- if status == 'deny':
- datetime_to_allow = access_settings.get('datetime_to_allow')
- if datetime_to_allow:
- try:
- # Check if time-based restriction has expired
- allow_time = datetime.fromisoformat(datetime_to_allow.replace('Z', '+00:00'))
- current_time = datetime.now(timezone.utc)
-
- if current_time >= allow_time:
- # Time-based restriction has expired, automatically restore access
- from functions_settings import update_user_settings
- update_user_settings(user_id, {
- 'access': {
- 'status': 'allow',
- 'datetime_to_allow': None
- }
- })
- return True, None
- else:
- return False, f"Access denied until {datetime_to_allow}"
- except ValueError:
- # Invalid datetime format, treat as permanent deny
- return False, "Access denied by administrator"
- else:
- return False, "Access denied by administrator"
-
- return True, None # Default to allow if status is unknown
-
+ restriction, reason = _read_user_access_restriction(user_id)
except Exception as e:
debug_print(f"Error checking user access status: {e}")
return True, None # Default to allow on error to prevent lockouts
+ if restriction is None:
+ return True, None
+ return False, reason
+
+
+def _is_api_request():
+ return (
+ request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
+ ) or request.path.startswith('/api/')
+
+
+def access_restricted_response(user_id, reason=None):
+ """Answer a request from a user whose access is restricted.
+
+ API calls get a 403 that says why, with the caller's own restriction. Page requests
+ are sent to the Access restricted page of the interface they came from, which is
+ served to restricted users so they can read the notice and sign out.
+ """
+ restriction = get_user_access_restriction(user_id) or fallback_access_restriction(reason)
+ restricted_url = access_restricted_page_path(request.path)
+ if _is_api_request():
+ return jsonify({
+ "error": ACCESS_RESTRICTED_ERROR,
+ "message": access_restricted_sentence(restriction),
+ "restriction": public_access_restriction(restriction),
+ "restricted_url": restricted_url,
+ }), 403
+ return redirect(restricted_url)
+
+
def user_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -806,10 +862,7 @@ def decorated_function(*args, **kwargs):
if user_id:
is_allowed, reason = check_user_access_status(user_id)
if not is_allowed:
- if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html or request.path.startswith('/api/'):
- return jsonify({"error": "Access Denied", "message": reason}), 403
- else:
- return f"Access Denied: {reason}", 403
+ return access_restricted_response(user_id, reason)
return f(*args, **kwargs)
return decorated_function
diff --git a/application/single_app/functions_chat_content_review.py b/application/single_app/functions_chat_content_review.py
index f12d8ed26..fb79effca 100644
--- a/application/single_app/functions_chat_content_review.py
+++ b/application/single_app/functions_chat_content_review.py
@@ -28,6 +28,15 @@
)
+# The messages the unchecked queue holds: allowed through while a required check could not
+# finish. Shared by the queue's list and the Review center dashboard's count.
+UNCHECKED_CHAT_CONTENT_WHERE = (
+ "c.metadata.chat_content_checks.status = 'not_checked' "
+ "AND c.metadata.chat_content_checks.decision = 'allow_unchecked' "
+ "AND c.role IN ('user', 'assistant')"
+)
+
+
class ChatContentReviewError(ScreeningError):
code = "chat_content_check_unavailable"
public_message = "The chat content check could not be completed. Reload and try again."
@@ -226,11 +235,7 @@ def list_unchecked_chat_content(
next_cursor = None
while index < len(sources) and len(items) < page_size:
current_source = sources[index]
- query = (
- "SELECT * FROM c WHERE c.metadata.chat_content_checks.status = 'not_checked' "
- "AND c.metadata.chat_content_checks.decision = 'allow_unchecked' "
- "AND c.role IN ('user', 'assistant')"
- )
+ query = f"SELECT * FROM c WHERE {UNCHECKED_CHAT_CONTENT_WHERE}"
parameters = []
if current_source == "shared":
query += " AND NOT IS_DEFINED(c.metadata.source_message_id)"
@@ -262,6 +267,28 @@ def list_unchecked_chat_content(
return {"items": items, "continuation": _encode_cursor(next_cursor, signature)}
+def count_unchecked_chat_content(*, stores=None):
+ """How many messages the unchecked queue holds across chat and shared conversations.
+
+ Counts what ``list_unchecked_chat_content`` lists with no filters, with one count query
+ per conversation source, for the Review center dashboard.
+ """
+ stores = stores or get_chat_review_stores()
+ total = 0
+ for current_source in ("chat", "shared"):
+ query = f"SELECT VALUE COUNT(1) FROM c WHERE {UNCHECKED_CHAT_CONTENT_WHERE}"
+ if current_source == "shared":
+ query += " AND NOT IS_DEFINED(c.metadata.source_message_id)"
+ values = stores.message_container(current_source).query_items(
+ query=query, parameters=[], enable_cross_partition_query=True,
+ )
+ total += sum(
+ int(value) for value in values
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
+ )
+ return total
+
+
def _recheck_text(message, conversation, summary):
source = summary.get("source") or {}
if source.get("kind") == "orchestration_turn":
diff --git a/application/single_app/functions_notifications.py b/application/single_app/functions_notifications.py
index cd13df387..97c819f65 100644
--- a/application/single_app/functions_notifications.py
+++ b/application/single_app/functions_notifications.py
@@ -214,6 +214,11 @@
'icon': 'bi-shield-lock',
'color': 'danger'
},
+ # A reviewer's response to feedback the user sent, sent only when the reviewer chooses to.
+ 'feedback_response': {
+ 'icon': 'bi-chat-heart',
+ 'color': 'info'
+ },
'agent_template_pending_admin': {
'icon': 'bi-layers',
'color': 'warning'
diff --git a/application/single_app/functions_review_center.py b/application/single_app/functions_review_center.py
new file mode 100644
index 000000000..de48d3d51
--- /dev/null
+++ b/application/single_app/functions_review_center.py
@@ -0,0 +1,406 @@
+# functions_review_center.py
+
+"""Shared helpers for the admin Review center's feedback and safety APIs.
+
+The Feedback and Safety review APIs both serve the V2 Review center. This module keeps the
+parts they share in one place: the dashboard window an analytics request may ask for, the
+daily series a chart reads, the batched lookup that turns the owners of records into
+display names, and the bulk operation envelope with its per-call cap.
+
+Every user id looked up here comes from a review record the caller is already authorized
+to read through the route's role decorator; nothing here accepts a user id from a client
+as a reason to read a profile.
+"""
+
+import copy
+import logging
+import re
+from datetime import datetime, timedelta, timezone
+
+from azure.core import MatchConditions
+from azure.cosmos import exceptions as cosmos_exceptions
+
+from config import cosmos_user_settings_container
+from functions_access_restriction import ACCESS_STATE_RESTRICTED, describe_access_restriction
+from functions_appinsights import log_event
+
+
+REVIEW_WINDOW_DAYS = (7, 30, 90)
+REVIEW_BULK_MAX_OPERATIONS = 100
+REVIEW_IDS_CAP = 500
+REVIEW_SEARCH_MAX_LENGTH = 200
+REVIEW_NAME_BATCH = 100
+REVIEW_EXCERPT_LENGTH = 160
+REVIEW_WRITE_ATTEMPTS = 3
+REVIEW_BULK_OPERATIONS = ('update', 'archive', 'delete')
+# Every key an operation may carry. Unknown keys are refused, so a later field, such as an
+# attribution to the suggestion that proposed the change, is added here on purpose.
+REVIEW_BULK_OPERATION_KEYS = frozenset({'id', 'op', 'etag', 'changes', 'archived'})
+REVIEW_RECORD_ID_MAX_LENGTH = 200
+
+REVIEW_RECORD_CHANGED_CODE = 'record_changed'
+REVIEW_NOT_FOUND_CODE = 'not_found'
+REVIEW_INVALID_OPERATION_CODE = 'invalid_operation'
+REVIEW_DUPLICATE_OPERATION_CODE = 'duplicate_operation'
+REVIEW_OPERATION_FAILED_CODE = 'operation_failed'
+
+_DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
+
+
+class ReviewRequestError(ValueError):
+ """A request the Review center APIs refuse as a whole, with a stable code."""
+
+ def __init__(self, message, code='invalid_request', status=400):
+ super().__init__(message)
+ self.message = message
+ self.code = code
+ self.status = status
+
+
+class ReviewRecordConflict(Exception):
+ """A record kept changing while it was being written."""
+
+
+def replace_review_record(container, item_id, mutate, *, base_item=None, attempts=REVIEW_WRITE_ATTEMPTS):
+ """Apply ``mutate`` to a record and replace it on the condition that it has not changed.
+
+ ``mutate(record)`` changes the record in place and returns False to stop without
+ writing. The first attempt applies it to ``base_item`` when given; after a conflict, to
+ a fresh read, so only what ``mutate`` sets is ever written over another writer's change.
+ Returns the stored record, or None when ``mutate`` stopped. Raises
+ ``ReviewRecordConflict`` when every attempt conflicts, and lets a missing record's
+ ``CosmosResourceNotFoundError`` through. The record's id must be its partition key.
+ """
+ current = base_item
+ for _attempt in range(max(1, attempts)):
+ if current is None:
+ current = container.read_item(item=item_id, partition_key=item_id)
+ record = copy.deepcopy(current)
+ if mutate(record) is False:
+ return None
+ etag = current.get('_etag')
+ try:
+ if etag:
+ stored = container.replace_item(
+ item=item_id,
+ body=record,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ stored = container.upsert_item(record)
+ except cosmos_exceptions.CosmosAccessConditionFailedError:
+ current = None
+ continue
+ return stored if isinstance(stored, dict) else record
+ raise ReviewRecordConflict(f'Record {item_id} changed while it was being written.')
+
+
+def parse_review_window(value):
+ """Return the requested dashboard window in days, or None when none was asked for.
+
+ Only the windows the dashboards offer are accepted, so a request cannot ask the server
+ to scan an arbitrary history.
+ """
+ if value is None or str(value).strip() == '':
+ return None
+ text = str(value).strip()
+ if not text.isdigit() or int(text) not in REVIEW_WINDOW_DAYS:
+ raise ReviewRequestError('The window must be 7, 30 or 90 days.', code='invalid_window')
+ return int(text)
+
+
+def parse_review_date(value):
+ """Return a ``YYYY-MM-DD`` day filter, or None. Anything else is refused."""
+ if value is None or str(value).strip() == '':
+ return None
+ text = str(value).strip()
+ if not _DATE_RE.match(text):
+ raise ReviewRequestError('The date must be YYYY-MM-DD.', code='invalid_date')
+ try:
+ datetime.strptime(text, '%Y-%m-%d')
+ except ValueError as exc:
+ raise ReviewRequestError('The date must be YYYY-MM-DD.', code='invalid_date') from exc
+ return text
+
+
+def parse_review_timestamp(value):
+ """Return a stored timestamp as an aware UTC datetime, or None when it can't be read.
+
+ Records written with ``datetime.utcnow().isoformat()`` carry no offset; they are UTC.
+ """
+ if isinstance(value, datetime):
+ parsed = value
+ elif isinstance(value, str) and value.strip():
+ try:
+ parsed = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
+ except ValueError:
+ return None
+ else:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ return parsed.astimezone(timezone.utc)
+
+
+def review_day(value):
+ """The UTC day a timestamp falls on, as ``YYYY-MM-DD``, or None."""
+ parsed = parse_review_timestamp(value)
+ return parsed.date().isoformat() if parsed else None
+
+
+def review_window(days, now=None):
+ """Describe a window of ``days`` UTC days ending today, oldest day first."""
+ current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
+ end = current.date()
+ dates = [(end - timedelta(days=offset)).isoformat() for offset in range(days - 1, -1, -1)]
+ start = datetime.combine(end - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc)
+ return {
+ 'days': days,
+ 'start_date': dates[0],
+ 'end_date': dates[-1],
+ 'dates': dates,
+ 'start': start,
+ }
+
+
+def in_review_window(value, window):
+ """True when a timestamp falls inside the window."""
+ parsed = parse_review_timestamp(value)
+ return bool(parsed and parsed >= window['start'])
+
+
+def normalize_review_search(value):
+ """Return search text as the lists match it: trimmed, lowercased and bounded."""
+ if not isinstance(value, str):
+ return ''
+ return value.strip().lower()[:REVIEW_SEARCH_MAX_LENGTH]
+
+
+def review_text_matches(needle, *values):
+ """True when the search text appears in any of the values."""
+ if not needle:
+ return True
+ for value in values:
+ if isinstance(value, str) and needle in value.lower():
+ return True
+ return False
+
+
+def review_excerpt(value, limit=REVIEW_EXCERPT_LENGTH):
+ """The first ``limit`` characters of text on one line, for a list row."""
+ if not isinstance(value, str):
+ return ''
+ flattened = ' '.join(value.split())
+ return flattened if len(flattened) <= limit else f"{flattened[:limit - 1].rstrip()}…"
+
+
+def _normalize_user_ids(user_ids):
+ return [
+ user_id for user_id in dict.fromkeys(user_ids or [])
+ if isinstance(user_id, str) and user_id.strip()
+ ]
+
+
+def resolve_review_users(user_ids, include_access=False, now=None):
+ """Return ``{user_id: {display_name, email[, access]}}`` with one query per batch.
+
+ Names are a convenience for the reviewer, so a failed lookup is logged and leaves the
+ affected ids out rather than failing the list. ``access`` describes whether the user is
+ restricted now: ``{'restricted': bool, 'kind': ..., 'until': ...}``.
+ """
+ pending = _normalize_user_ids(user_ids)
+ found = {}
+ projection = 'c.id, c.display_name, c.email'
+ if include_access:
+ projection += ', c.settings.access AS access'
+ query = f"SELECT {projection} FROM c WHERE ARRAY_CONTAINS(@ids, c.id)"
+ for start in range(0, len(pending), REVIEW_NAME_BATCH):
+ batch = pending[start:start + REVIEW_NAME_BATCH]
+ try:
+ rows = list(cosmos_user_settings_container.query_items(
+ query=query,
+ parameters=[{'name': '@ids', 'value': batch}],
+ enable_cross_partition_query=True,
+ ))
+ except Exception as exc:
+ log_event(
+ '[REVIEW_CENTER] User names could not be resolved for a review list.',
+ extra={'user_count': len(batch), 'error_type': type(exc).__name__},
+ level=logging.WARNING,
+ )
+ continue
+ for row in rows:
+ if not isinstance(row, dict) or row.get('id') not in batch:
+ continue
+ entry = {
+ 'display_name': str(row.get('display_name') or '').strip(),
+ 'email': str(row.get('email') or '').strip(),
+ }
+ if include_access:
+ state, restriction = describe_access_restriction(row.get('access'), now=now)
+ entry['access'] = {
+ 'restricted': state == ACCESS_STATE_RESTRICTED,
+ 'kind': (restriction or {}).get('kind'),
+ 'until': (restriction or {}).get('until'),
+ }
+ found[row['id']] = entry
+ return found
+
+
+def review_user_label(user_id, users):
+ """The name a reviewer reads for a user: display name, then email, then id."""
+ entry = (users or {}).get(user_id) or {}
+ return entry.get('display_name') or entry.get('email') or user_id or ''
+
+
+def cap_review_ids(ids):
+ """The ids a "select all matching" may act on, capped, and whether the cap applied."""
+ unique = [item for item in dict.fromkeys(ids or []) if isinstance(item, str) and item]
+ return {
+ 'ids': unique[:REVIEW_IDS_CAP],
+ 'total': len(unique),
+ 'capped': len(unique) > REVIEW_IDS_CAP,
+ 'cap': REVIEW_IDS_CAP,
+ }
+
+
+def _operation_error(index, record_id, op, message):
+ return {
+ 'index': index,
+ 'id': record_id if isinstance(record_id, str) else None,
+ 'op': op if isinstance(op, str) else None,
+ 'error': {
+ 'status': 400,
+ 'body': {'error': message, 'code': REVIEW_INVALID_OPERATION_CODE},
+ },
+ }
+
+
+def parse_review_bulk_operations(payload):
+ """Validate a bulk request and return its operations, in the order they were sent.
+
+ A request is ``{"operations": [...]}`` with 1 to 100 operations. Each operation is
+ ``{"id", "op", "etag"?, ...}``: ``update`` carries ``changes`` (the same fields the
+ single-record PATCH accepts), ``archive`` carries ``archived`` (a boolean) and
+ ``delete`` carries nothing more. ``etag``, when present, must match the stored record.
+
+ A malformed request as a whole raises ``ReviewRequestError``. A malformed operation is
+ returned with an ``error`` so the rest of the request still runs and the caller can
+ report it per item. A second operation on an id already in the request is refused.
+ """
+ if not isinstance(payload, dict) or set(payload) - {'operations'}:
+ raise ReviewRequestError('Send {"operations": [...]}.', code='invalid_request')
+ operations = payload.get('operations')
+ if not isinstance(operations, list) or not operations:
+ raise ReviewRequestError('Send at least one operation.', code='invalid_request')
+ if len(operations) > REVIEW_BULK_MAX_OPERATIONS:
+ raise ReviewRequestError(
+ f'Send at most {REVIEW_BULK_MAX_OPERATIONS} operations at a time.',
+ code='too_many_operations',
+ )
+
+ parsed = []
+ seen = set()
+ for index, raw in enumerate(operations):
+ if not isinstance(raw, dict):
+ parsed.append(_operation_error(index, None, None, 'Each operation must be an object.'))
+ continue
+ record_id = raw.get('id')
+ op = raw.get('op')
+ if set(raw) - REVIEW_BULK_OPERATION_KEYS:
+ parsed.append(_operation_error(index, record_id, op, 'The operation has fields this API does not accept.'))
+ continue
+ if not isinstance(record_id, str) or not record_id.strip() or len(record_id) > REVIEW_RECORD_ID_MAX_LENGTH:
+ parsed.append(_operation_error(index, record_id, op, 'Each operation needs the id of a record.'))
+ continue
+ if op not in REVIEW_BULK_OPERATIONS:
+ parsed.append(_operation_error(index, record_id, op, 'The operation must be update, archive or delete.'))
+ continue
+ etag = raw.get('etag')
+ if etag is not None and (not isinstance(etag, str) or not etag):
+ parsed.append(_operation_error(index, record_id, op, 'The etag must be text.'))
+ continue
+ if op == 'update' and not isinstance(raw.get('changes'), dict):
+ parsed.append(_operation_error(index, record_id, op, 'An update needs its changes.'))
+ continue
+ if op == 'archive' and not isinstance(raw.get('archived'), bool):
+ parsed.append(_operation_error(index, record_id, op, 'The archived field must be a boolean.'))
+ continue
+ if (op != 'update' and 'changes' in raw) or (op != 'archive' and 'archived' in raw):
+ parsed.append(_operation_error(index, record_id, op, 'The operation has fields this API does not accept.'))
+ continue
+ if record_id in seen:
+ duplicate = _operation_error(index, record_id, op, 'This record already has an operation in this request.')
+ duplicate['error']['body']['code'] = REVIEW_DUPLICATE_OPERATION_CODE
+ parsed.append(duplicate)
+ continue
+ seen.add(record_id)
+ parsed.append({
+ 'index': index,
+ 'id': record_id,
+ 'op': op,
+ 'etag': etag,
+ 'changes': raw.get('changes') if op == 'update' else None,
+ 'archived': raw.get('archived') if op == 'archive' else None,
+ })
+ return parsed
+
+
+def review_bulk_result(operation, body, status):
+ """One operation's result: the single-record route's response body, with its outcome."""
+ body = body if isinstance(body, dict) else {}
+ result = {
+ 'index': operation.get('index'),
+ 'id': operation.get('id'),
+ 'op': operation.get('op'),
+ 'ok': 200 <= int(status) < 300,
+ 'status': int(status),
+ }
+ for key, value in body.items():
+ if key not in result:
+ result[key] = value
+ if not result['ok'] and 'code' not in result:
+ result['code'] = {
+ 404: REVIEW_NOT_FOUND_CODE,
+ 409: REVIEW_RECORD_CHANGED_CODE,
+ }.get(int(status), REVIEW_OPERATION_FAILED_CODE)
+ return result
+
+
+def summarize_review_bulk_results(results):
+ """The bulk response: every result in request order, and how many succeeded."""
+ ordered = sorted(results, key=lambda item: item.get('index') or 0)
+ succeeded = sum(1 for item in ordered if item.get('ok'))
+ return {
+ 'results': ordered,
+ 'succeeded': succeeded,
+ 'failed': len(ordered) - succeeded,
+ }
+
+
+def daily_counts(records, window, timestamp_of, group_of):
+ """Count records per day of the window, split by group.
+
+ Returns ``{'dates': [...], 'series': [{'key': group, 'counts': [...]}, ...]}`` with the
+ groups ordered by their total, largest first. ``group_of`` may return several groups
+ for one record, which is then counted once in each.
+ """
+ dates = window['dates']
+ index = {date: position for position, date in enumerate(dates)}
+ counts = {}
+ for record in records:
+ day = review_day(timestamp_of(record))
+ if day not in index:
+ continue
+ groups = group_of(record)
+ if isinstance(groups, str) or groups is None:
+ groups = [groups or 'Unknown']
+ for group in dict.fromkeys(groups):
+ series = counts.setdefault(group, [0] * len(dates))
+ series[index[day]] += 1
+ ordered = sorted(counts.items(), key=lambda item: (-sum(item[1]), str(item[0])))
+ return {
+ 'dates': list(dates),
+ 'series': [{'key': key, 'counts': values} for key, values in ordered],
+ }
diff --git a/application/single_app/functions_review_lifecycle.py b/application/single_app/functions_review_lifecycle.py
index 418b772dd..36ca6ab7b 100644
--- a/application/single_app/functions_review_lifecycle.py
+++ b/application/single_app/functions_review_lifecycle.py
@@ -7,9 +7,12 @@
ARCHIVE_STATE_ACTIVE = 'active'
ARCHIVE_STATE_ARCHIVED = 'archived'
+# Both: the Review center's dashboard counts and its links to what they counted.
+ARCHIVE_STATE_ALL = 'all'
ALLOWED_ARCHIVE_STATES = {
ARCHIVE_STATE_ACTIVE,
ARCHIVE_STATE_ARCHIVED,
+ ARCHIVE_STATE_ALL,
}
@@ -17,13 +20,15 @@ def normalize_archive_state(value):
"""Normalize an archive-state query value, defaulting to active records."""
normalized_value = str(value or ARCHIVE_STATE_ACTIVE).strip().lower()
if normalized_value not in ALLOWED_ARCHIVE_STATES:
- raise ValueError("Archive state must be 'active' or 'archived'.")
+ raise ValueError("Archive state must be 'active', 'archived' or 'all'.")
return normalized_value
def append_archive_query_filter(where_clauses, archive_state):
"""Add a backward-compatible archive predicate to a Cosmos SQL query."""
normalized_state = normalize_archive_state(archive_state)
+ if normalized_state == ARCHIVE_STATE_ALL:
+ return
if normalized_state == ARCHIVE_STATE_ARCHIVED:
where_clauses.append("IS_DEFINED(c.is_archived) AND c.is_archived = true")
else:
diff --git a/application/single_app/functions_safety_remediation.py b/application/single_app/functions_safety_remediation.py
index 806fea96b..45aae17f5 100644
--- a/application/single_app/functions_safety_remediation.py
+++ b/application/single_app/functions_safety_remediation.py
@@ -2,14 +2,26 @@
"""Helpers for safety violation remediation actions and notifications."""
+import copy
import logging
-from datetime import datetime
-from typing import Any, Dict, Optional
-
-from config import cosmos_safety_container
+import uuid
+from datetime import datetime, timedelta, timezone
+from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
+
+from azure.core import MatchConditions
+from azure.cosmos import exceptions as cosmos_exceptions
+
+from config import cosmos_approvals_container, cosmos_safety_container
+from functions_access_restriction import (
+ ACCESS_RESTRICTION_KIND_BLOCKED,
+ ACCESS_RESTRICTION_KIND_SUSPENDED,
+ ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION,
+ build_access_restriction_notice,
+ parse_access_restore_time,
+)
from functions_appinsights import log_event
from functions_debug import debug_print
-from functions_notifications import create_notification
+from functions_notifications import create_notification, mark_notification_read
from functions_settings import get_user_settings, update_user_settings
@@ -17,19 +29,353 @@
SAFETY_REMEDIATION_SUSPEND = 'SuspendUser'
SAFETY_REMEDIATION_BLOCK = 'BlockUser'
+# Warnings the signed-in user still has to acknowledge, read at most this many at a time.
+SAFETY_WARNING_PENDING_LIMIT = 20
+SAFETY_WARNING_WRITE_ATTEMPTS = 3
+SAFETY_WARNING_FALLBACK_MESSAGE = (
+ 'An administrator reviewed content you submitted and sent you this warning. '
+ 'Please review the acceptable use requirements before continuing.'
+)
+
+WARNING_ACKNOWLEDGMENT_PENDING = 'pending'
+WARNING_ACKNOWLEDGMENT_ACKNOWLEDGED = 'acknowledged'
+WARNING_ACKNOWLEDGMENT_NOT_TRACKED = 'not_tracked'
+
+# A reviewer can warn about the same violation again, which replaces the warning on it. An
+# acknowledgment of the earlier one is refused with this code rather than recorded against
+# a warning the user has not read.
+SAFETY_WARNING_REPLACED_CODE = 'safety_warning_replaced'
+SAFETY_WARNING_REPLACED_MESSAGE = (
+ 'A newer warning replaced this one. Read the newer warning, then acknowledge it.'
+)
+
+# A save claims the violation before it sends a warning, so of two overlapping saves -- a
+# double-click, or two reviewers -- only one sends. While the claim is held the violation is
+# 'sending', which never counts as a warning to acknowledge. A claim older than the time to
+# live is from a save that stopped before it finished: it no longer blocks, and it reads as
+# a failed send, which saving the warning again retries.
+SAFETY_WARNING_SENDING_STATUS = 'sending'
+SAFETY_WARNING_SEND_CLAIM_TTL = timedelta(minutes=5)
+SAFETY_WARNING_SEND_CLAIM_FIELDS = ('warning_send_claim_id', 'warning_send_claimed_at')
+SAFETY_WARNING_SEND_INTERRUPTED_MESSAGE = (
+ 'The save that was sending this warning did not finish, so it is not known whether the '
+ 'warning reached the user. Saving the warning again sends it again.'
+)
+
+# What a remediation request leaves on its violation. Pending locks the violation until the
+# request is decided; every other state is settled and leaves the violation editable.
+SAFETY_REQUEST_PENDING = 'pending'
+SAFETY_REQUEST_EXECUTED = 'executed'
+SAFETY_REQUEST_FAILED = 'failed'
+SAFETY_REQUEST_DENIED = 'denied'
+SAFETY_REQUEST_EXPIRED = 'expired'
+SAFETY_REQUEST_STATUSES = (
+ SAFETY_REQUEST_PENDING,
+ SAFETY_REQUEST_EXECUTED,
+ SAFETY_REQUEST_FAILED,
+ SAFETY_REQUEST_DENIED,
+ SAFETY_REQUEST_EXPIRED,
+)
+# A pending approval request is removed by Cosmos TTL this long after it was created
+# (functions_approvals.TTL_AUTO_DENY_SECONDS, which cannot be imported here without a
+# cycle). A violation whose request can no longer be found is settled as expired only once
+# the request could no longer exist, so a request that is slow to appear in a query never
+# unlocks its violation.
+SAFETY_APPROVAL_LIFETIME = timedelta(days=3)
+SAFETY_LOG_WRITE_ATTEMPTS = 3
+SAFETY_REQUEST_LOOKUP_BATCH = 100
+SAFETY_REQUEST_FAILED_MESSAGE = (
+ 'The approved action could not be completed. Open the approval request for details.'
+)
+SAFETY_REQUEST_NOT_CURRENT_MESSAGE = (
+ 'The safety violation is no longer waiting on this request, so nothing was changed. '
+ 'Open the violation to decide what to do now.'
+)
+# What a remediation decision rests on: the request the violation waits on, a warning being
+# sent, and the warning last recorded. A reviewer's save is written only while these are as
+# the save read them, so it never lands on another save's warning or request.
+SAFETY_REMEDIATION_STATE_FIELDS = (
+ 'action_request_status',
+ 'action_request_id',
+ 'warning_send_claim_id',
+ 'warning_notification_id',
+ 'warning_issued_at',
+)
+
+
+class SafetyLogConflict(Exception):
+ """A violation kept changing while it was being written."""
+
def get_safety_log_item(log_id: str) -> Dict[str, Any]:
"""Return a safety log item by its document id."""
return cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+def write_safety_log_updates(
+ log_id: str,
+ updates: Dict[str, Any],
+ *,
+ base_item: Optional[Dict[str, Any]] = None,
+ guard: Optional[Callable[[Dict[str, Any]], bool]] = None,
+ attempts: int = SAFETY_LOG_WRITE_ATTEMPTS,
+) -> Optional[Dict[str, Any]]:
+ """Merge ``updates`` onto the stored violation with an ETag-conditional replace.
+
+ The first attempt applies them to ``base_item``, the copy the caller already read, when
+ there is one; after a conflict, to a fresh read. Only the named fields are written, so a
+ concurrent write to any other field, such as the user acknowledging a warning, survives.
+ ``guard(current)`` can refuse a fresh read, for example one that has since moved on to
+ another request; nothing is written then and None is returned. Raises
+ ``SafetyLogConflict`` when every attempt conflicts, and lets a missing record's
+ ``CosmosResourceNotFoundError`` through.
+ """
+ current = base_item
+ for _attempt in range(max(1, attempts)):
+ if current is None:
+ current = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ if guard is not None and not guard(current):
+ return None
+ merged = copy.deepcopy(current)
+ merged.update(copy.deepcopy(updates or {}))
+ etag = current.get('_etag')
+ try:
+ if etag:
+ stored = cosmos_safety_container.replace_item(
+ item=log_id,
+ body=merged,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ stored = cosmos_safety_container.upsert_item(merged)
+ except cosmos_exceptions.CosmosAccessConditionFailedError:
+ current = None
+ continue
+ return stored if isinstance(stored, dict) else merged
+ raise SafetyLogConflict(f'Safety violation {log_id} changed while it was being written.')
+
+
def update_safety_log_action_state(log_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
- """Persist remediation state changes onto a safety log item."""
- item = get_safety_log_item(log_id)
- item.update(updates or {})
- item['last_updated'] = datetime.utcnow().isoformat()
- cosmos_safety_container.upsert_item(item)
- return item
+ """Persist remediation state changes onto a safety log item.
+
+ Only the named fields are written, conditionally on the stored version and again on a
+ fresh copy after a conflict, so a concurrent write is never overwritten.
+ """
+ fields = dict(updates or {})
+ fields['last_updated'] = datetime.utcnow().isoformat()
+ return write_safety_log_updates(log_id, fields)
+
+
+def _safety_request_status(log_item: Dict[str, Any]) -> str:
+ return str((log_item or {}).get('action_request_status') or '').strip().lower()
+
+
+def _safety_request_still_pending(log_item: Dict[str, Any], approval_id: Optional[str]) -> bool:
+ """True while a violation is still waiting on this very remediation request."""
+ return (
+ bool(approval_id)
+ and _safety_request_status(log_item) == SAFETY_REQUEST_PENDING
+ and log_item.get('action_request_id') == approval_id
+ )
+
+
+def safety_log_awaits_request(log_item: Optional[Dict[str, Any]], approval_id: Optional[str]) -> bool:
+ """True while a violation is waiting on exactly this remediation request.
+
+ An approved request is carried out only then. One its violation has moved on from --
+ withdrawn, replaced by a newer request, or settled -- must not change the user's access.
+ """
+ return _safety_request_still_pending(log_item or {}, approval_id)
+
+
+def safety_remediation_state(log_item: Optional[Dict[str, Any]]) -> Tuple[Any, ...]:
+ """The parts of a violation a remediation decision rests on, to compare a later read with."""
+ log_item = log_item or {}
+ return tuple(log_item.get(field) for field in SAFETY_REMEDIATION_STATE_FIELDS)
+
+
+def release_safety_log_after_approval_decision(
+ approval: Dict[str, Any],
+ outcome: str,
+) -> Optional[Dict[str, Any]]:
+ """Record that a violation's remediation request was denied or expired, unlocking it.
+
+ Called whenever a warn, suspend or block request is denied by a reviewer or by the
+ expiry sweep. Only a violation still waiting on this very request changes: one that
+ has since moved on to a newer request, or was already settled, is left as it is.
+ Returns the stored violation, or None when nothing changed.
+ """
+ if outcome not in (SAFETY_REQUEST_DENIED, SAFETY_REQUEST_EXPIRED):
+ raise ValueError(f'Unsupported remediation request outcome: {outcome}')
+ approval = approval if isinstance(approval, dict) else {}
+ metadata = approval.get('metadata') if isinstance(approval.get('metadata'), dict) else {}
+ log_id = str(metadata.get('safety_log_id') or '').strip()
+ approval_id = approval.get('id')
+ if not log_id or not approval_id:
+ return None
+
+ now_text = datetime.utcnow().isoformat()
+ updates = {
+ 'action_request_status': outcome,
+ 'action_request_decided_at': approval.get('approved_at') or now_text,
+ 'action_execution_error': None,
+ 'last_updated': now_text,
+ }
+ try:
+ stored = write_safety_log_updates(
+ log_id,
+ updates,
+ guard=lambda current: _safety_request_still_pending(current, approval_id),
+ )
+ except cosmos_exceptions.CosmosResourceNotFoundError:
+ return None
+ if stored is not None:
+ log_event(
+ '[SAFETY_REMEDIATION] A violation was released after its remediation request was decided.',
+ extra={
+ 'safety_log_id': log_id,
+ 'approval_id': approval_id,
+ 'request_type': approval.get('request_type'),
+ 'outcome': outcome,
+ },
+ )
+ return stored
+
+
+def _settled_request_outcome(
+ log_item: Dict[str, Any],
+ approval: Optional[Dict[str, Any]],
+ now: datetime,
+) -> Optional[str]:
+ """What a pending violation's request has become, or None while it may still be decided."""
+ if approval is None:
+ requested_at = parse_access_restore_time(log_item.get('action_requested_at'))
+ if requested_at is None or now - requested_at >= SAFETY_APPROVAL_LIFETIME:
+ return SAFETY_REQUEST_EXPIRED
+ return None
+ return {
+ 'denied': SAFETY_REQUEST_DENIED,
+ 'auto_denied': SAFETY_REQUEST_EXPIRED,
+ 'expired': SAFETY_REQUEST_EXPIRED,
+ 'executed': SAFETY_REQUEST_EXECUTED,
+ 'failed': SAFETY_REQUEST_FAILED,
+ }.get(str(approval.get('status') or '').strip().lower())
+
+
+def _settled_request_updates(outcome: str, approval: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ approval = approval or {}
+ now_text = datetime.utcnow().isoformat()
+ updates = {'action_request_status': outcome, 'last_updated': now_text}
+ if outcome in (SAFETY_REQUEST_DENIED, SAFETY_REQUEST_EXPIRED):
+ updates['action_request_decided_at'] = approval.get('approved_at') or now_text
+ updates['action_execution_error'] = None
+ elif outcome == SAFETY_REQUEST_EXECUTED:
+ # Recorded as executed only: whatever the request sent was sent by the approval
+ # path, so no warning is raised for acknowledgment here.
+ updates['action_approved_at'] = approval.get('approved_at')
+ updates['action_executed_at'] = approval.get('executed_at') or now_text
+ updates['action_execution_error'] = None
+ elif outcome == SAFETY_REQUEST_FAILED:
+ updates['action_approved_at'] = approval.get('approved_at')
+ updates['action_execution_error'] = SAFETY_REQUEST_FAILED_MESSAGE
+ return updates
+
+
+def _lookup_remediation_requests(approval_ids: Iterable[str]) -> Dict[str, Dict[str, Any]]:
+ """Return ``{approval_id: request}`` for the remediation requests that still exist."""
+ ids = [value for value in dict.fromkeys(approval_ids or []) if isinstance(value, str) and value]
+ found: Dict[str, Dict[str, Any]] = {}
+ for start in range(0, len(ids), SAFETY_REQUEST_LOOKUP_BATCH):
+ batch = ids[start:start + SAFETY_REQUEST_LOOKUP_BATCH]
+ rows = cosmos_approvals_container.query_items(
+ query=(
+ "SELECT c.id, c.status, c.request_type, c.approved_at, c.executed_at, "
+ "c.metadata.safety_log_id AS safety_log_id FROM c WHERE ARRAY_CONTAINS(@ids, c.id)"
+ ),
+ parameters=[{'name': '@ids', 'value': batch}],
+ enable_cross_partition_query=True,
+ )
+ for row in rows:
+ if isinstance(row, dict) and row.get('id') in batch:
+ found[row['id']] = row
+ return found
+
+
+def reconcile_pending_safety_logs(
+ logs: Iterable[Dict[str, Any]],
+ now: Optional[datetime] = None,
+) -> List[Dict[str, Any]]:
+ """Settle violations still marked pending whose remediation request was decided or is gone.
+
+ One lookup covers every pending request in ``logs``. A request that was denied or has
+ expired unlocks its violation; one that executed or failed is recorded as such. A request
+ that is still pending, or approved and still running, keeps its violation locked, and so
+ does a lookup that fails. Returns the list with each settled violation replaced by its
+ stored copy.
+ """
+ logs = list(logs or [])
+ pending = [
+ (index, log_item) for index, log_item in enumerate(logs)
+ if isinstance(log_item, dict)
+ and _safety_request_status(log_item) == SAFETY_REQUEST_PENDING
+ and log_item.get('action_request_id')
+ ]
+ if not pending:
+ return logs
+ try:
+ requests = _lookup_remediation_requests(log_item['action_request_id'] for _index, log_item in pending)
+ except Exception as exc:
+ log_event(
+ '[SAFETY_REMEDIATION] Pending remediation requests could not be looked up.',
+ extra={'request_count': len(pending), 'error_type': type(exc).__name__},
+ level=logging.WARNING,
+ )
+ return logs
+
+ current_time = now or datetime.now(timezone.utc)
+ for index, log_item in pending:
+ approval_id = log_item['action_request_id']
+ approval = requests.get(approval_id)
+ if approval is not None and approval.get('safety_log_id') not in (None, log_item.get('id')):
+ # A request that names another violation is never used to settle this one.
+ continue
+ outcome = _settled_request_outcome(log_item, approval, current_time)
+ if outcome is None:
+ continue
+ try:
+ stored = write_safety_log_updates(
+ log_item['id'],
+ _settled_request_updates(outcome, approval),
+ base_item=log_item,
+ guard=lambda current, request_id=approval_id: _safety_request_still_pending(current, request_id),
+ )
+ except (SafetyLogConflict, cosmos_exceptions.CosmosResourceNotFoundError):
+ continue
+ except Exception as exc:
+ log_event(
+ '[SAFETY_REMEDIATION] A pending violation could not be settled.',
+ extra={'safety_log_id': log_item.get('id'), 'error_type': type(exc).__name__},
+ level=logging.WARNING,
+ )
+ continue
+ if stored is not None:
+ logs[index] = stored
+ log_event(
+ '[SAFETY_REMEDIATION] A pending violation was settled from its remediation request.',
+ extra={
+ 'safety_log_id': log_item.get('id'),
+ 'approval_id': approval_id,
+ 'outcome': outcome,
+ 'request_found': approval is not None,
+ },
+ )
+ return logs
+
+
+def reconcile_pending_safety_log(log_item: Dict[str, Any], now: Optional[datetime] = None) -> Dict[str, Any]:
+ """Settle one violation, as ``reconcile_pending_safety_logs`` does for a list."""
+ return reconcile_pending_safety_logs([log_item], now=now)[0]
def resolve_safety_target_user(user_id: str) -> Dict[str, str]:
@@ -132,6 +478,15 @@ def execute_safety_violation_action(
'access': {
'status': 'deny',
'datetime_to_allow': datetime_to_allow,
+ # What the user was told, so the Access restricted screen can show it.
+ 'notice': build_access_restriction_notice(
+ ACCESS_RESTRICTION_KIND_SUSPENDED,
+ normalized_title,
+ normalized_message,
+ until=datetime_to_allow,
+ reference_id=safety_log.get('id'),
+ source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION,
+ ),
}
},
allow_cross_user=True,
@@ -147,6 +502,13 @@ def execute_safety_violation_action(
'access': {
'status': 'deny',
'datetime_to_allow': None,
+ 'notice': build_access_restriction_notice(
+ ACCESS_RESTRICTION_KIND_BLOCKED,
+ normalized_title,
+ normalized_message,
+ reference_id=safety_log.get('id'),
+ source=ACCESS_RESTRICTION_SOURCE_SAFETY_VIOLATION,
+ ),
}
},
allow_cross_user=True,
@@ -204,5 +566,366 @@ def execute_safety_violation_action(
'success': True,
'message': result_message,
'notification_id': notification.get('id'),
+ 'notification_title': normalized_title,
+ 'notification_message': normalized_message,
'target_user': target_user,
}
+
+
+def build_safety_action_execution_updates(
+ action: str,
+ execution_result: Dict[str, Any],
+ executed_at: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Return the safety log fields that record an executed remediation action.
+
+ An executed warning is also marked as needing the user's acknowledgment, with the title
+ and message the user was sent, so it can be shown to them until they acknowledge it.
+ Warnings executed before this was recorded carry none of these fields and are never
+ shown again.
+ """
+ executed_at = executed_at or datetime.now(timezone.utc).isoformat()
+ updates = {
+ 'action_request_status': 'executed',
+ 'action_executed_at': executed_at,
+ 'action_execution_error': None,
+ }
+ if action == SAFETY_REMEDIATION_WARNING:
+ result = execution_result or {}
+ updates.update({
+ 'warning_requires_acknowledgment': True,
+ 'warning_notification_id': result.get('notification_id'),
+ 'warning_title': result.get('notification_title'),
+ 'warning_message': result.get('notification_message'),
+ 'warning_issued_at': executed_at,
+ 'warning_acknowledged_at': None,
+ })
+ return updates
+
+
+def _request_status(log_item: Dict[str, Any]) -> str:
+ return str(log_item.get('action_request_status') or '').strip().lower()
+
+
+def safety_warning_send_in_progress(log_item: Dict[str, Any], now: Optional[datetime] = None) -> bool:
+ """True while a save is sending a warning on this violation, so other writes must wait.
+
+ A claim older than ``SAFETY_WARNING_SEND_CLAIM_TTL`` either way, or one whose time can't
+ be read, is from a save that stopped before it finished, and does not block.
+ """
+ if _request_status(log_item) != SAFETY_WARNING_SENDING_STATUS:
+ return False
+ # The claim time is stored as an ISO UTC time, read the same way as a restore time.
+ claimed_at = parse_access_restore_time(log_item.get('warning_send_claimed_at'))
+ if claimed_at is None:
+ return False
+ now = now or datetime.now(timezone.utc)
+ return abs(now - claimed_at) < SAFETY_WARNING_SEND_CLAIM_TTL
+
+
+def is_interrupted_safety_warning_send(log_item: Dict[str, Any], now: Optional[datetime] = None) -> bool:
+ """True for a violation left 'sending' by a save that stopped before it finished."""
+ return (
+ _request_status(log_item) == SAFETY_WARNING_SENDING_STATUS
+ and not safety_warning_send_in_progress(log_item, now)
+ )
+
+
+def mark_interrupted_safety_warning_send(log_item: Dict[str, Any]) -> None:
+ """Treat an unfinished send as a failed one, in place, so saving the warning retries it."""
+ log_item['action_request_status'] = 'failed'
+ log_item['action_execution_error'] = SAFETY_WARNING_SEND_INTERRUPTED_MESSAGE
+ for field in SAFETY_WARNING_SEND_CLAIM_FIELDS:
+ log_item.pop(field, None)
+
+
+def present_safety_warning_send_state(log_item: Dict[str, Any], now: Optional[datetime] = None) -> None:
+ """Prepare a listed violation's send state for display, in place.
+
+ The claim's id and time are internal. An unfinished send reads as failed, as the next
+ save records it, rather than as sending forever.
+ """
+ if is_interrupted_safety_warning_send(log_item, now):
+ mark_interrupted_safety_warning_send(log_item)
+ for field in SAFETY_WARNING_SEND_CLAIM_FIELDS:
+ log_item.pop(field, None)
+
+
+def _replace_safety_log_if_unchanged(body: Dict[str, Any]) -> Dict[str, Any]:
+ """Write a safety log only if it is still the version that was read."""
+ etag = body.get('_etag')
+ if not etag:
+ # Cosmos DB always returns an ETag; only a store without them lands here.
+ stored = cosmos_safety_container.upsert_item(body)
+ else:
+ stored = cosmos_safety_container.replace_item(
+ item=body['id'],
+ body=body,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ return stored if isinstance(stored, dict) else body
+
+
+def claim_safety_warning_send(log_item: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
+ """Mark a violation as sending a warning, unless it changed since this save read it.
+
+ The write is conditional on the ETag the save read, so of two saves that read the same
+ version only one claims it. The other gets ``CosmosAccessConditionFailedError`` and must
+ send nothing. The claim also stores the rest of the save's changes. Returns the stored
+ record and the claim's id.
+ """
+ claim_id = str(uuid.uuid4())
+ body = dict(log_item)
+ body.update({
+ 'action_request_status': SAFETY_WARNING_SENDING_STATUS,
+ 'warning_send_claim_id': claim_id,
+ 'warning_send_claimed_at': datetime.now(timezone.utc).isoformat(),
+ })
+ return _replace_safety_log_if_unchanged(body), claim_id
+
+
+def record_safety_warning_send(
+ claimed: Dict[str, Any],
+ claim_id: str,
+ updates: Dict[str, Any],
+) -> Optional[Dict[str, Any]]:
+ """Store what a claimed send did on its violation, and release the claim.
+
+ The write is conditional on the claimed version. When another writer changed the record
+ meanwhile but kept the claim -- archiving it, for example -- the outcome is written on
+ the latest version instead. Returns the stored record, or None when the claim was lost:
+ the record was deleted, or replaced without the claim, so nothing could be recorded.
+ """
+ log_id = claimed.get('id')
+ current = claimed
+ for attempt in range(SAFETY_WARNING_WRITE_ATTEMPTS):
+ if attempt:
+ try:
+ current = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ except cosmos_exceptions.CosmosResourceNotFoundError:
+ return None
+ if current.get('warning_send_claim_id') != claim_id:
+ return None
+ body = dict(current)
+ body.update(updates)
+ for field in SAFETY_WARNING_SEND_CLAIM_FIELDS:
+ body.pop(field, None)
+ body['last_updated'] = datetime.utcnow().isoformat()
+ try:
+ return _replace_safety_log_if_unchanged(body)
+ except cosmos_exceptions.CosmosAccessConditionFailedError:
+ continue
+ except cosmos_exceptions.CosmosResourceNotFoundError:
+ return None
+ return None
+
+
+def serialize_safety_warning_state(log_item: Dict[str, Any]) -> Dict[str, Any]:
+ """Return a record's warning acknowledgment state for reviewers and the warned user.
+
+ ``warning_acknowledgment_status`` is None unless the record holds an executed warning;
+ then it is ``pending``, ``acknowledged`` or ``not_tracked`` for a warning sent before
+ acknowledgment was recorded.
+ """
+ executed_warning = (
+ log_item.get('action') == SAFETY_REMEDIATION_WARNING
+ and str(log_item.get('action_request_status') or '').strip().lower() == 'executed'
+ )
+ requires_acknowledgment = log_item.get('warning_requires_acknowledgment') is True
+ status = None
+ if executed_warning:
+ if not requires_acknowledgment:
+ status = WARNING_ACKNOWLEDGMENT_NOT_TRACKED
+ elif log_item.get('warning_acknowledged_at'):
+ status = WARNING_ACKNOWLEDGMENT_ACKNOWLEDGED
+ else:
+ status = WARNING_ACKNOWLEDGMENT_PENDING
+ return {
+ 'warning_requires_acknowledgment': requires_acknowledgment,
+ 'warning_issued_at': log_item.get('warning_issued_at'),
+ 'warning_acknowledged_at': log_item.get('warning_acknowledged_at'),
+ 'warning_acknowledgment_status': status,
+ }
+
+
+def _user_safe_categories(log_item: Dict[str, Any]) -> List[Dict[str, Any]]:
+ categories = []
+ for entry in log_item.get('triggered_categories') or []:
+ if not isinstance(entry, dict):
+ continue
+ name = str(entry.get('category') or '').strip()
+ if not name:
+ continue
+ severity = entry.get('severity')
+ if isinstance(severity, bool) or not isinstance(severity, (int, float)):
+ severity = None
+ categories.append({'category': name, 'severity': severity})
+ return categories
+
+
+def _warning_issued_at(log_item: Dict[str, Any]) -> Optional[str]:
+ """When the warning now on a violation was sent. Each warning sent on it has its own."""
+ return log_item.get('warning_issued_at') or log_item.get('action_executed_at')
+
+
+def serialize_safety_warning_for_user(log_item: Dict[str, Any]) -> Dict[str, Any]:
+ """Return only what the warned user may see of a warning: what they were sent, and when."""
+ return {
+ 'id': log_item.get('id'),
+ 'violation_id': log_item.get('id'),
+ 'title': str(log_item.get('warning_title') or '').strip()
+ or _default_notification_title(SAFETY_REMEDIATION_WARNING),
+ 'message': str(log_item.get('warning_message') or '').strip() or SAFETY_WARNING_FALLBACK_MESSAGE,
+ 'issued_at': _warning_issued_at(log_item),
+ 'acknowledged_at': log_item.get('warning_acknowledged_at'),
+ 'triggered_categories': _user_safe_categories(log_item),
+ }
+
+
+def _is_acknowledgeable_warning(log_item: Dict[str, Any], user_id: str) -> bool:
+ """True for the caller's own executed warning that asks for acknowledgment."""
+ return (
+ bool(user_id)
+ and log_item.get('user_id') == user_id
+ and log_item.get('content_origin', 'user') == 'user'
+ and log_item.get('action') == SAFETY_REMEDIATION_WARNING
+ and str(log_item.get('action_request_status') or '').strip().lower() == 'executed'
+ and log_item.get('warning_requires_acknowledgment') is True
+ )
+
+
+_PENDING_WARNING_CONDITIONS = (
+ "c.user_id = @user_id "
+ "AND c.action = @action "
+ "AND c.action_request_status = 'executed' "
+ "AND c.warning_requires_acknowledgment = true "
+ "AND (NOT IS_DEFINED(c.warning_acknowledged_at) OR IS_NULL(c.warning_acknowledged_at)) "
+ "AND (NOT IS_DEFINED(c.content_origin) OR c.content_origin = 'user')"
+)
+
+
+def _pending_warning_parameters(user_id: str) -> List[Dict[str, Any]]:
+ return [
+ {'name': '@user_id', 'value': user_id},
+ {'name': '@action', 'value': SAFETY_REMEDIATION_WARNING},
+ ]
+
+
+def count_pending_safety_warnings(user_id: str) -> int:
+ """Return how many warnings the user still has to acknowledge.
+
+ V2 bootstrap carries this so the interface only asks for the warnings themselves when
+ there are some, rather than on every page load.
+ """
+ if not user_id:
+ return 0
+ values = cosmos_safety_container.query_items(
+ query=f"SELECT VALUE COUNT(1) FROM c WHERE {_PENDING_WARNING_CONDITIONS}",
+ parameters=_pending_warning_parameters(user_id),
+ enable_cross_partition_query=True,
+ )
+ return sum(int(value) for value in values if isinstance(value, (int, float)) and not isinstance(value, bool))
+
+
+def list_pending_safety_warnings(user_id: str) -> List[Dict[str, Any]]:
+ """Return the caller's warnings that still need acknowledgment, oldest first."""
+ if not user_id:
+ return []
+ query = (
+ "SELECT c.id, c.user_id, c.content_origin, c.action, c.action_request_status, "
+ "c.action_executed_at, c.warning_requires_acknowledgment, c.warning_acknowledged_at, "
+ "c.warning_title, c.warning_message, c.warning_issued_at, c.triggered_categories "
+ f"FROM c WHERE {_PENDING_WARNING_CONDITIONS}"
+ )
+ items = list(cosmos_safety_container.query_items(
+ query=query,
+ parameters=_pending_warning_parameters(user_id),
+ enable_cross_partition_query=True,
+ ))
+ pending = [
+ item for item in items
+ if _is_acknowledgeable_warning(item, user_id) and not item.get('warning_acknowledged_at')
+ ]
+ pending.sort(key=lambda item: str(item.get('warning_issued_at') or item.get('action_executed_at') or ''))
+ return [serialize_safety_warning_for_user(item) for item in pending[:SAFETY_WARNING_PENDING_LIMIT]]
+
+
+def _mark_warning_notification_read(log_item: Dict[str, Any], user_id: str) -> None:
+ """Mark the bell notification that delivered the warning as read for its recipient."""
+ notification_id = str(log_item.get('warning_notification_id') or '').strip()
+ if not notification_id:
+ return
+ if not mark_notification_read(notification_id, user_id):
+ log_event(
+ '[SAFETY_WARNINGS] The warning notification could not be marked read.',
+ extra={'safety_log_id': log_item.get('id'), 'user_id': user_id},
+ level=logging.WARNING,
+ )
+
+
+def acknowledge_safety_warning(
+ log_id: str,
+ user_id: str,
+ issued_at: Optional[str] = None,
+) -> Tuple[str, Optional[Dict[str, Any]]]:
+ """Record that the signed-in user acknowledged their own warning.
+
+ Returns ``(status, warning)``. ``status`` is ``acknowledged``, ``already_acknowledged``,
+ ``replaced`` or ``not_found``. A record that doesn't exist, belongs to someone else, or
+ isn't a warning that asks for acknowledgment is ``not_found``, so the answer never
+ confirms another user's record. ``issued_at`` is when the warning the user read was
+ sent: when the violation now holds a warning sent at another time, the one they read was
+ replaced, and nothing is recorded (``replaced``). Without it, the warning now on the
+ violation is acknowledged. The write is conditional on the stored ETag and retried, so a
+ reviewer saving the record at the same moment is never overwritten.
+ """
+ normalized_id = str(log_id or '').strip()
+ if not normalized_id or not user_id:
+ return 'not_found', None
+ expected_issued_at = str(issued_at or '').strip()
+
+ for _attempt in range(SAFETY_WARNING_WRITE_ATTEMPTS):
+ try:
+ item = cosmos_safety_container.read_item(item=normalized_id, partition_key=normalized_id)
+ except cosmos_exceptions.CosmosResourceNotFoundError:
+ return 'not_found', None
+ if not _is_acknowledgeable_warning(item, user_id):
+ return 'not_found', None
+ if expected_issued_at and expected_issued_at != str(_warning_issued_at(item) or '').strip():
+ return 'replaced', None
+ if item.get('warning_acknowledged_at'):
+ _mark_warning_notification_read(item, user_id)
+ return 'already_acknowledged', serialize_safety_warning_for_user(item)
+
+ item['warning_acknowledged_at'] = datetime.now(timezone.utc).isoformat()
+ etag = item.get('_etag')
+ try:
+ if etag:
+ stored = cosmos_safety_container.replace_item(
+ item=normalized_id,
+ body=item,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ stored = cosmos_safety_container.upsert_item(item)
+ except cosmos_exceptions.CosmosAccessConditionFailedError:
+ continue
+
+ stored = stored if isinstance(stored, dict) else item
+ _mark_warning_notification_read(stored, user_id)
+ log_event(
+ '[SAFETY_WARNINGS] Safety warning acknowledged by its recipient.',
+ extra={
+ 'safety_log_id': normalized_id,
+ 'user_id': user_id,
+ 'warning_issued_at': stored.get('warning_issued_at'),
+ },
+ )
+ return 'acknowledged', serialize_safety_warning_for_user(stored)
+
+ raise cosmos_exceptions.CosmosAccessConditionFailedError(
+ status_code=412,
+ message='The safety warning changed while it was being acknowledged.',
+ )
diff --git a/application/single_app/route_access_restriction.py b/application/single_app/route_access_restriction.py
new file mode 100644
index 000000000..08f161b46
--- /dev/null
+++ b/application/single_app/route_access_restriction.py
@@ -0,0 +1,127 @@
+# route_access_restriction.py
+"""The Access restricted screen, in both interfaces, and the call behind the V2 page.
+
+A user whose access an administrator suspended or blocked can still sign in, but
+``functions_authentication.user_required`` refuses every app surface and sends them here.
+These routes are therefore login-only on purpose: they must stay reachable by exactly the
+users ``user_required`` refuses. They only ever describe the signed-in user's own
+restriction, read from their own settings, and never accept a user id from the request.
+
+The Admin role is not subject to access restrictions, so an Admin is always told they are
+not restricted, matching what ``user_required`` enforces.
+"""
+
+from flask import jsonify, make_response, render_template, session
+
+from functions_access_restriction import (
+ ACCESS_RESTRICTION_KIND_SUSPENDED,
+ parse_access_restore_time,
+ public_access_restriction,
+)
+from functions_authentication import get_user_access_restriction, login_required
+from functions_branding_urls import build_custom_logo_urls
+from functions_settings import get_settings, sanitize_settings_for_user
+from route_frontend_v2 import _serve_v2_shell
+from swagger_wrapper import get_auth_security, swagger_route
+
+
+NO_STORE_HEADERS = {"Cache-Control": "no-store"}
+
+
+def _session_user():
+ user = session.get("user")
+ return user if isinstance(user, dict) else {}
+
+
+def _current_restriction():
+ """Return the signed-in user's own restriction, as ``user_required`` would enforce it."""
+ user = _session_user()
+ if "Admin" in (user.get("roles") or []):
+ return None
+ user_id = user.get("oid") or user.get("sub")
+ if not user_id:
+ return None
+ return get_user_access_restriction(user_id)
+
+
+def _restore_display(restriction):
+ """Return a UTC rendering of a suspension's restore time for the server-rendered page."""
+ if not restriction or restriction.get("kind") != ACCESS_RESTRICTION_KIND_SUSPENDED:
+ return ""
+ restore_at = parse_access_restore_time(restriction.get("until"))
+ return restore_at.strftime("%Y-%m-%d %H:%M UTC") if restore_at else ""
+
+
+def _restricted_page_branding(raw_settings, public_settings):
+ """Return the branding the V2 page draws: the fields the V2 Terms of Use page receives.
+
+ Read from the same settings as ``route_backend_v2._build_branding``. Only the logo URLs
+ are returned, never the stored image data.
+ """
+ show_logo = bool(raw_settings.get("show_logo", False))
+ logo_url, logo_dark_url = build_custom_logo_urls(raw_settings)
+ classification_banner = None
+ if raw_settings.get("classification_banner_enabled") and raw_settings.get("classification_banner_text"):
+ classification_banner = {
+ "enabled": True,
+ "text": raw_settings.get("classification_banner_text"),
+ "color": raw_settings.get("classification_banner_color") or "#ffc107",
+ "text_color": raw_settings.get("classification_banner_text_color") or "#ffffff",
+ }
+ return {
+ "app_title": public_settings.get("app_title") or "SimpleChat",
+ "hide_app_title": bool(raw_settings.get("hide_app_title", False)),
+ "show_logo": show_logo,
+ "logo_url": logo_url if show_logo else None,
+ "logo_dark_url": logo_dark_url if show_logo else None,
+ "classification_banner": classification_banner,
+ }
+
+
+def register_route_access_restriction(bp):
+ @bp.route("/v2/access-restricted", methods=["GET"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ def v2_access_restricted():
+ """Serve the V2 SPA shell for the Access restricted page.
+
+ This static rule is matched ahead of the ``/v2/`` catch-all, which
+ requires the User role and an unrestricted account. The page itself loads nothing
+ but ``/api/v2/access-restriction``.
+ """
+ return _serve_v2_shell()
+
+ @bp.route("/api/v2/access-restriction", methods=["GET"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ def v2_access_restriction():
+ """Return the signed-in user's own access restriction for the V2 page.
+
+ ``restricted`` is False once a suspension has ended -- the read restores it -- or
+ an administrator has restored access, and the page then offers to continue.
+ """
+ restriction = _current_restriction()
+ settings = get_settings() or {}
+ payload = {
+ "restricted": restriction is not None,
+ "branding": _restricted_page_branding(settings, sanitize_settings_for_user(settings)),
+ }
+ if restriction is not None:
+ payload["restriction"] = public_access_restriction(restriction)
+ return jsonify(payload), 200, NO_STORE_HEADERS
+
+ @bp.route("/access-restricted", methods=["GET"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ def access_restricted():
+ """Render the classic Access restricted page for the signed-in user."""
+ restriction = _current_restriction()
+ public_settings = sanitize_settings_for_user(get_settings() or {})
+ response = make_response(render_template(
+ "access_restricted.html",
+ app_settings=public_settings,
+ restriction=public_access_restriction(restriction) if restriction else None,
+ restore_display=_restore_display(restriction),
+ ))
+ response.headers["Cache-Control"] = "no-store"
+ return response
diff --git a/application/single_app/route_backend_control_center.py b/application/single_app/route_backend_control_center.py
index 5762d5c13..841238ec3 100644
--- a/application/single_app/route_backend_control_center.py
+++ b/application/single_app/route_backend_control_center.py
@@ -45,7 +45,13 @@
from functions_logging import *
from functions_activity_logging import *
from functions_approvals import *
-from functions_approvals import _can_user_approve, _can_user_deny
+from functions_approvals import (
+ APPROVAL_STATS_SCAN_LIMIT,
+ _can_user_approve,
+ _can_user_deny,
+ summarize_visible_approvals,
+)
+from functions_review_center import parse_review_window
from functions_m365_approvals import is_m365_approval
from functions_m365_pending_delivery import cancel_m365_conversation_deliveries
from route_backend_m365 import m365_approval_decision_response
@@ -75,8 +81,11 @@
select_workspace_ids, workspace_members, workspace_row,
)
from functions_safety_remediation import (
+ SAFETY_REQUEST_NOT_CURRENT_MESSAGE,
+ build_safety_action_execution_updates,
execute_safety_violation_action,
get_safety_log_item,
+ safety_log_awaits_request,
update_safety_log_action_state,
)
from functions_public_workspaces import (
@@ -8499,6 +8508,46 @@ def api_get_approvals():
log_event("[APPROVALS] Failed to fetch approvals", extra={'exception_type': type(e).__name__}, level=logging.ERROR)
return jsonify({'error': 'Failed to fetch approvals'}), 500
+ @bp.route('/api/approvals/stats', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ def api_get_approval_stats():
+ """
+ Summarize the approval requests the current user can see, for the Approvals dashboard.
+
+ Query Parameters:
+ days (int): 7, 30 or 90 (default 30), the window decided requests are counted in.
+
+ Requests are read through the same visibility rules as GET /api/approvals, so a
+ request the caller cannot see is never counted.
+ """
+ try:
+ days = parse_review_window(request.args.get('days')) or 30
+ except ValueError:
+ return jsonify({'error': 'The window must be 7, 30 or 90 days.'}), 400
+ try:
+ user = session.get('user', {})
+ user_id = user.get('oid') or user.get('sub')
+ user_roles = user.get('roles', [])
+ result = get_pending_approvals(
+ user_id=user_id,
+ user_roles=user_roles,
+ page=1,
+ per_page=APPROVAL_STATS_SCAN_LIMIT,
+ include_completed=True,
+ status_filter='all',
+ tenant_id=user.get('tid'),
+ )
+ return jsonify(summarize_visible_approvals(
+ result.get('approvals', []),
+ user_id,
+ user_roles,
+ days,
+ )), 200
+ except Exception as e:
+ log_event("[APPROVALS] Failed to summarize approvals", extra={'exception_type': type(e).__name__}, level=logging.ERROR)
+ return jsonify({'error': 'Failed to summarize approvals'}), 500
+
@bp.route('/api/approvals/', methods=['GET'])
@swagger_route(security=get_auth_security())
@login_required
@@ -8745,6 +8794,10 @@ def _execute_safety_violation_request(approval, executor_id, executor_email, exe
return {'success': False, 'message': 'Approval metadata is missing the safety log reference.'}
safety_log = get_safety_log_item(safety_log_id)
+ # Only the request its violation is waiting on is carried out. One the violation has
+ # moved on from -- withdrawn, replaced by a newer request, or settled -- changes nothing.
+ if not safety_log_awaits_request(safety_log, approval.get('id')):
+ return {'success': False, 'message': SAFETY_REQUEST_NOT_CURRENT_MESSAGE}
action = metadata.get('violation_action')
if not action:
return {'success': False, 'message': 'Approval metadata is missing the violation action.'}
@@ -8762,15 +8815,16 @@ def _execute_safety_violation_request(approval, executor_id, executor_email, exe
},
)
- update_safety_log_action_state(safety_log_id, {
- 'action_request_status': 'executed',
+ # Warnings are sent without approval now; this path still finishes warn_user
+ # requests created before that, and marks them for acknowledgment the same way.
+ execution_updates = build_safety_action_execution_updates(action, result)
+ execution_updates.update({
'action_request_id': approval.get('id'),
'action_request_type': approval.get('request_type'),
'action_requested_at': approval.get('created_at'),
'action_approved_at': approval.get('approved_at'),
- 'action_executed_at': datetime.utcnow().isoformat(),
- 'action_execution_error': None,
})
+ update_safety_log_action_state(safety_log_id, execution_updates)
return result
diff --git a/application/single_app/route_backend_feedback.py b/application/single_app/route_backend_feedback.py
index 98c3a6bcb..e54cdc488 100644
--- a/application/single_app/route_backend_feedback.py
+++ b/application/single_app/route_backend_feedback.py
@@ -4,12 +4,36 @@
import io
import logging
+from azure.core import MatchConditions
from flask import make_response
from config import *
from functions_appinsights import log_event
from functions_authentication import *
+from functions_notifications import create_notification
+from functions_review_center import (
+ REVIEW_RECORD_CHANGED_CODE,
+ REVIEW_WRITE_ATTEMPTS,
+ ReviewRecordConflict,
+ ReviewRequestError,
+ cap_review_ids,
+ daily_counts,
+ in_review_window,
+ normalize_review_search,
+ parse_review_bulk_operations,
+ parse_review_date,
+ parse_review_window,
+ replace_review_record,
+ resolve_review_users,
+ review_bulk_result,
+ review_day,
+ review_excerpt,
+ review_text_matches,
+ review_window,
+ summarize_review_bulk_results,
+)
from functions_review_lifecycle import (
+ ARCHIVE_STATE_ALL,
apply_archive_state,
append_archive_query_filter,
log_review_lifecycle_action,
@@ -27,6 +51,19 @@
"negative": "Negative",
"neutral": "Neutral",
}
+FEEDBACK_REVIEW_TEXT_FIELDS = ('analysisNotes', 'responseToUser', 'actionTaken')
+FEEDBACK_REVIEW_TEXT_MAX_LENGTH = 8000
+FEEDBACK_OLDEST_AWAITING_LIMIT = 5
+FEEDBACK_RECORD_CHANGED_MESSAGE = (
+ 'This feedback changed after you opened it. Reload it to see the latest version, then try again.'
+)
+# The notice a user receives when a reviewer chooses to tell them about the review. The link
+# opens the user's own feedback list: classic Profile, and V2 Settings, which reads it too.
+FEEDBACK_RESPONSE_NOTIFICATION_TYPE = 'feedback_response'
+FEEDBACK_RESPONSE_NOTIFICATION_TITLE = 'An administrator responded to your feedback'
+FEEDBACK_RESPONSE_NOTIFICATION_FALLBACK = 'An administrator reviewed the feedback you sent about an AI response.'
+FEEDBACK_RESPONSE_NOTIFICATION_LINK = '/profile?tab=feedback'
+FEEDBACK_RESPONSE_NOTIFICATION_MAX_LENGTH = 1000
def _authorize_feedback_conversation(user_id, conversation_id):
@@ -129,6 +166,14 @@ def _query_feedback_items(
))
serialized_items = [_serialize_feedback_item(item) for item in items]
+ if user_id:
+ # Who reviewed the feedback is for reviewers; the user sees the review itself.
+ for serialized_item in serialized_items:
+ review = serialized_item.get("adminReview")
+ if isinstance(review, dict) and "analyzedBy" in review:
+ serialized_item["adminReview"] = {
+ key: value for key, value in review.items() if key != "analyzedBy"
+ }
if filter_type:
serialized_items = [
@@ -246,10 +291,11 @@ def _get_feedback_admin_actor():
return {
'id': actor_id,
'email': user.get('preferred_username') or user.get('email') or '',
+ 'name': user.get('name') or user.get('preferred_username') or '',
}
-def _feedback_lifecycle_response(message, audit_logged):
+def _feedback_lifecycle_body(message, audit_logged):
response = {
'success': True,
'message': message,
@@ -259,7 +305,11 @@ def _feedback_lifecycle_response(message, audit_logged):
response['audit_warning'] = (
'The record was updated, but the audit activity could not be recorded.'
)
- return jsonify(response)
+ return response
+
+
+def _feedback_lifecycle_response(message, audit_logged):
+ return jsonify(_feedback_lifecycle_body(message, audit_logged))
def _log_feedback_audit_failure(feedback_id, lifecycle_action):
@@ -272,6 +322,344 @@ def _log_feedback_audit_failure(feedback_id, lifecycle_action):
level=logging.ERROR,
)
+
+def _feedback_record_changed_body():
+ return {'error': FEEDBACK_RECORD_CHANGED_MESSAGE, 'code': REVIEW_RECORD_CHANGED_CODE}
+
+
+def _feedback_write_attempts(expected_etag):
+ """How often a save may write: once when it names the version it read, so a conflict is
+ refused rather than merged onto a newer version; otherwise it is merged field by field."""
+ return 1 if expected_etag else REVIEW_WRITE_ATTEMPTS
+
+
+def _feedback_acknowledged(item):
+ return bool((item.get('adminReview') or {}).get('acknowledged'))
+
+
+def _parse_feedback_list_filters():
+ """Read the Review center list filters. Raises ValueError for a value that can't be used."""
+ filter_type, filter_ack_bool, archive_state = _parse_feedback_filters(include_archive_state=True)
+ return {
+ 'type': filter_type,
+ 'ack': filter_ack_bool,
+ 'archive': archive_state,
+ 'search': normalize_review_search(request.args.get('search')),
+ 'user_id': (request.args.get('user_id') or '').strip() or None,
+ 'date': parse_review_date(request.args.get('date')),
+ 'days': parse_review_window(request.args.get('days')),
+ }
+
+
+def _feedback_matches(item, filters, users, window):
+ if filters['user_id'] and item.get('userId') != filters['user_id']:
+ return False
+ if filters['date'] and review_day(item.get('timestamp')) != filters['date']:
+ return False
+ if window and not in_review_window(item.get('timestamp'), window):
+ return False
+ if filters['search']:
+ review = item.get('adminReview') or {}
+ user = users.get(item.get('userId')) or {}
+ return review_text_matches(
+ filters['search'],
+ item.get('id'),
+ item.get('prompt'),
+ item.get('aiResponse'),
+ item.get('reason'),
+ item.get('userId'),
+ review.get('analysisNotes'),
+ review.get('responseToUser'),
+ review.get('actionTaken'),
+ user.get('display_name'),
+ user.get('email'),
+ )
+ return True
+
+
+def _load_admin_feedback(filters):
+ """The feedback a Review center list or "select all matching" covers, newest first."""
+ items = _query_feedback_items(
+ filter_type=filters['type'],
+ filter_ack_bool=filters['ack'],
+ archive_state=filters['archive'],
+ )
+ users = resolve_review_users([item.get('userId') for item in items]) if filters['search'] else {}
+ window = review_window(filters['days']) if filters['days'] else None
+ return [item for item in items if _feedback_matches(item, filters, users, window)], users
+
+
+def _with_feedback_user_names(items, users=None):
+ """Add each record's user display name and email, looked up in one batch."""
+ names = dict(users or {})
+ missing = [item.get('userId') for item in items if item.get('userId') not in names]
+ if missing:
+ names.update(resolve_review_users(missing))
+ for item in items:
+ entry = names.get(item.get('userId')) or {}
+ item['userDisplayName'] = entry.get('display_name') or None
+ item['userEmail'] = entry.get('email') or None
+ return items
+
+
+def _build_feedback_window_stats(days):
+ """The Feedback dashboard for the last ``days`` days, read across active and archived records."""
+ window = review_window(days)
+ items = _query_feedback_items(archive_state=ARCHIVE_STATE_ALL)
+ in_window = [item for item in items if in_review_window(item.get('timestamp'), window)]
+ awaiting = [item for item in items if not item.get('isArchived') and not _feedback_acknowledged(item)]
+ oldest = sorted(awaiting, key=lambda item: str(item.get('timestamp') or ''))[:FEEDBACK_OLDEST_AWAITING_LIMIT]
+ names = resolve_review_users([item.get('userId') for item in oldest])
+ acknowledged_in_window = sum(1 for item in in_window if _feedback_acknowledged(item))
+ return {
+ 'window': {
+ 'days': window['days'],
+ 'start_date': window['start_date'],
+ 'end_date': window['end_date'],
+ },
+ 'received_count': len(in_window),
+ 'awaiting_review_count': len(awaiting),
+ 'negative_count_in_window': sum(1 for item in in_window if item.get('feedbackType') == 'Negative'),
+ 'acknowledged_count_in_window': acknowledged_in_window,
+ 'acknowledgement_rate': round(acknowledged_in_window / len(in_window), 4) if in_window else None,
+ 'archived_count': sum(1 for item in items if item.get('isArchived')),
+ 'daily_by_rating': daily_counts(
+ in_window,
+ window,
+ lambda item: item.get('timestamp'),
+ lambda item: item.get('feedbackType') or 'Unknown',
+ ),
+ 'oldest_awaiting': [
+ {
+ 'id': item.get('id'),
+ 'feedbackType': item.get('feedbackType'),
+ 'timestamp': item.get('timestamp'),
+ 'userId': item.get('userId'),
+ 'userDisplayName': (names.get(item.get('userId')) or {}).get('display_name') or None,
+ 'promptExcerpt': review_excerpt(item.get('prompt')),
+ }
+ for item in oldest
+ ],
+ }
+
+
+def _validate_feedback_review_changes(data):
+ """Return an error message for review changes the API refuses, or None."""
+ if 'acknowledged' in data and not isinstance(data.get('acknowledged'), bool):
+ return 'Acknowledged must be true or false.'
+ for field in FEEDBACK_REVIEW_TEXT_FIELDS:
+ value = data.get(field)
+ if value is not None and not isinstance(value, str):
+ return f'{field} must be text.'
+ if isinstance(value, str) and len(value) > FEEDBACK_REVIEW_TEXT_MAX_LENGTH:
+ return f'{field} is too long.'
+ if 'notify_user' in data and not isinstance(data.get('notify_user'), bool):
+ return 'notify_user must be true or false.'
+ return None
+
+
+def _notify_feedback_user(feedback_doc, actor):
+ """Tell the user who sent the feedback that a reviewer responded. Returns a warning or None."""
+ user_id = feedback_doc.get('userId')
+ if not user_id:
+ return 'The user could not be notified because the feedback has no user.'
+ response_text = str(((feedback_doc.get('adminReview') or {}).get('responseToUser')) or '').strip()
+ message = response_text[:FEEDBACK_RESPONSE_NOTIFICATION_MAX_LENGTH] or FEEDBACK_RESPONSE_NOTIFICATION_FALLBACK
+ notification = create_notification(
+ user_id=user_id,
+ notification_type=FEEDBACK_RESPONSE_NOTIFICATION_TYPE,
+ title=FEEDBACK_RESPONSE_NOTIFICATION_TITLE,
+ message=message,
+ link_url=FEEDBACK_RESPONSE_NOTIFICATION_LINK,
+ link_context={'tab': 'feedback', 'feedback_id': feedback_doc.get('id')},
+ metadata={'feedback_id': feedback_doc.get('id')},
+ )
+ if notification is None:
+ log_event('[FEEDBACK_REVIEW] The feedback response notification could not be created.', {
+ 'feedback_id': feedback_doc.get('id'),
+ 'actor_id': actor.get('id'),
+ }, level=logging.WARNING)
+ return 'The review was saved, but the user could not be notified.'
+ log_event('[FEEDBACK_REVIEW] The user was notified about a feedback review.', {
+ 'feedback_id': feedback_doc.get('id'),
+ 'actor_id': actor.get('id'),
+ 'notification_id': notification.get('id'),
+ })
+ return None
+
+
+def _apply_feedback_review_update(feedback_id, data, actor):
+ """Save a reviewer's review of one feedback record. Returns ``(body, status)``.
+
+ Shared by PATCH /feedback/review/ and the bulk ``update`` operation. Only the fields
+ sent change; the reviewer is recorded as ``adminReview.analyzedBy``. ``etag``, when
+ sent, must match the stored record. ``notify_user: true`` sends the user a notification
+ with the response once the review is saved.
+ """
+ if not isinstance(data, dict):
+ return {"error": "The request body must be an object."}, 400
+ problem = _validate_feedback_review_changes(data)
+ if problem:
+ return {"error": problem}, 400
+ expected_etag = data.get('etag')
+ if expected_etag is not None and not isinstance(expected_etag, str):
+ return {"error": "The etag must be text."}, 400
+ if not actor.get('id'):
+ return {'error': 'No user ID found in session'}, 403
+
+ try:
+ feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id)
+ except CosmosResourceNotFoundError:
+ return {"error": "Feedback not found"}, 404
+ except Exception as e:
+ log_event('[FEEDBACK_REVIEW] A feedback record could not be read for review.', {
+ 'feedback_id': feedback_id,
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return {"error": "Failed to read feedback item"}, 500
+ if expected_etag and feedback_doc.get('_etag') != expected_etag:
+ return _feedback_record_changed_body(), 409
+
+ notify_user = data.get('notify_user') is True
+ reviewed_at = datetime.utcnow().isoformat()
+
+ def apply_review(record):
+ admin_review = dict(record.get("adminReview") or {})
+ admin_review["acknowledged"] = data.get("acknowledged", admin_review.get("acknowledged", False))
+ for field in FEEDBACK_REVIEW_TEXT_FIELDS:
+ admin_review[field] = data.get(field, admin_review.get(field))
+ admin_review["reviewTimestamp"] = reviewed_at
+ admin_review["analyzedBy"] = {'id': actor['id'], 'displayName': actor.get('name') or actor.get('email') or ''}
+ if notify_user:
+ admin_review["userNotifiedAt"] = reviewed_at
+ record["adminReview"] = admin_review
+
+ try:
+ # A save that names the version it read is written on that version or not at all;
+ # one that doesn't is merged field by field onto a newer version.
+ stored = replace_review_record(
+ cosmos_feedback_container,
+ feedback_id,
+ apply_review,
+ base_item=feedback_doc,
+ attempts=_feedback_write_attempts(expected_etag),
+ )
+ except ReviewRecordConflict:
+ return _feedback_record_changed_body(), 409
+ except CosmosResourceNotFoundError:
+ return {"error": "Feedback not found"}, 404
+ except Exception as e:
+ log_event('[FEEDBACK_REVIEW] A feedback review could not be saved.', {
+ 'feedback_id': feedback_id,
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return {"error": "Failed to save changes"}, 500
+
+ body = {"success": True, "etag": stored.get('_etag'), "notified": False}
+ if notify_user:
+ warning = _notify_feedback_user(stored, actor)
+ body["notified"] = warning is None
+ if warning:
+ body["notification_warning"] = warning
+ return body, 200
+
+
+def _archive_feedback(feedback_id, archived, actor, expected_etag=None):
+ """Archive or restore one feedback record. Returns ``(body, status)``.
+
+ Shared by the archive route and the bulk ``archive`` operation. Only the archive fields
+ change, conditionally on the stored version, so a concurrent review save survives.
+ """
+ if not isinstance(archived, bool):
+ return {'error': 'The archived field must be a boolean.'}, 400
+ if not actor.get('id'):
+ return {'error': 'No user ID found in session'}, 403
+
+ try:
+ feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id)
+ if expected_etag and feedback_doc.get('_etag') != expected_etag:
+ return _feedback_record_changed_body(), 409
+ was_archived = bool(feedback_doc.get('is_archived'))
+
+ def apply_archive(record):
+ apply_archive_state(record, archived, actor['id'])
+
+ stored = replace_review_record(
+ cosmos_feedback_container,
+ feedback_id,
+ apply_archive,
+ base_item=feedback_doc,
+ attempts=_feedback_write_attempts(expected_etag),
+ )
+ except CosmosResourceNotFoundError:
+ return {'error': 'Feedback not found'}, 404
+ except ReviewRecordConflict:
+ return _feedback_record_changed_body(), 409
+ except Exception as e:
+ log_event(
+ '[FEEDBACK_LIFECYCLE] Failed to update feedback archive state',
+ {'feedback_id': feedback_id, 'error_type': type(e).__name__},
+ level=logging.ERROR,
+ )
+ return {'error': 'Failed to update feedback archive state'}, 500
+
+ lifecycle_action = 'archive' if archived else 'unarchive'
+ audit_logged = log_review_lifecycle_action(
+ 'feedback',
+ lifecycle_action,
+ stored,
+ actor,
+ was_archived=was_archived,
+ )
+ if not audit_logged:
+ _log_feedback_audit_failure(feedback_id, lifecycle_action)
+
+ message = 'Feedback archived successfully.' if archived else 'Feedback unarchived successfully.'
+ return _feedback_lifecycle_body(message, audit_logged), 200
+
+
+def _delete_feedback(feedback_id, actor, expected_etag=None):
+ """Permanently delete one feedback record, keeping its audit entry. Returns ``(body, status)``."""
+ if not actor.get('id'):
+ return {'error': 'No user ID found in session'}, 403
+
+ try:
+ feedback_doc = cosmos_feedback_container.read_item(item=feedback_id, partition_key=feedback_id)
+ if expected_etag and feedback_doc.get('_etag') != expected_etag:
+ return _feedback_record_changed_body(), 409
+ if feedback_doc.get('_etag'):
+ cosmos_feedback_container.delete_item(
+ item=feedback_id,
+ partition_key=feedback_id,
+ etag=feedback_doc.get('_etag'),
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ cosmos_feedback_container.delete_item(item=feedback_id, partition_key=feedback_id)
+ except CosmosResourceNotFoundError:
+ return {'error': 'Feedback not found'}, 404
+ except exceptions.CosmosAccessConditionFailedError:
+ return _feedback_record_changed_body(), 409
+ except Exception as e:
+ log_event(
+ '[FEEDBACK_LIFECYCLE] Failed to delete feedback',
+ {'feedback_id': feedback_id, 'error_type': type(e).__name__},
+ level=logging.ERROR,
+ )
+ return {'error': 'Failed to delete feedback'}, 500
+
+ audit_logged = log_review_lifecycle_action(
+ 'feedback',
+ 'delete',
+ feedback_doc,
+ actor,
+ )
+ if not audit_logged:
+ _log_feedback_audit_failure(feedback_id, 'delete')
+
+ return _feedback_lifecycle_body('Feedback permanently deleted.', audit_logged), 200
+
+
def register_route_backend_feedback(bp):
@bp.route("/feedback/submit", methods=["POST"])
@@ -414,19 +802,18 @@ def feedback_submit():
def feedback_review_get():
"""
Return feedback for admin review with pagination and filtering.
+
+ Query parameters: page, page_size, type, ack and archive as before, plus the Review
+ center's search, user_id, date (YYYY-MM-DD) and days (7, 30 or 90). Each record
+ carries userDisplayName and userEmail.
"""
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 10, type=int)
- filter_type, filter_ack_bool, archive_state = _parse_feedback_filters(
- include_archive_state=True,
- )
- items = _query_feedback_items(
- filter_type=filter_type,
- filter_ack_bool=filter_ack_bool,
- archive_state=archive_state,
- )
+ filters = _parse_feedback_list_filters()
+ items, users = _load_admin_feedback(filters)
paginated_items, page, page_size = _paginate_feedback_items(items, page, page_size)
+ _with_feedback_user_names(paginated_items, users)
total_count = len(items)
return jsonify({
@@ -446,23 +833,56 @@ def feedback_review_get():
traceback.print_exc()
return jsonify({"error": f"Failed to retrieve feedback: {str(e)}"}), 500
+ @bp.route("/feedback/review/ids", methods=["GET"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @feedback_admin_required
+ @enabled_required("enable_user_feedback")
+ def feedback_review_ids():
+ """Return the ids of the feedback matching the list filters, for "select all matching".
+
+ Takes the same filters as GET /feedback/review. At most 500 ids are returned:
+ ``total`` is how many matched, and ``capped`` says whether the cap applied.
+ """
+ try:
+ filters = _parse_feedback_list_filters()
+ items, _users = _load_admin_feedback(filters)
+ return jsonify(cap_review_ids([item.get('id') for item in items])), 200
+ except ValueError:
+ return jsonify({"error": "Invalid request parameters."}), 400
+ except Exception as e:
+ log_event('[FEEDBACK_REVIEW] Matching feedback ids could not be listed.', {
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return jsonify({"error": "The matching feedback could not be listed."}), 500
+
@bp.route("/feedback/review/stats", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
@feedback_admin_required
@enabled_required("enable_user_feedback")
def feedback_review_stats():
- """Return aggregate feedback review statistics for the admin page."""
+ """Return aggregate feedback review statistics for the admin page.
+
+ With ``days`` (7, 30 or 90) the response also carries the Review center dashboard:
+ feedback awaiting review, negative feedback and the acknowledgement rate in the
+ window, archived feedback, feedback per day by rating and the oldest feedback
+ awaiting review. Every other field is unchanged.
+ """
try:
filter_type, filter_ack_bool, archive_state = _parse_feedback_filters(
include_archive_state=True,
)
+ days = parse_review_window(request.args.get('days'))
items = _query_feedback_items(
filter_type=filter_type,
filter_ack_bool=filter_ack_bool,
archive_state=archive_state,
)
- return jsonify(_build_feedback_stats(items))
+ stats = _build_feedback_stats(items)
+ if days:
+ stats.update(_build_feedback_window_stats(days))
+ return jsonify(stats)
except ValueError:
logging.exception("Invalid request parameters for feedback review stats")
return jsonify({"error": "Invalid request parameters."}), 400
@@ -476,22 +896,68 @@ def feedback_review_stats():
@feedback_admin_required
@enabled_required("enable_user_feedback")
def feedback_review_export():
- """Export feedback review rows as CSV for the active filter set."""
+ """Export feedback review rows as CSV for the active filter set.
+
+ Takes the same filters as GET /feedback/review, so an export matches the list.
+ """
try:
- filter_type, filter_ack_bool, archive_state = _parse_feedback_filters(
- include_archive_state=True,
- )
- items = _query_feedback_items(
- filter_type=filter_type,
- filter_ack_bool=filter_ack_bool,
- archive_state=archive_state,
- )
+ filters = _parse_feedback_list_filters()
+ items, _users = _load_admin_feedback(filters)
return _build_feedback_export_response(items, 'feedback_review_export', include_user_id=True)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": f"Failed to export feedback: {str(e)}"}), 500
+ @bp.route("/feedback/review/bulk", methods=["POST"])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @feedback_admin_required
+ @enabled_required("enable_user_feedback")
+ def feedback_review_bulk():
+ """Apply up to 100 review operations to feedback, each as its single route would.
+
+ Body: ``{"operations": [{"id", "op", "etag"?, ...}]}``. ``update`` carries
+ ``changes``, the same fields PATCH /feedback/review/ accepts; ``archive`` carries
+ ``archived``; ``delete`` carries nothing more. Each result repeats the single route's
+ response with ``ok`` and ``status``, in request order; one failure never stops the
+ others.
+ """
+ actor = _get_feedback_admin_actor()
+ if not actor.get('id'):
+ return jsonify({'error': 'No user ID found in session'}), 403
+ try:
+ operations = parse_review_bulk_operations(request.get_json(silent=True))
+ except ReviewRequestError as error:
+ return jsonify({'error': error.message, 'code': error.code}), error.status
+
+ results = []
+ for operation in operations:
+ if operation.get('error'):
+ results.append(review_bulk_result(
+ operation, operation['error']['body'], operation['error']['status'],
+ ))
+ continue
+ if operation['op'] == 'update':
+ changes = {key: value for key, value in operation['changes'].items() if key != 'etag'}
+ if operation['etag']:
+ changes['etag'] = operation['etag']
+ body, status = _apply_feedback_review_update(operation['id'], changes, actor)
+ elif operation['op'] == 'archive':
+ body, status = _archive_feedback(operation['id'], operation['archived'], actor, operation['etag'])
+ else:
+ body, status = _delete_feedback(operation['id'], actor, operation['etag'])
+ results.append(review_bulk_result(operation, body, status))
+
+ summary = summarize_review_bulk_results(results)
+ log_event('[FEEDBACK_REVIEW] Bulk feedback review applied.', {
+ 'actor_id': actor.get('id'),
+ 'operation_count': len(results),
+ 'succeeded': summary['succeeded'],
+ 'failed': summary['failed'],
+ })
+ return jsonify(summary), 200
+
@bp.route("/feedback/review/", methods=["GET"])
@swagger_route(security=get_auth_security())
@login_required
@@ -501,6 +967,8 @@ def feedback_review_get_single(feedbackId):
"""
Fetch a single feedback item by its ID.
Needed for the edit modal after switching to pagination.
+
+ Also carries ``etag`` (send it back with a save) and the user's display name and email.
"""
try:
# Assuming feedbackId is the partition key as well
@@ -516,9 +984,11 @@ def feedback_review_get_single(feedbackId):
"feedbackType": _normalize_feedback_type(feedback_doc.get("feedbackType")) or feedback_doc.get("feedbackType"),
"reason": feedback_doc.get("reason"),
"timestamp": feedback_doc.get("timestamp"),
- "adminReview": feedback_doc.get("adminReview", {})
+ "adminReview": feedback_doc.get("adminReview", {}),
+ "etag": feedback_doc.get("_etag"),
}
result.update(serialize_archive_metadata(feedback_doc))
+ _with_feedback_user_names([result])
return jsonify(result)
except CosmosResourceNotFoundError: # Import this if not already done
@@ -536,42 +1006,15 @@ def feedback_review_get_single(feedbackId):
@enabled_required("enable_user_feedback")
def feedback_review_update(feedbackId):
"""
- Patch admin fields: acknowledged, analysisNotes, responseToUser, actionTaken
+ Patch admin fields: acknowledged, analysisNotes, responseToUser, actionTaken.
+
+ The reviewer is recorded as adminReview.analyzedBy. ``etag``, when sent, must match
+ the stored record, or 409 ``record_changed`` is returned. ``notify_user: true`` sends
+ the user a notification with the response to their feedback.
"""
data = request.get_json()
-
- try:
- # Assume feedbackId is the partition key
- feedback_doc = cosmos_feedback_container.read_item(
- item=feedbackId, partition_key=feedbackId
- )
- except CosmosResourceNotFoundError:
- return jsonify({"error": "Feedback not found"}), 404
- except Exception as e:
- print(f"Error reading feedback item {feedbackId} for update: {e}")
- return jsonify({"error": "Failed to read feedback item"}), 500
-
-
- admin_review_data = feedback_doc.get("adminReview", {}) # Get current or default dict
-
- # Update fields based on request data
- admin_review_data["acknowledged"] = data.get("acknowledged", admin_review_data.get("acknowledged", False))
- admin_review_data["analysisNotes"] = data.get("analysisNotes", admin_review_data.get("analysisNotes"))
- admin_review_data["responseToUser"] = data.get("responseToUser", admin_review_data.get("responseToUser"))
- admin_review_data["actionTaken"] = data.get("actionTaken", admin_review_data.get("actionTaken"))
- admin_review_data["reviewTimestamp"] = datetime.utcnow().isoformat()
- # Optionally add analyzedBy from session user
- # if 'user' in session:
- # admin_review_data["analyzedBy"] = session['user'].get('oid') or session['user'].get('sub')
-
- feedback_doc["adminReview"] = admin_review_data # Assign updated dict back
-
- try:
- cosmos_feedback_container.upsert_item(feedback_doc)
- return jsonify({"success": True})
- except Exception as e:
- print(f"Error updating feedback item {feedbackId}: {e}")
- return jsonify({"error": "Failed to save changes"}), 500
+ body, status = _apply_feedback_review_update(feedbackId, data, _get_feedback_admin_actor())
+ return jsonify(body), status
@bp.route("/feedback/review//archive", methods=["PATCH"])
@swagger_route(security=get_auth_security())
@@ -579,47 +1022,20 @@ def feedback_review_update(feedbackId):
@feedback_admin_required
@enabled_required("enable_user_feedback")
def feedback_review_archive(feedbackId):
- """Archive or unarchive a feedback review record."""
- data = request.get_json() or {}
- archived = data.get('archived')
- if not isinstance(archived, bool):
- return jsonify({'error': 'The archived field must be a boolean.'}), 400
-
- actor = _get_feedback_admin_actor()
- if not actor.get('id'):
- return jsonify({'error': 'No user ID found in session'}), 403
+ """Archive or unarchive a feedback review record.
- try:
- feedback_doc = cosmos_feedback_container.read_item(
- item=feedbackId,
- partition_key=feedbackId,
- )
- was_archived = bool(feedback_doc.get('is_archived'))
- apply_archive_state(feedback_doc, archived, actor['id'])
- cosmos_feedback_container.upsert_item(feedback_doc)
- except CosmosResourceNotFoundError:
- return jsonify({'error': 'Feedback not found'}), 404
- except Exception as e:
- log_event(
- '[FEEDBACK_LIFECYCLE] Failed to update feedback archive state',
- {'feedback_id': feedbackId, 'error': str(e)},
- level=logging.ERROR,
- )
- return jsonify({'error': 'Failed to update feedback archive state'}), 500
-
- lifecycle_action = 'archive' if archived else 'unarchive'
- audit_logged = log_review_lifecycle_action(
- 'feedback',
- lifecycle_action,
- feedback_doc,
- actor,
- was_archived=was_archived,
+ ``etag``, when sent, must match the stored record or 409 ``record_changed`` is returned.
+ """
+ data = request.get_json() or {}
+ if not isinstance(data, dict):
+ return jsonify({'error': 'The request body must be an object.'}), 400
+ body, status = _archive_feedback(
+ feedbackId,
+ data.get('archived'),
+ _get_feedback_admin_actor(),
+ data.get('etag') if isinstance(data.get('etag'), str) else None,
)
- if not audit_logged:
- _log_feedback_audit_failure(feedbackId, lifecycle_action)
-
- message = 'Feedback archived successfully.' if archived else 'Feedback unarchived successfully.'
- return _feedback_lifecycle_response(message, audit_logged), 200
+ return jsonify(body), status
@bp.route("/feedback/review/", methods=["DELETE"])
@swagger_route(security=get_auth_security())
@@ -628,42 +1044,8 @@ def feedback_review_archive(feedbackId):
@enabled_required("enable_user_feedback")
def feedback_review_delete(feedbackId):
"""Permanently delete a feedback review record."""
- actor = _get_feedback_admin_actor()
- if not actor.get('id'):
- return jsonify({'error': 'No user ID found in session'}), 403
-
- try:
- feedback_doc = cosmos_feedback_container.read_item(
- item=feedbackId,
- partition_key=feedbackId,
- )
- cosmos_feedback_container.delete_item(
- item=feedbackId,
- partition_key=feedbackId,
- )
- except CosmosResourceNotFoundError:
- return jsonify({'error': 'Feedback not found'}), 404
- except Exception as e:
- log_event(
- '[FEEDBACK_LIFECYCLE] Failed to delete feedback',
- {'feedback_id': feedbackId, 'error': str(e)},
- level=logging.ERROR,
- )
- return jsonify({'error': 'Failed to delete feedback'}), 500
-
- audit_logged = log_review_lifecycle_action(
- 'feedback',
- 'delete',
- feedback_doc,
- actor,
- )
- if not audit_logged:
- _log_feedback_audit_failure(feedbackId, 'delete')
-
- return _feedback_lifecycle_response(
- 'Feedback permanently deleted.',
- audit_logged,
- ), 200
+ body, status = _delete_feedback(feedbackId, _get_feedback_admin_actor())
+ return jsonify(body), status
@bp.route("/feedback/retest/", methods=["POST"])
diff --git a/application/single_app/route_backend_safety.py b/application/single_app/route_backend_safety.py
index 2b722460c..9aaabf554 100644
--- a/application/single_app/route_backend_safety.py
+++ b/application/single_app/route_backend_safety.py
@@ -4,6 +4,7 @@
import io
import logging
+from azure.core import MatchConditions
from azure.core.exceptions import AzureError
from azure.cosmos.exceptions import CosmosAccessConditionFailedError, CosmosResourceNotFoundError
from flask import make_response
@@ -14,6 +15,7 @@
from functions_chat_content_review import (
ChatContentReviewConflict,
content_checks_report_enabled,
+ count_unchecked_chat_content,
list_unchecked_chat_content,
recheck_chat_message,
)
@@ -26,9 +28,28 @@
create_approval_request,
get_approval_roles_for_request_type,
mark_approval_executed,
+ withdraw_approval_request,
)
from functions_authentication import *
+from functions_review_center import (
+ REVIEW_RECORD_CHANGED_CODE,
+ ReviewRequestError,
+ cap_review_ids,
+ daily_counts,
+ in_review_window,
+ normalize_review_search,
+ parse_review_bulk_operations,
+ parse_review_date,
+ parse_review_window,
+ resolve_review_users,
+ review_bulk_result,
+ review_day,
+ review_text_matches,
+ review_window,
+ summarize_review_bulk_results,
+)
from functions_review_lifecycle import (
+ ARCHIVE_STATE_ALL,
apply_archive_state,
append_archive_query_filter,
log_review_lifecycle_action,
@@ -36,29 +57,133 @@
serialize_archive_metadata,
)
from functions_safety_remediation import (
+ SAFETY_LOG_WRITE_ATTEMPTS,
SAFETY_REMEDIATION_BLOCK,
SAFETY_REMEDIATION_SUSPEND,
SAFETY_REMEDIATION_WARNING,
+ SAFETY_REQUEST_STATUSES,
+ SAFETY_WARNING_REPLACED_CODE,
+ SAFETY_WARNING_REPLACED_MESSAGE,
+ SAFETY_WARNING_SEND_CLAIM_FIELDS,
+ SafetyLogConflict,
+ acknowledge_safety_warning,
+ build_safety_action_execution_updates,
+ claim_safety_warning_send,
execute_safety_violation_action,
+ is_interrupted_safety_warning_send,
+ list_pending_safety_warnings,
+ mark_interrupted_safety_warning_send,
+ present_safety_warning_send_state,
+ record_safety_warning_send,
+ reconcile_pending_safety_log,
+ reconcile_pending_safety_logs,
resolve_safety_target_user,
+ safety_remediation_state,
+ safety_warning_send_in_progress,
+ serialize_safety_warning_state,
+ write_safety_log_updates,
)
+from functions_activity_logging import log_general_admin_action
from functions_settings import *
from swagger_wrapper import swagger_route, get_auth_security
ALLOWED_SAFETY_PAGE_SIZES = {10, 20, 50, 100}
ALLOWED_SAFETY_STATUSES = {'New', 'In-Review', 'Resolved', 'Dismissed'}
-ALLOWED_SAFETY_ACTIONS = {'None', 'WarnUser', 'SuspendUser', 'Escalate', 'BlockUser'}
+# Escalate was a label with no workflow behind it. It can no longer be chosen, but a record
+# that already carries it stays editable, so it is still accepted when it is unchanged.
+SAFETY_ACTION_ESCALATE_LEGACY = 'Escalate'
+SELECTABLE_SAFETY_ACTIONS = {'None', 'WarnUser', 'SuspendUser', 'BlockUser'}
+ALLOWED_SAFETY_ACTIONS = SELECTABLE_SAFETY_ACTIONS | {SAFETY_ACTION_ESCALATE_LEGACY}
+SAFETY_ESCALATE_RETIRED_MESSAGE = (
+ 'Escalate is no longer available as a safety action. '
+ 'Choose None, Warn user, Suspend user or Block user.'
+)
+SAFETY_WARNING_SEND_FAILED_MESSAGE = 'The warning notification could not be sent.'
+# Of two overlapping saves that would send a warning -- a double-click, or two reviewers --
+# only the one that claims the violation first sends it; the other is refused with this code.
+SAFETY_WARNING_IN_PROGRESS_CODE = 'safety_warning_in_progress'
+SAFETY_WARNING_SENDING_MESSAGE = (
+ 'A warning for this violation is being sent right now, so nothing was saved. '
+ 'Reload the violation in a moment to see the result.'
+)
+SAFETY_WARNING_CLAIM_CONFLICT_MESSAGE = (
+ 'Another save of this violation got there first, so this one saved nothing and sent no '
+ 'warning. Reload the violation in a moment to see the result.'
+)
+SAFETY_WARNING_NOT_RECORDED_CODE = 'safety_warning_not_recorded'
+SAFETY_WARNING_NOT_RECORDED_MESSAGE = (
+ 'The warning was sent to the user, but it could not be recorded on this violation. '
+ 'Reload the violation before saving it again, so the warning is not sent twice.'
+)
SAFETY_REMEDIATION_ACTIONS = {
SAFETY_REMEDIATION_WARNING,
SAFETY_REMEDIATION_SUSPEND,
SAFETY_REMEDIATION_BLOCK,
}
+# Suspend and block restrict access, so another eligible reviewer must approve them.
+SAFETY_APPROVAL_REQUIRED_ACTIONS = {
+ SAFETY_REMEDIATION_SUSPEND,
+ SAFETY_REMEDIATION_BLOCK,
+}
SAFETY_ACTION_REQUEST_TYPE_MAP = {
SAFETY_REMEDIATION_WARNING: TYPE_WARN_USER,
SAFETY_REMEDIATION_SUSPEND: TYPE_SUSPEND_USER,
SAFETY_REMEDIATION_BLOCK: TYPE_BLOCK_USER,
}
+SAFETY_PENDING_UPDATE_MESSAGE = 'This violation already has a pending remediation approval request.'
+SAFETY_PENDING_DELETE_MESSAGE = (
+ 'This safety violation cannot be deleted while a remediation approval request is pending.'
+)
+SAFETY_REMEDIATION_PENDING_CODE = 'remediation_pending'
+SAFETY_RECORD_CHANGED_MESSAGE = (
+ 'This violation changed after you opened it. Reload it to see the latest version, then try again.'
+)
+SAFETY_REQUEST_NOT_RECORDED_MESSAGE = (
+ 'This violation changed while the request was being created, so nothing was requested. '
+ 'Reload it to see the latest version, then try again.'
+)
+SAFETY_REQUEST_WITHDRAWN_COMMENT = (
+ 'Withdrawn automatically: the safety violation changed while this request was being created, '
+ 'so the request was never recorded on it.'
+)
+SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE = (
+ 'This safety violation cannot be archived or restored while a warning for it '
+ 'is being sent. Try again in a moment.'
+)
+# "Open" is the reviewer's queue: everything not yet resolved or dismissed.
+SAFETY_LIST_STATUS_OPEN = 'open'
+SAFETY_OPEN_STATUSES = {'New', 'In-Review'}
+SAFETY_WARNING_FILTERS = {'pending', 'acknowledged', 'not_tracked'}
+# The remediation request fields an approval request writes beside a reviewer's own changes,
+# and the archive fields, so a conflicting write is merged field by field and never drops
+# another writer's change. A reviewer's save writes only the review fields it was sent.
+SAFETY_REQUEST_FIELDS = (
+ 'action_request_id',
+ 'action_request_type',
+ 'action_requested_at',
+ 'action_approved_at',
+ 'action_executed_at',
+ 'action_request_status',
+ 'action_request_decided_at',
+ 'action_execution_error',
+ 'action_notification_title',
+ 'action_notification_message',
+ 'action_datetime_to_allow',
+)
+SAFETY_ARCHIVE_FIELDS = (
+ 'is_archived',
+ 'archived_at',
+ 'archived_by',
+ 'unarchived_at',
+ 'unarchived_by',
+ 'last_updated',
+)
+SAFETY_REPEAT_USERS_LIMIT = 10
+SAFETY_ACTION_LABELS = {
+ SAFETY_REMEDIATION_SUSPEND: 'suspension',
+ SAFETY_REMEDIATION_BLOCK: 'block',
+}
def _get_safety_session_user_id():
@@ -162,6 +287,7 @@ def _query_safety_logs(
filter_status=None,
filter_action=None,
archive_state='active',
+ reconcile=False,
):
query = "SELECT * FROM c"
where_clauses = []
@@ -192,8 +318,14 @@ def _query_safety_logs(
parameters=parameters,
enable_cross_partition_query=True,
))
+ if reconcile:
+ # Before anything is added to the records: a settled violation is written back
+ # whole, so it must still be exactly the stored document.
+ logs = reconcile_pending_safety_logs(logs)
for log_item in logs:
log_item.update(serialize_archive_metadata(log_item))
+ present_safety_warning_send_state(log_item)
+ log_item.update(serialize_safety_warning_state(log_item))
return strip_private_chat_checks(logs) if user_id else logs
@@ -262,6 +394,349 @@ def _build_safety_stats(logs):
return stats
+def _safety_record_time(log_item):
+ return log_item.get('created_at') or log_item.get('timestamp')
+
+
+def _safety_category_names(log_item):
+ names = []
+ for entry in log_item.get('triggered_categories') or []:
+ if isinstance(entry, dict):
+ name = str(entry.get('category') or '').strip()
+ if name:
+ names.append(name)
+ return list(dict.fromkeys(names))
+
+
+def _safety_highest_severity(log_item):
+ severities = [
+ entry.get('severity')
+ for entry in log_item.get('triggered_categories') or []
+ if isinstance(entry, dict)
+ and isinstance(entry.get('severity'), (int, float))
+ and not isinstance(entry.get('severity'), bool)
+ ]
+ return int(max(severities)) if severities else None
+
+
+def _safety_request_state(log_item):
+ return str(log_item.get('action_request_status') or '').strip().lower()
+
+
+def _parse_safety_list_filters():
+ """Read the Review center list filters. Raises ValueError for a value that can't be used."""
+ args = request.args
+ severity_text = (args.get('severity') or '').strip()
+ if severity_text and not severity_text.isdigit():
+ raise ReviewRequestError('The severity must be a whole number.', code='invalid_severity')
+ request_state = (args.get('request') or '').strip().lower() or None
+ if request_state and request_state not in SAFETY_REQUEST_STATUSES:
+ raise ReviewRequestError('Unknown remediation request state.', code='invalid_request_state')
+ warning = (args.get('warning') or '').strip().lower() or None
+ if warning and warning not in SAFETY_WARNING_FILTERS:
+ raise ReviewRequestError('Unknown warning acknowledgment state.', code='invalid_warning_state')
+ return {
+ 'status': (args.get('status') or '').strip() or None,
+ 'action': (args.get('action') or '').strip() or None,
+ 'archive': normalize_archive_state(args.get('archive', None, type=str)),
+ 'search': normalize_review_search(args.get('search')),
+ 'user_id': (args.get('user_id') or '').strip() or None,
+ 'category': (args.get('category') or '').strip().lower() or None,
+ 'severity': int(severity_text) if severity_text else None,
+ 'request': request_state,
+ 'warning': warning,
+ 'restricted': (args.get('restricted') or '').strip().lower() in ('1', 'true'),
+ 'date': parse_review_date(args.get('date')),
+ 'days': parse_review_window(args.get('days')),
+ }
+
+
+def _safety_restricts_user_now(log_item, users):
+ """True for an applied suspension or block whose user is still restricted."""
+ if log_item.get('action') not in SAFETY_APPROVAL_REQUIRED_ACTIONS or _safety_request_state(log_item) != 'executed':
+ return False
+ access = (users.get(log_item.get('user_id')) or {}).get('access') or {}
+ return access.get('restricted') is True
+
+
+def _safety_log_matches(log_item, filters, users, window):
+ status = str(log_item.get('status') or 'New')
+ if filters['status'] == SAFETY_LIST_STATUS_OPEN and status not in SAFETY_OPEN_STATUSES:
+ return False
+ if filters['user_id'] and log_item.get('user_id') != filters['user_id']:
+ return False
+ if filters['category'] and filters['category'] not in {
+ name.lower() for name in _safety_category_names(log_item)
+ }:
+ return False
+ if filters['severity'] is not None and _safety_highest_severity(log_item) != filters['severity']:
+ return False
+ if filters['request'] and _safety_request_state(log_item) != filters['request']:
+ return False
+ if filters['warning'] and log_item.get('warning_acknowledgment_status') != filters['warning']:
+ return False
+ if filters['restricted'] and not _safety_restricts_user_now(log_item, users):
+ return False
+ if filters['date'] and review_day(_safety_record_time(log_item)) != filters['date']:
+ return False
+ if window and not in_review_window(_safety_record_time(log_item), window):
+ return False
+ if filters['search']:
+ user = users.get(log_item.get('user_id')) or {}
+ return review_text_matches(
+ filters['search'],
+ log_item.get('id'),
+ log_item.get('message'),
+ log_item.get('notes'),
+ log_item.get('user_notes'),
+ log_item.get('user_id'),
+ user.get('display_name'),
+ user.get('email'),
+ ' '.join(_safety_category_names(log_item)),
+ )
+ return True
+
+
+def _load_admin_safety_logs(filters):
+ """The violations a Review center list or "select all matching" covers, newest first.
+
+ Violations whose remediation request was decided or has expired are settled first, so
+ a list never shows a violation locked by a request that can no longer be decided.
+ Returns ``(logs, users)``, where ``users`` holds the names already looked up.
+ """
+ status = filters['status']
+ logs = _query_safety_logs(
+ filter_status=None if status == SAFETY_LIST_STATUS_OPEN else status,
+ filter_action=filters['action'],
+ archive_state=filters['archive'],
+ reconcile=True,
+ )
+ users = {}
+ if filters['search'] or filters['restricted']:
+ users = resolve_review_users(
+ [log_item.get('user_id') for log_item in logs],
+ include_access=filters['restricted'],
+ )
+ window = review_window(filters['days']) if filters['days'] else None
+ selected = [log_item for log_item in logs if _safety_log_matches(log_item, filters, users, window)]
+ return selected, users
+
+
+def _with_safety_user_names(items, users=None):
+ """Add each record's user display name and email, looked up in one batch."""
+ names = dict(users or {})
+ missing = [item.get('user_id') for item in items if item.get('user_id') not in names]
+ if missing:
+ names.update(resolve_review_users(missing))
+ for item in items:
+ entry = names.get(item.get('user_id')) or {}
+ item['user_display_name'] = entry.get('display_name') or None
+ item['user_email'] = entry.get('email') or None
+ return items
+
+
+def _count_other_user_violations(user_id, log_id):
+ """How many other violations the same user has, or None when that can't be read."""
+ if not user_id:
+ return 0
+ try:
+ rows = cosmos_safety_container.query_items(
+ query="SELECT c.id, c.content_origin FROM c WHERE c.user_id = @user_id",
+ parameters=[{"name": "@user_id", "value": user_id}],
+ enable_cross_partition_query=True,
+ )
+ return sum(
+ 1 for row in rows
+ if isinstance(row, dict)
+ and row.get('id') != log_id
+ and row.get('content_origin', 'user') == 'user'
+ )
+ except Exception as exc:
+ log_event(
+ '[SAFETY_VIOLATIONS] The user violation count could not be read.',
+ {'safety_log_id': log_id, 'error_type': type(exc).__name__},
+ level=logging.WARNING,
+ )
+ return None
+
+
+def _build_safety_window_stats(days):
+ """The Safety dashboard for the last ``days`` days, read across active and archived records."""
+ window = review_window(days)
+ logs = _query_safety_logs(archive_state=ARCHIVE_STATE_ALL, reconcile=True)
+ in_window = [log_item for log_item in logs if in_review_window(_safety_record_time(log_item), window)]
+ tracked_warnings = [
+ log_item for log_item in logs
+ if log_item.get('warning_acknowledgment_status') in ('pending', 'acknowledged')
+ ]
+ sent_in_window = [
+ log_item for log_item in tracked_warnings
+ if in_review_window(log_item.get('warning_issued_at'), window)
+ ]
+
+ restricting = sorted({
+ log_item.get('user_id') for log_item in logs
+ if log_item.get('user_id')
+ and log_item.get('action') in SAFETY_APPROVAL_REQUIRED_ACTIONS
+ and _safety_request_state(log_item) == 'executed'
+ })
+ restricted_user_count = 0
+ if restricting:
+ access_by_user = resolve_review_users(restricting, include_access=True)
+ restricted_user_count = sum(
+ 1 for user_id in restricting
+ if ((access_by_user.get(user_id) or {}).get('access') or {}).get('restricted') is True
+ ) if access_by_user else None
+
+ try:
+ unchecked_chat_count = count_unchecked_chat_content()
+ except Exception as exc:
+ log_event(
+ '[CHAT_CONTENT_CHECKS] Unchecked chat content could not be counted for the dashboard.',
+ {'error_type': type(exc).__name__},
+ level=logging.WARNING,
+ )
+ unchecked_chat_count = None
+
+ per_user = {}
+ severity_counts = {}
+ action_counts = {}
+ for log_item in in_window:
+ severity = _safety_highest_severity(log_item)
+ severity_counts[severity] = severity_counts.get(severity, 0) + 1
+ action = str(log_item.get('action') or 'None')
+ action_counts[action] = action_counts.get(action, 0) + 1
+ user_id = log_item.get('user_id')
+ if user_id and log_item.get('content_origin', 'user') == 'user':
+ per_user[user_id] = per_user.get(user_id, 0) + 1
+ repeat = sorted(
+ ((user_id, count) for user_id, count in per_user.items() if count >= 2),
+ key=lambda pair: (-pair[1], pair[0]),
+ )[:SAFETY_REPEAT_USERS_LIMIT]
+ repeat_names = resolve_review_users([user_id for user_id, _count in repeat])
+
+ return {
+ 'window': {
+ 'days': window['days'],
+ 'start_date': window['start_date'],
+ 'end_date': window['end_date'],
+ },
+ 'received_count': len(in_window),
+ 'open_count': sum(
+ 1 for log_item in logs
+ if not log_item.get('isArchived') and str(log_item.get('status') or 'New') in SAFETY_OPEN_STATUSES
+ ),
+ 'pending_remediation_count': sum(1 for log_item in logs if _safety_request_state(log_item) == 'pending'),
+ 'restricted_user_count': restricted_user_count,
+ 'warnings_sent_count': len(sent_in_window),
+ 'warnings_acknowledged_count': sum(
+ 1 for log_item in sent_in_window if log_item.get('warning_acknowledgment_status') == 'acknowledged'
+ ),
+ 'warnings_pending_count': sum(
+ 1 for log_item in tracked_warnings if log_item.get('warning_acknowledgment_status') == 'pending'
+ ),
+ 'unchecked_chat_count': unchecked_chat_count,
+ 'daily_by_category': daily_counts(
+ in_window,
+ window,
+ _safety_record_time,
+ lambda log_item: _safety_category_names(log_item) or ['Uncategorized'],
+ ),
+ 'severity_mix': [
+ {'severity': severity, 'count': count}
+ for severity, count in sorted(
+ severity_counts.items(),
+ key=lambda pair: (pair[0] is None, pair[0] if pair[0] is not None else 0),
+ )
+ ],
+ 'action_mix': [
+ {'action': action, 'count': count}
+ for action, count in sorted(action_counts.items(), key=lambda pair: (-pair[1], pair[0]))
+ ],
+ 'repeat_users': [
+ {
+ 'user_id': user_id,
+ 'display_name': (repeat_names.get(user_id) or {}).get('display_name') or None,
+ 'email': (repeat_names.get(user_id) or {}).get('email') or None,
+ 'count': count,
+ }
+ for user_id, count in repeat
+ ],
+ }
+
+
+def _record_changed_body():
+ return {'error': SAFETY_RECORD_CHANGED_MESSAGE, 'code': REVIEW_RECORD_CHANGED_CODE}
+
+
+def _write_attempts(expected_etag):
+ """How often a save may write: once when it names the version it read, so a conflict is
+ refused rather than merged onto a newer version; otherwise it is merged field by field."""
+ return 1 if expected_etag else SAFETY_LOG_WRITE_ATTEMPTS
+
+
+def _remediation_unchanged(read_item):
+ """A guard for a reviewer's save: the violation's request and warning are as the save read them.
+
+ A conflicting write is merged onto a fresh read only while nothing a remediation decision
+ rests on has moved -- no warning is being sent or was recorded since, and the violation
+ waits on the same request -- so a save never lands on another save's warning or request.
+ """
+ expected = safety_remediation_state(read_item)
+
+ def guard(current):
+ return not safety_warning_send_in_progress(current) and safety_remediation_state(current) == expected
+
+ return guard
+
+
+def _withdraw_safety_request(approval, actor, log_id):
+ """Withdraw a remediation request this save created but could not record on its violation.
+
+ Nothing links the violation to the request then, so it is withdrawn at once instead of
+ being left for a reviewer to approve. Should withdrawing fail, approval still refuses to
+ carry out a request its violation is not waiting on. Returns True when it was withdrawn.
+ """
+ approval = approval or {}
+ try:
+ withdrawn = withdraw_approval_request(
+ approval_id=approval.get('id'),
+ group_id=approval.get('group_id'),
+ withdrawn_by_id=actor.get('id'),
+ withdrawn_by_email=actor.get('email') or '',
+ withdrawn_by_name=actor.get('name') or '',
+ comment=SAFETY_REQUEST_WITHDRAWN_COMMENT,
+ )
+ except Exception as exc:
+ log_event(
+ '[SAFETY_REMEDIATION] A remediation request its violation could not record was not withdrawn.',
+ {'safety_log_id': log_id, 'approval_id': approval.get('id'), 'error_type': type(exc).__name__},
+ level=logging.ERROR,
+ )
+ return False
+ log_event(
+ '[SAFETY_REMEDIATION] A remediation request was withdrawn because its violation changed while it was created.',
+ {
+ 'safety_log_id': log_id,
+ 'approval_id': approval.get('id'),
+ 'actor_id': actor.get('id'),
+ 'withdrawn': withdrawn is not None,
+ },
+ level=logging.WARNING,
+ )
+ return withdrawn is not None
+
+
+def _response_parts(result):
+ """The JSON body and status of a route-style result, for one bulk operation."""
+ if isinstance(result, tuple):
+ response, status = result[0], result[1]
+ else:
+ response, status = result, getattr(result, 'status_code', 200)
+ body = response.get_json(silent=True) if hasattr(response, 'get_json') else response
+ return (body if isinstance(body, dict) else {}), int(status)
+
+
def _build_safety_export_response(logs, filename_prefix, include_user_id=False):
output = io.StringIO()
writer = csv.writer(output)
@@ -312,7 +787,7 @@ def _build_safety_export_response(logs, filename_prefix, include_user_id=False):
return response
-def _safety_lifecycle_response(message, audit_logged):
+def _safety_lifecycle_body(message, audit_logged):
response = {
'success': True,
'message': message,
@@ -322,7 +797,11 @@ def _safety_lifecycle_response(message, audit_logged):
response['audit_warning'] = (
'The record was updated, but the audit activity could not be recorded.'
)
- return jsonify(response)
+ return response
+
+
+def _safety_lifecycle_response(message, audit_logged):
+ return jsonify(_safety_lifecycle_body(message, audit_logged))
def _log_safety_audit_failure(log_id, lifecycle_action):
@@ -335,6 +814,276 @@ def _log_safety_audit_failure(log_id, lifecycle_action):
level=logging.ERROR,
)
+
+def _archive_safety_log(log_id, archived, actor, expected_etag=None):
+ """Archive or restore one violation. Returns ``(body, status)``.
+
+ Shared by the archive route and the bulk ``archive`` operation. Only the archive fields
+ are written, conditionally on the stored version, so a concurrent change to anything
+ else on the record survives. ``expected_etag`` refuses a record that changed since the
+ caller read it.
+ """
+ if not isinstance(archived, bool):
+ return {'error': 'The archived field must be a boolean.'}, 400
+ if not actor.get('id'):
+ return {'error': 'No user ID found in session'}, 403
+
+ try:
+ item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ if expected_etag and item.get('_etag') != expected_etag:
+ return _record_changed_body(), 409
+ # A warning being sent is recorded on the record when it finishes; wait for that.
+ if safety_warning_send_in_progress(item):
+ return {
+ 'error': SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE,
+ 'code': SAFETY_WARNING_IN_PROGRESS_CODE,
+ }, 409
+ was_archived = bool(item.get('is_archived'))
+ apply_archive_state(item, archived, actor['id'])
+ item['last_updated'] = datetime.utcnow().isoformat()
+ stored = write_safety_log_updates(
+ log_id,
+ {field: item.get(field) for field in SAFETY_ARCHIVE_FIELDS if field in item},
+ base_item=item,
+ guard=lambda current: not safety_warning_send_in_progress(current),
+ attempts=_write_attempts(expected_etag),
+ )
+ if stored is None:
+ return {
+ 'error': SAFETY_ARCHIVE_IN_PROGRESS_MESSAGE,
+ 'code': SAFETY_WARNING_IN_PROGRESS_CODE,
+ }, 409
+ except exceptions.CosmosResourceNotFoundError:
+ return {'error': 'Safety violation not found'}, 404
+ except SafetyLogConflict:
+ return _record_changed_body(), 409
+ except Exception as e:
+ log_event(
+ '[SAFETY_LIFECYCLE] Failed to update safety violation archive state',
+ {'safety_log_id': log_id, 'error_type': type(e).__name__},
+ level=logging.ERROR,
+ )
+ return {'error': 'Failed to update safety violation archive state'}, 500
+
+ lifecycle_action = 'archive' if archived else 'unarchive'
+ audit_logged = log_review_lifecycle_action(
+ 'safety_violation',
+ lifecycle_action,
+ item,
+ actor,
+ was_archived=was_archived,
+ )
+ if not audit_logged:
+ _log_safety_audit_failure(log_id, lifecycle_action)
+
+ message = (
+ 'Safety violation archived successfully.'
+ if archived
+ else 'Safety violation unarchived successfully.'
+ )
+ return _safety_lifecycle_body(message, audit_logged), 200
+
+
+def _delete_safety_log(log_id, actor, expected_etag=None):
+ """Permanently delete one violation, keeping its audit history. Returns ``(body, status)``.
+
+ Shared by the delete route and the bulk ``delete`` operation. A violation whose
+ remediation request is still pending is refused; one whose request was decided or has
+ expired is settled first, so it can be deleted. The delete is conditional on the version
+ read, so a violation that changes in the meantime is not deleted.
+ """
+ if not actor.get('id'):
+ return {'error': 'No user ID found in session'}, 403
+
+ try:
+ item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ if expected_etag and item.get('_etag') != expected_etag:
+ return _record_changed_body(), 409
+ item = reconcile_pending_safety_log(item)
+ if str(item.get('action_request_status') or '').strip().lower() == 'pending':
+ return {
+ 'error': SAFETY_PENDING_DELETE_MESSAGE,
+ 'code': SAFETY_REMEDIATION_PENDING_CODE,
+ }, 409
+ if safety_warning_send_in_progress(item):
+ return {
+ 'error': (
+ 'This safety violation cannot be deleted while a warning for it is '
+ 'being sent. Try again in a moment.'
+ ),
+ 'code': SAFETY_WARNING_IN_PROGRESS_CODE,
+ }, 409
+ if item.get('_etag'):
+ cosmos_safety_container.delete_item(
+ item=log_id,
+ partition_key=log_id,
+ etag=item.get('_etag'),
+ match_condition=MatchConditions.IfNotModified,
+ )
+ else:
+ cosmos_safety_container.delete_item(item=log_id, partition_key=log_id)
+ except exceptions.CosmosResourceNotFoundError:
+ return {'error': 'Safety violation not found'}, 404
+ except CosmosAccessConditionFailedError:
+ return _record_changed_body(), 409
+ except Exception as e:
+ log_event(
+ '[SAFETY_LIFECYCLE] Failed to delete safety violation',
+ {'safety_log_id': log_id, 'error_type': type(e).__name__},
+ level=logging.ERROR,
+ )
+ return {'error': 'Failed to delete safety violation'}, 500
+
+ audit_logged = log_review_lifecycle_action(
+ 'safety_violation',
+ 'delete',
+ item,
+ actor,
+ )
+ if not audit_logged:
+ _log_safety_audit_failure(log_id, 'delete')
+
+ return _safety_lifecycle_body('Safety violation permanently deleted.', audit_logged), 200
+
+
+def _log_safety_warning_sent(item, actor, notification_id):
+ """Record the reviewer's warning in the activity log, the audit an approval used to be."""
+ return log_general_admin_action(
+ admin_user_id=actor.get('id'),
+ admin_email=actor.get('email') or '',
+ action='safety_violation_warning_sent',
+ description='Sent a safety violation warning to a user.',
+ additional_context={
+ 'record_type': 'safety_violation',
+ 'record_id': item.get('id'),
+ 'target_user_id': item.get('user_id'),
+ 'notification_id': notification_id,
+ },
+ )
+
+
+def _send_safety_warning_now(item, actor, notification_title, notification_message):
+ """Send a warning as the reviewer saves it, and record it on the violation.
+
+ A suspension or block restricts access, so it waits for a second reviewer. A warning
+ restricts nothing, so it is sent at once: the reviewer's decision is audited, and the
+ user has to acknowledge the warning before carrying on.
+
+ Nothing is sent until this save has claimed the violation, with a write conditional on
+ the version it read, so of two overlapping saves -- a double-click, or two reviewers --
+ only one sends the warning. The outcome is then written on the claimed version.
+ Returns ``(response, status)``.
+ """
+ item['action_request_id'] = None
+ item['action_request_type'] = None
+ item['action_requested_at'] = None
+ item['action_approved_at'] = None
+ item['action_notification_title'] = notification_title or None
+ item['action_notification_message'] = notification_message or None
+ item['action_datetime_to_allow'] = None
+ item['action_executed_at'] = None
+ item['action_execution_error'] = None
+ item['last_updated'] = datetime.utcnow().isoformat()
+
+ try:
+ claimed, claim_id = claim_safety_warning_send(item)
+ except CosmosAccessConditionFailedError:
+ log_event(
+ '[SAFETY_REMEDIATION] An overlapping save claimed the safety warning first; this save sent nothing.',
+ {'safety_log_id': item.get('id'), 'actor_id': actor.get('id')},
+ level=logging.WARNING,
+ )
+ return jsonify({
+ 'error': SAFETY_WARNING_CLAIM_CONFLICT_MESSAGE,
+ 'code': SAFETY_WARNING_IN_PROGRESS_CODE,
+ }), 409
+
+ try:
+ execution_result = execute_safety_violation_action(
+ action=SAFETY_REMEDIATION_WARNING,
+ safety_log=claimed,
+ notification_title=notification_title,
+ notification_message=notification_message,
+ datetime_to_allow=None,
+ actor=actor,
+ )
+ except Exception as exc:
+ log_event(
+ '[SAFETY_REMEDIATION] A safety warning could not be sent.',
+ {
+ 'safety_log_id': item.get('id'),
+ 'actor_id': actor.get('id'),
+ 'error_type': type(exc).__name__,
+ },
+ level=logging.ERROR,
+ )
+ try:
+ recorded = record_safety_warning_send(claimed, claim_id, {
+ 'action_request_status': 'failed',
+ 'action_executed_at': None,
+ 'action_execution_error': SAFETY_WARNING_SEND_FAILED_MESSAGE,
+ })
+ except Exception:
+ recorded = None
+ if recorded is None:
+ log_event(
+ '[SAFETY_REMEDIATION] A failed safety warning could not be recorded on its violation.',
+ {'safety_log_id': item.get('id'), 'actor_id': actor.get('id')},
+ level=logging.ERROR,
+ )
+ return jsonify({
+ 'error': 'The warning could not be sent. The rest of the review was saved; save it again to retry.',
+ }), 500
+
+ notification_id = execution_result.get('notification_id')
+ updates = build_safety_action_execution_updates(SAFETY_REMEDIATION_WARNING, execution_result)
+ record_error_type = None
+ try:
+ stored = record_safety_warning_send(claimed, claim_id, updates)
+ except Exception as exc:
+ stored = None
+ record_error_type = type(exc).__name__
+ # The warning reached the user either way, so the reviewer's decision is audited.
+ audit_logged = _log_safety_warning_sent(stored or claimed, actor, notification_id)
+
+ if stored is None:
+ log_event(
+ '[SAFETY_REMEDIATION] A safety warning was sent, but it could not be recorded on its violation.',
+ {
+ 'safety_log_id': item.get('id'),
+ 'target_user_id': item.get('user_id'),
+ 'actor_id': actor.get('id'),
+ 'notification_id': notification_id,
+ 'error_type': record_error_type or 'claim_lost',
+ },
+ level=logging.ERROR,
+ )
+ return jsonify({
+ 'error': SAFETY_WARNING_NOT_RECORDED_MESSAGE,
+ 'code': SAFETY_WARNING_NOT_RECORDED_CODE,
+ 'audit_logged': audit_logged,
+ }), 500 if record_error_type else 409
+
+ log_event(
+ '[SAFETY_REMEDIATION] Safety warning sent without a second reviewer.',
+ {
+ 'safety_log_id': item.get('id'),
+ 'target_user_id': item.get('user_id'),
+ 'actor_id': actor.get('id'),
+ 'actor_email': actor.get('email'),
+ 'notification_id': notification_id,
+ },
+ )
+ response = {
+ 'message': 'Warning sent to the user.',
+ 'approval_required': False,
+ 'approval_id': None,
+ 'audit_logged': audit_logged,
+ }
+ if not response['audit_logged']:
+ response['audit_warning'] = 'The warning was sent, but the audit activity could not be recorded.'
+ return jsonify(response), 200
+
def register_route_backend_safety(bp):
def chat_check_error(error):
if isinstance(error, (CosmosAccessConditionFailedError, CosmosResourceNotFoundError)):
@@ -405,21 +1154,22 @@ def get_safety_logs():
Query Parameters:
page (int): The page number to retrieve (default: 1).
page_size (int): The number of items per page (default: 10).
- status (str): Filter logs by status.
+ status (str): Filter logs by status, or "open" for New and In-Review.
action (str): Filter logs by action.
+ archive (str): "active" (default) or "archived".
+ search (str): Text matched against the message, notes, categories and user.
+ user_id, category, severity, request, warning, restricted, date, days:
+ the Review center's narrower filters, used by its dashboard links.
+ Each log carries ``user_display_name`` and ``user_email``. Violations whose
+ remediation request was decided or has expired are settled before they are listed.
"""
try:
page = int(request.args.get('page', 1))
page_size = int(request.args.get('page_size', 10))
- filter_status, filter_action, archive_state = _parse_safety_filters(
- include_archive_state=True,
- )
- logs = _query_safety_logs(
- filter_status=filter_status,
- filter_action=filter_action,
- archive_state=archive_state,
- )
+ filters = _parse_safety_list_filters()
+ logs, users = _load_admin_safety_logs(filters)
paginated_items, page, page_size = _paginate_safety_logs(logs, page, page_size)
+ _with_safety_user_names(paginated_items, users)
return jsonify({
"logs": paginated_items,
@@ -435,23 +1185,56 @@ def get_safety_logs():
logging.exception("Error in get_safety_logs")
return jsonify({"error": "An internal error occurred while fetching safety logs."}), 500
+ @bp.route('/api/safety/logs/ids', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @safety_violation_admin_required
+ @content_checks_report_enabled
+ def get_safety_log_ids():
+ """Return the ids of the violations matching the list filters, for "select all matching".
+
+ Takes the same filters as GET /api/safety/logs. At most 500 ids are returned:
+ ``total`` is how many matched, and ``capped`` says whether the cap applied.
+ """
+ try:
+ filters = _parse_safety_list_filters()
+ logs, _users = _load_admin_safety_logs(filters)
+ return jsonify(cap_review_ids([log_item.get('id') for log_item in logs])), 200
+ except ValueError:
+ return jsonify({"error": "Invalid request parameters."}), 400
+ except Exception as e:
+ log_event('[SAFETY_VIOLATIONS] Matching violation ids could not be listed.', {
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return jsonify({"error": "The matching violations could not be listed."}), 500
+
@bp.route('/api/safety/logs/stats', methods=['GET'])
@swagger_route(security=get_auth_security())
@login_required
@safety_violation_admin_required
@content_checks_report_enabled
def get_safety_log_stats():
- """Return aggregate safety violation statistics for the admin page."""
+ """Return aggregate safety violation statistics for the admin page.
+
+ With ``days`` (7, 30 or 90) the response also carries the Review center dashboard:
+ open, pending remediation, restricted users, warnings sent and acknowledged,
+ unchecked chat content, violations per day by category, and the severity, action
+ and repeat-user breakdowns for the window. Every other field is unchanged.
+ """
try:
filter_status, filter_action, archive_state = _parse_safety_filters(
include_archive_state=True,
)
+ days = parse_review_window(request.args.get('days'))
logs = _query_safety_logs(
filter_status=filter_status,
filter_action=filter_action,
archive_state=archive_state,
)
- return jsonify(_build_safety_stats(logs)), 200
+ stats = _build_safety_stats(logs)
+ if days:
+ stats.update(_build_safety_window_stats(days))
+ return jsonify(stats), 200
except ValueError:
logging.exception("Invalid request parameters in get_safety_log_stats")
return jsonify({"error": "Invalid request parameters."}), 400
@@ -459,22 +1242,59 @@ def get_safety_log_stats():
logging.exception("Error in get_safety_log_stats")
return jsonify({"error": "Failed to retrieve safety stats."}), 500
+ @bp.route('/api/safety/logs/', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @safety_violation_admin_required
+ @content_checks_report_enabled
+ def get_safety_log(log_id):
+ """Return one violation for the Review center editor.
+
+ Besides the stored record, the response carries ``etag`` (send it back with a save),
+ the user's display name and email, whether the user's access is restricted now,
+ and how many other violations the user has.
+ """
+ try:
+ item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ except exceptions.CosmosResourceNotFoundError:
+ return jsonify({'error': 'Safety violation not found'}), 404
+ except Exception as e:
+ log_event('[SAFETY_VIOLATIONS] A violation could not be read.', {
+ 'safety_log_id': log_id,
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return jsonify({'error': 'The violation could not be loaded.'}), 500
+
+ item = reconcile_pending_safety_log(item)
+ record = dict(item)
+ record.update(serialize_archive_metadata(item))
+ present_safety_warning_send_state(record)
+ record.update(serialize_safety_warning_state(record))
+ user_id = item.get('user_id')
+ users = resolve_review_users([user_id], include_access=True)
+ entry = users.get(user_id) or {}
+ record.update({
+ 'etag': item.get('_etag'),
+ 'user_display_name': entry.get('display_name') or None,
+ 'user_email': entry.get('email') or None,
+ 'user_access': entry.get('access'),
+ 'user_violation_count': _count_other_user_violations(user_id, log_id),
+ })
+ return jsonify(record), 200
+
@bp.route('/api/safety/logs/export', methods=['GET'])
@swagger_route(security=get_auth_security())
@login_required
@safety_violation_admin_required
@content_checks_report_enabled
def export_safety_logs():
- """Export safety violation rows as CSV for the active filter set."""
+ """Export safety violation rows as CSV for the active filter set.
+
+ Takes the same filters as GET /api/safety/logs, so an export matches the list.
+ """
try:
- filter_status, filter_action, archive_state = _parse_safety_filters(
- include_archive_state=True,
- )
- logs = _query_safety_logs(
- filter_status=filter_status,
- filter_action=filter_action,
- archive_state=archive_state,
- )
+ filters = _parse_safety_list_filters()
+ logs, _users = _load_admin_safety_logs(filters)
return _build_safety_export_response(logs, 'admin_safety_violations_export', include_user_id=True)
except ValueError as e:
logging.exception("Invalid parameters when exporting safety logs")
@@ -491,8 +1311,32 @@ def update_safety_log(log_id):
"""
Updates status, action, and notes on a safety log.
Also sets timestamps (created_at if missing, and last_updated).
+
+ Warn user sends the warning as soon as the review is saved; saving the record again
+ does not send it twice, and of two overlapping saves only the one that claims the
+ violation first sends it. Suspend user and Block user restrict access, so they create an
+ approval request that another eligible reviewer must approve. Escalate can no longer
+ be chosen; it is only accepted unchanged on a record that already carries it.
+
+ Saving a suspension or block again with the same action requests nothing more unless
+ the body carries ``reissue: true``. ``etag``, when sent, must match the stored record,
+ or the save is refused with 409 ``record_changed``; it is then written on that version
+ only, never merged onto a newer one. A suspension or block that can't be recorded on
+ the violation, because another save moved it on meanwhile, is withdrawn and refused
+ with 409 ``record_changed``.
"""
data = request.get_json() or {}
+ if not isinstance(data, dict):
+ return jsonify({'error': 'The request body must be an object.'}), 400
+ return apply_safety_review_update(log_id, data)
+
+ def apply_safety_review_update(log_id, data):
+ """The single-record save, shared with the bulk ``update`` operation.
+
+ Returns the same response the PATCH route sends, so a bulk update behaves exactly as
+ saving that violation on its own would: a warning is sent at once, and a suspension
+ or block creates an approval request.
+ """
status = data.get("status")
action = data.get("action")
notes = data.get("notes")
@@ -507,37 +1351,86 @@ def update_safety_log(log_id):
if action and action not in ALLOWED_SAFETY_ACTIONS:
return jsonify({'error': 'Invalid safety action'}), 400
+ if notes is not None and not isinstance(notes, str):
+ return jsonify({'error': 'Notes must be text.'}), 400
+
item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
+ expected_etag = data.get('etag')
+ if expected_etag and item.get('_etag') != expected_etag:
+ return jsonify(_record_changed_body()), 409
+ # A request that was denied or has expired no longer locks the violation.
+ item = reconcile_pending_safety_log(item)
+ # What this save decides from. Its write lands only while that still holds, so it
+ # never overwrites a warning being sent or recorded, or another request.
+ unchanged_remediation = _remediation_unchanged(item)
+ previous_action = str(item.get('action') or 'None')
+ existing_request_status = str(item.get('action_request_status') or '').strip().lower()
+
+ if action == SAFETY_ACTION_ESCALATE_LEGACY and previous_action != SAFETY_ACTION_ESCALATE_LEGACY:
+ return jsonify({'error': SAFETY_ESCALATE_RETIRED_MESSAGE}), 400
if action in SAFETY_REMEDIATION_ACTIONS and item.get("content_origin", "user") != "user":
return jsonify({"error": "AI-generated findings cannot be used to warn or restrict a user."}), 400
+ # Only what this save changes is written, so a concurrent change to anything else
+ # on the violation survives it.
+ review_updates = {}
if not item.get("created_at"):
item["created_at"] = datetime.utcnow().isoformat()
+ review_updates['created_at'] = item['created_at']
- existing_request_status = str(item.get('action_request_status') or '').strip().lower()
if existing_request_status == 'pending':
return jsonify({
- 'error': 'This violation already has a pending remediation approval request.'
+ 'error': SAFETY_PENDING_UPDATE_MESSAGE,
+ 'code': SAFETY_REMEDIATION_PENDING_CODE,
}), 409
+ # While another save is sending a warning, the violation waits for it to finish.
+ if safety_warning_send_in_progress(item):
+ return jsonify({
+ 'error': SAFETY_WARNING_SENDING_MESSAGE,
+ 'code': SAFETY_WARNING_IN_PROGRESS_CODE,
+ }), 409
+ interrupted_send = is_interrupted_safety_warning_send(item)
+ if interrupted_send:
+ mark_interrupted_safety_warning_send(item)
+ existing_request_status = 'failed'
+
+ # A suspension or block is requested again only when the action changes or the
+ # reviewer asks to re-issue it. Saving notes or a status on one already requested
+ # or applied must not create a second approval request.
+ restriction_requested = action in SAFETY_APPROVAL_REQUIRED_ACTIONS and (
+ action != previous_action or data.get('reissue') is True
+ )
normalized_datetime_to_allow = None
- if action in SAFETY_REMEDIATION_ACTIONS:
+ if action == SAFETY_REMEDIATION_WARNING or restriction_requested:
normalized_datetime_to_allow = _validate_safety_remediation_request(action, datetime_to_allow)
if status:
item["status"] = status
+ review_updates['status'] = status
if action:
item["action"] = action
+ review_updates['action'] = action
if notes is not None:
item["notes"] = notes
+ review_updates['notes'] = notes
actor = _get_safety_actor_context()
if not actor.get('id'):
return jsonify({'error': 'No user ID found in session'}), 403
- if action in SAFETY_REMEDIATION_ACTIONS:
+ # Saving a warned record again, for example to resolve it, must not warn twice.
+ warning_already_sent = (
+ action == SAFETY_REMEDIATION_WARNING
+ and previous_action == SAFETY_REMEDIATION_WARNING
+ and existing_request_status == 'executed'
+ )
+ if action == SAFETY_REMEDIATION_WARNING and not warning_already_sent:
+ return _send_safety_warning_now(item, actor, notification_title, notification_message)
+
+ if restriction_requested:
target_user = resolve_safety_target_user(item.get('user_id'))
request_type = SAFETY_ACTION_REQUEST_TYPE_MAP[action]
approval_reason = notes or notification_message or f"Requested {action} for safety violation {log_id}."
@@ -563,6 +1456,7 @@ def update_safety_log(log_id):
item['action_request_id'] = approval.get('id')
item['action_request_type'] = request_type
item['action_requested_at'] = approval.get('created_at')
+ item['action_request_decided_at'] = None
item['action_execution_error'] = None
item['action_notification_title'] = notification_title or None
item['action_notification_message'] = notification_message or None
@@ -604,10 +1498,50 @@ def update_safety_log(log_id):
item['action_executed_at'] = None
item["last_updated"] = datetime.utcnow().isoformat()
+ review_updates['last_updated'] = item['last_updated']
+
+ if restriction_requested:
+ request_updates = {field: item.get(field) for field in SAFETY_REQUEST_FIELDS if field in item}
+ if interrupted_send:
+ # A save that stopped while sending a warning can never record it over this request.
+ request_updates.update({field: None for field in SAFETY_WARNING_SEND_CLAIM_FIELDS})
+ try:
+ recorded = write_safety_log_updates(
+ log_id,
+ {**review_updates, **request_updates},
+ base_item=item,
+ guard=unchanged_remediation,
+ attempts=_write_attempts(expected_etag),
+ )
+ except SafetyLogConflict:
+ recorded = None
+ except Exception:
+ _withdraw_safety_request(approval, actor, log_id)
+ raise
+ if recorded is None:
+ # The request exists, but the violation moved on -- another save sent a
+ # warning or created a request, or the version this save named changed --
+ # so nothing links the two. Withdraw it rather than leave it approvable.
+ _withdraw_safety_request(approval, actor, log_id)
+ return jsonify({
+ 'error': SAFETY_REQUEST_NOT_RECORDED_MESSAGE,
+ 'code': REVIEW_RECORD_CHANGED_CODE,
+ }), 409
+ else:
+ try:
+ written = write_safety_log_updates(
+ log_id,
+ review_updates,
+ base_item=item,
+ guard=unchanged_remediation,
+ attempts=_write_attempts(expected_etag),
+ )
+ except SafetyLogConflict:
+ written = None
+ if written is None:
+ return jsonify(_record_changed_body()), 409
- cosmos_safety_container.upsert_item(item)
-
- if action in SAFETY_REMEDIATION_ACTIONS:
+ if restriction_requested:
if item.get('action_request_status') == 'pending':
return jsonify({
'message': 'Safety log updated and remediation approval request created.',
@@ -621,67 +1555,124 @@ def update_safety_log(log_id):
'approval_id': item.get('action_request_id'),
}), 200
+ if warning_already_sent:
+ return jsonify({
+ 'message': 'Safety log updated. The warning was already sent, so it was not sent again.',
+ 'approval_required': False,
+ 'warning_already_sent': True,
+ }), 200
+
+ if action in SAFETY_APPROVAL_REQUIRED_ACTIONS:
+ label = SAFETY_ACTION_LABELS[action]
+ if existing_request_status == 'executed':
+ return jsonify({
+ 'message': (
+ f'Safety log updated. The {label} was already applied, so it was not requested again. '
+ f'To request it again, select "Request this {label} again" and save.'
+ ),
+ 'approval_required': False,
+ 'remediation_already_applied': True,
+ }), 200
+ return jsonify({
+ 'message': (
+ f'Safety log updated. No new {label} was requested. '
+ f'To request it again, select "Request this {label} again" and save.'
+ ),
+ 'approval_required': False,
+ 'remediation_unchanged': True,
+ 'remediation_status': existing_request_status or None,
+ }), 200
+
return jsonify({"message": "Safety log updated successfully."}), 200
+ except exceptions.CosmosResourceNotFoundError:
+ return jsonify({'error': 'Safety violation not found'}), 404
+ except SafetyLogConflict:
+ return jsonify(_record_changed_body()), 409
except exceptions.CosmosHttpResponseError as e:
- return jsonify({"error": str(e)}), 404
+ log_event('[SAFETY_VIOLATIONS] Failed to update safety log', {
+ 'safety_log_id': log_id,
+ 'error_type': type(e).__name__,
+ }, level=logging.ERROR)
+ return jsonify({'error': 'Failed to update safety log.'}), 500
except ValueError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
log_event('[SAFETY_VIOLATIONS] Failed to update safety log', {
'safety_log_id': log_id,
- 'error': str(e),
+ 'error_type': type(e).__name__,
}, level=logging.ERROR)
- return jsonify({'error': f'Failed to update safety log: {str(e)}'}), 500
+ return jsonify({'error': 'Failed to update safety log.'}), 500
- @bp.route('/api/safety/logs//archive', methods=['PATCH'])
+ @bp.route('/api/safety/logs/bulk', methods=['POST'])
@swagger_route(security=get_auth_security())
@login_required
@safety_violation_admin_required
@content_checks_report_enabled
- def archive_safety_log(log_id):
- """Archive or unarchive a safety violation record."""
- data = request.get_json() or {}
- archived = data.get('archived')
- if not isinstance(archived, bool):
- return jsonify({'error': 'The archived field must be a boolean.'}), 400
-
+ def bulk_update_safety_logs():
+ """Apply up to 100 review operations to violations, each as its single route would.
+
+ Body: ``{"operations": [{"id", "op", "etag"?, ...}]}``. ``update`` carries
+ ``changes``, the same fields PATCH /api/safety/logs/ accepts, and behaves
+ exactly as that save would: Warn user sends the warning, Suspend user and Block user
+ create approval requests. ``archive`` carries ``archived``; ``delete`` carries
+ nothing more. Each result repeats the single route's response with ``ok`` and
+ ``status``, in request order; one failure never stops the others.
+ """
actor = _get_safety_actor_context()
if not actor.get('id'):
return jsonify({'error': 'No user ID found in session'}), 403
-
try:
- item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
- was_archived = bool(item.get('is_archived'))
- apply_archive_state(item, archived, actor['id'])
- item['last_updated'] = datetime.utcnow().isoformat()
- cosmos_safety_container.upsert_item(item)
- except exceptions.CosmosResourceNotFoundError:
- return jsonify({'error': 'Safety violation not found'}), 404
- except Exception as e:
- log_event(
- '[SAFETY_LIFECYCLE] Failed to update safety violation archive state',
- {'safety_log_id': log_id, 'error': str(e)},
- level=logging.ERROR,
- )
- return jsonify({'error': 'Failed to update safety violation archive state'}), 500
-
- lifecycle_action = 'archive' if archived else 'unarchive'
- audit_logged = log_review_lifecycle_action(
- 'safety_violation',
- lifecycle_action,
- item,
- actor,
- was_archived=was_archived,
- )
- if not audit_logged:
- _log_safety_audit_failure(log_id, lifecycle_action)
+ operations = parse_review_bulk_operations(request.get_json(silent=True))
+ except ReviewRequestError as error:
+ return jsonify({'error': error.message, 'code': error.code}), error.status
+
+ results = []
+ for operation in operations:
+ if operation.get('error'):
+ results.append(review_bulk_result(
+ operation, operation['error']['body'], operation['error']['status'],
+ ))
+ continue
+ if operation['op'] == 'update':
+ changes = {key: value for key, value in operation['changes'].items() if key != 'etag'}
+ if operation['etag']:
+ changes['etag'] = operation['etag']
+ body, status = _response_parts(apply_safety_review_update(operation['id'], changes))
+ elif operation['op'] == 'archive':
+ body, status = _archive_safety_log(operation['id'], operation['archived'], actor, operation['etag'])
+ else:
+ body, status = _delete_safety_log(operation['id'], actor, operation['etag'])
+ results.append(review_bulk_result(operation, body, status))
+
+ summary = summarize_review_bulk_results(results)
+ log_event('[SAFETY_VIOLATIONS] Bulk safety review applied.', {
+ 'actor_id': actor.get('id'),
+ 'operation_count': len(results),
+ 'succeeded': summary['succeeded'],
+ 'failed': summary['failed'],
+ })
+ return jsonify(summary), 200
+
+ @bp.route('/api/safety/logs//archive', methods=['PATCH'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @safety_violation_admin_required
+ @content_checks_report_enabled
+ def archive_safety_log(log_id):
+ """Archive or unarchive a safety violation record.
- message = (
- 'Safety violation archived successfully.'
- if archived
- else 'Safety violation unarchived successfully.'
+ ``etag``, when sent, must match the stored record or 409 ``record_changed`` is returned.
+ """
+ data = request.get_json() or {}
+ if not isinstance(data, dict):
+ return jsonify({'error': 'The request body must be an object.'}), 400
+ body, status = _archive_safety_log(
+ log_id,
+ data.get('archived'),
+ _get_safety_actor_context(),
+ data.get('etag') if isinstance(data.get('etag'), str) else None,
)
- return _safety_lifecycle_response(message, audit_logged), 200
+ return jsonify(body), status
@bp.route('/api/safety/logs/', methods=['DELETE'])
@swagger_route(security=get_auth_security())
@@ -690,43 +1681,8 @@ def archive_safety_log(log_id):
@content_checks_report_enabled
def delete_safety_log(log_id):
"""Permanently delete a safety violation without deleting its audit history."""
- actor = _get_safety_actor_context()
- if not actor.get('id'):
- return jsonify({'error': 'No user ID found in session'}), 403
-
- try:
- item = cosmos_safety_container.read_item(item=log_id, partition_key=log_id)
- if str(item.get('action_request_status') or '').strip().lower() == 'pending':
- return jsonify({
- 'error': (
- 'This safety violation cannot be deleted while a remediation '
- 'approval request is pending.'
- )
- }), 409
- cosmos_safety_container.delete_item(item=log_id, partition_key=log_id)
- except exceptions.CosmosResourceNotFoundError:
- return jsonify({'error': 'Safety violation not found'}), 404
- except Exception as e:
- log_event(
- '[SAFETY_LIFECYCLE] Failed to delete safety violation',
- {'safety_log_id': log_id, 'error': str(e)},
- level=logging.ERROR,
- )
- return jsonify({'error': 'Failed to delete safety violation'}), 500
-
- audit_logged = log_review_lifecycle_action(
- 'safety_violation',
- 'delete',
- item,
- actor,
- )
- if not audit_logged:
- _log_safety_audit_failure(log_id, 'delete')
-
- return _safety_lifecycle_response(
- 'Safety violation permanently deleted.',
- audit_logged,
- ), 200
+ body, status = _delete_safety_log(log_id, _get_safety_actor_context())
+ return jsonify(body), status
@bp.route('/api/safety/logs/my', methods=['GET'])
@swagger_route(security=get_auth_security())
@@ -848,9 +1804,91 @@ def update_my_safety_log(log_id):
item["user_notes"] = user_notes
item["last_updated"] = datetime.utcnow().isoformat()
- cosmos_safety_container.upsert_item(item)
+ # Only the user's own fields, merged onto a fresh copy after a conflict, so a
+ # reviewer saving the same violation at that moment is never overwritten.
+ write_safety_log_updates(
+ log_id,
+ {field: item.get(field) for field in ('user_notes', 'created_at', 'last_updated') if field in item},
+ base_item=item,
+ guard=lambda current: current.get("user_id") == user_id,
+ )
return jsonify({"message": "Safety log updated successfully."}), 200
+ except SafetyLogConflict:
+ return jsonify({"error": "The content-check record changed while it was being saved. Try again."}), 409
except exceptions.CosmosHttpResponseError as e:
log_event("[CONTENT_SAFETY] User content-check note could not be saved.", extra={"error_type": type(e).__name__}, level=logging.WARNING)
return jsonify({"error": "The content-check note could not be saved."}), 404
+
+ # A warning that was sent must stay acknowledgeable even if an administrator later turns
+ # content checks reporting off, so these two routes are not gated on that setting.
+ @bp.route('/api/safety/warnings/pending', methods=['GET'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ def get_pending_safety_warnings():
+ """Return the signed-in user's own safety warnings that still need acknowledgment.
+
+ Only what the user was sent is returned: the title, message, when it was issued,
+ the violation id and its triggered categories.
+ """
+ user_id = _get_safety_session_user_id()
+ if not user_id:
+ return jsonify({"error": "No user ID found in session"}), 403
+
+ try:
+ warnings = list_pending_safety_warnings(user_id)
+ except Exception as e:
+ log_event(
+ "[SAFETY_WARNINGS] Pending safety warnings could not be read.",
+ extra={"user_id": user_id, "error_type": type(e).__name__},
+ level=logging.WARNING,
+ )
+ return jsonify({"error": "Your warnings could not be loaded."}), 500, {"Cache-Control": "no-store"}
+
+ return jsonify({"warnings": warnings, "count": len(warnings)}), 200, {"Cache-Control": "no-store"}
+
+ @bp.route('/api/safety/warnings//acknowledge', methods=['POST'])
+ @swagger_route(security=get_auth_security())
+ @login_required
+ @user_required
+ def acknowledge_pending_safety_warning(log_id):
+ """Record that the signed-in user acknowledged one of their own safety warnings.
+
+ Repeating it changes nothing. Any record that isn't the caller's own warning is
+ answered 404, so the response never confirms that another user's record exists.
+ The body may carry ``issued_at``, as listed by the pending route: when the
+ violation has since been warned about again, the warning the user read was
+ replaced, and 409 ``safety_warning_replaced`` records nothing.
+ """
+ user_id = _get_safety_session_user_id()
+ if not user_id:
+ return jsonify({"error": "No user ID found in session"}), 403
+
+ payload = request.get_json(silent=True)
+ issued_at = payload.get('issued_at') if isinstance(payload, dict) else None
+ if issued_at is not None and not isinstance(issued_at, str):
+ return jsonify({"error": "issued_at must be a string."}), 400
+
+ try:
+ status, warning = acknowledge_safety_warning(log_id, user_id, issued_at=issued_at)
+ except Exception as e:
+ log_event(
+ "[SAFETY_WARNINGS] A safety warning acknowledgment could not be saved.",
+ extra={"user_id": user_id, "error_type": type(e).__name__},
+ level=logging.ERROR,
+ )
+ return jsonify({"error": "Your acknowledgment could not be saved. Try again."}), 500
+
+ if status == 'not_found':
+ return jsonify({"error": "Warning not found."}), 404
+ if status == 'replaced':
+ return jsonify({
+ "error": SAFETY_WARNING_REPLACED_MESSAGE,
+ "code": SAFETY_WARNING_REPLACED_CODE,
+ }), 409
+ return jsonify({
+ "success": True,
+ "already_acknowledged": status == 'already_acknowledged',
+ "warning": warning,
+ }), 200
diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py
index 2f032c303..99cb683d7 100644
--- a/application/single_app/route_backend_users.py
+++ b/application/single_app/route_backend_users.py
@@ -565,6 +565,8 @@ def user_settings():
'v2AdminRailCollapsed',
# Whether the V2 Approvals categories rail is collapsed to icons.
'v2ApprovalsRailCollapsed',
+ # Whether the V2 admin Review center sections rail is collapsed to icons.
+ 'v2ReviewRailCollapsed',
# Whether the V2 User Settings sections rail is collapsed to icons.
'v2UserSettingsRailCollapsed',
# Whether the V2 Control Center section rail is collapsed to icons.
diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py
index 2e7d3926f..70e1e32c0 100644
--- a/application/single_app/route_backend_v2.py
+++ b/application/single_app/route_backend_v2.py
@@ -170,6 +170,7 @@
keyvault_model_endpoint_save_helper,
)
from functions_keyvault_errors import KeyVaultSecretStorageError
+from functions_safety_remediation import count_pending_safety_warnings
from functions_source_review import (
get_source_review_runtime_capabilities,
is_source_review_enabled_for_user,
@@ -1112,6 +1113,20 @@ def v2_bootstrap():
except Exception as exc:
logger.warning(f"[V2_BOOTSTRAP] Failed to resolve workspace sections: {exc}")
+ # Safety warnings an administrator sent that still need the user's
+ # acknowledgment. Only the count rides here, so the interface asks for the
+ # warnings themselves only when there are some. A failed read counts none; the
+ # next bootstrap, on reload or when the tab comes back, reads again.
+ pending_safety_warnings = 0
+ try:
+ pending_safety_warnings = count_pending_safety_warnings(user_id)
+ except Exception as exc:
+ log_event(
+ "[V2_BOOTSTRAP] Pending safety warnings could not be counted.",
+ extra={"user_id": user_id, "error_type": type(exc).__name__},
+ level=logging.WARNING,
+ )
+
payload = {
"version": VERSION,
"user": {
@@ -1148,6 +1163,7 @@ def v2_bootstrap():
"notices": _build_notices(public_settings, user_settings_dict),
"workspace": workspace,
"workspace_uploads": _build_workspace_uploads(public_settings),
+ "safety_warnings": {"pending": pending_safety_warnings},
"settings": public_settings,
}
diff --git a/application/single_app/static/js/access-restricted.js b/application/single_app/static/js/access-restricted.js
new file mode 100644
index 000000000..195606f70
--- /dev/null
+++ b/application/single_app/static/js/access-restricted.js
@@ -0,0 +1,32 @@
+// access-restricted.js
+// Shows a suspension's restore time in the reader's own locale and time zone. The page
+// already renders it in UTC, so the text only improves when this runs.
+
+(function () {
+ function formatLocalDateTime(value) {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ return '';
+ }
+ try {
+ return parsed.toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'short' });
+ } catch (error) {
+ return parsed.toLocaleString();
+ }
+ }
+
+ function localizeRestoreTimes() {
+ document.querySelectorAll('time[data-local-datetime]').forEach(function (element) {
+ const formatted = formatLocalDateTime(element.dataset.localDatetime || '');
+ if (formatted) {
+ element.textContent = formatted;
+ }
+ });
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', localizeRestoreTimes);
+ } else {
+ localizeRestoreTimes();
+ }
+}());
diff --git a/application/single_app/static/js/admin/admin-safety-violations.js b/application/single_app/static/js/admin/admin-safety-violations.js
index 7154ab557..25aa781ea 100644
--- a/application/single_app/static/js/admin/admin-safety-violations.js
+++ b/application/single_app/static/js/admin/admin-safety-violations.js
@@ -13,11 +13,17 @@
const SAFETY_VIEW_STORAGE_KEY = 'simplechat.admin.safetyViolations.viewMode';
const SAFETY_REMEDIATION_ACTIONS = new Set(['WarnUser', 'SuspendUser', 'BlockUser']);
+ // Escalate is no longer an action. A record that already carries it keeps it, labelled
+ // as legacy, and only that record's editor offers it again.
+ const LEGACY_ESCALATE_ACTION = 'Escalate';
+ // Every value the action select can hold. The select is read through this list, so only
+ // these constant strings, never text taken from the page, reach data attributes or a save.
+ const ACTION_VALUES = ['None', 'WarnUser', 'SuspendUser', 'BlockUser', LEGACY_ESCALATE_ACTION];
const ACTION_LABELS = {
None: 'None',
WarnUser: 'Warn user',
SuspendUser: 'Suspend user',
- Escalate: 'Escalate',
+ Escalate: 'Escalated (legacy)',
BlockUser: 'Block user',
};
@@ -415,6 +421,8 @@
const requestStatus = String(logItem.action_request_status || '').toLowerCase();
if (requestStatus === 'pending') {
actionLabel += ' (Pending approval)';
+ } else if (requestStatus === 'sending') {
+ actionLabel += ' (Sending)';
} else if (requestStatus === 'failed') {
actionLabel += ' (Execution failed)';
}
@@ -470,10 +478,99 @@
return messageLines.join('\n');
}
+ function isExecutedWarning(logItem) {
+ return logItem.action === 'WarnUser'
+ && String(logItem.action_request_status || '').toLowerCase() === 'executed';
+ }
+
+ function isRestrictionAction(action) {
+ return action === 'SuspendUser' || action === 'BlockUser';
+ }
+
+ // The action chosen in the review, as the matching entry of ACTION_VALUES. An empty or
+ // unknown value reads as None, as an empty one always has.
+ function readSelectedAction() {
+ const selectedValue = document.getElementById('editAction')?.value || '';
+ return ACTION_VALUES.find(function (value) {
+ return value === selectedValue;
+ }) || 'None';
+ }
+
+ // A suspension or block the violation already records is requested again only on purpose,
+ // whatever became of the last request, and never while one is waiting or a warning is sent.
+ function offersReissue(logItem, action) {
+ const requestStatus = String(logItem.action_request_status || '').toLowerCase();
+ return isRestrictionAction(action)
+ && logItem.action === action
+ && requestStatus !== 'pending'
+ && requestStatus !== 'sending';
+ }
+
+ function describeExistingRestriction(logItem, action) {
+ const noun = action === 'BlockUser' ? 'block' : 'suspension';
+ const requestStatus = String(logItem.action_request_status || '').toLowerCase();
+ if (requestStatus === 'pending') {
+ return `This ${noun} is waiting for another eligible reviewer to approve it. The violation can't be changed until the request is decided.`;
+ }
+ const states = {
+ executed: `This ${noun} was approved and applied.`,
+ denied: `This ${noun} request was denied.`,
+ expired: `This ${noun} request expired without a decision.`,
+ failed: `This ${noun} was approved but could not be applied.`,
+ };
+ const where = states[requestStatus] || `This violation already records a ${noun}.`;
+ return `${where} Saving updates the review only and requests nothing new. To ask another eligible reviewer to approve it again, select "Request this ${noun} again".`;
+ }
+
+ function isFutureDate(isoValue) {
+ const parsedDate = isoValue ? new Date(isoValue) : null;
+ return Boolean(parsedDate) && !Number.isNaN(parsedDate.getTime()) && parsedDate.getTime() > Date.now();
+ }
+
+ function syncLegacyEscalateOption(selectElement, logItem) {
+ if (!selectElement) {
+ return;
+ }
+
+ const existingOption = Array.from(selectElement.options).find(function (option) {
+ return option.value === LEGACY_ESCALATE_ACTION;
+ });
+ if (logItem.action === LEGACY_ESCALATE_ACTION) {
+ if (!existingOption) {
+ const legacyOption = document.createElement('option');
+ legacyOption.value = LEGACY_ESCALATE_ACTION;
+ legacyOption.textContent = ACTION_LABELS.Escalate;
+ selectElement.appendChild(legacyOption);
+ }
+ } else if (existingOption) {
+ existingOption.remove();
+ }
+ }
+
+ function updateWarningAcknowledgment(logItem) {
+ const acknowledgmentElement = document.getElementById('safetyWarningAcknowledgment');
+ if (!acknowledgmentElement) {
+ return;
+ }
+
+ const status = isExecutedWarning(logItem) ? logItem.warning_acknowledgment_status : null;
+ let text = '';
+ if (status === 'acknowledged') {
+ text = `Warning acknowledged ${formatDateTime(logItem.warning_acknowledged_at)}`;
+ } else if (status === 'pending') {
+ text = 'Warning sent. Not yet acknowledged by the user.';
+ } else if (status === 'not_tracked') {
+ text = 'Warning sent before acknowledgment was tracked.';
+ }
+ acknowledgmentElement.textContent = text;
+ setElementHidden(acknowledgmentElement, !text);
+ }
+
function updateRemediationFields(logItem, forcePopulate) {
- const action = document.getElementById('editAction')?.value || 'None';
+ const action = readSelectedAction();
const remediationFields = document.getElementById('safetyRemediationFields');
const remediationHelp = document.getElementById('safetyRemediationHelp');
+ const notificationGroup = document.getElementById('safetyNotificationGroup');
const notificationMessage = document.getElementById('editNotificationMessage');
const suspendGroup = document.getElementById('safetySuspendUntilGroup');
const suspendInput = document.getElementById('editSuspendUntil');
@@ -484,22 +581,55 @@
const shouldShow = SAFETY_REMEDIATION_ACTIONS.has(action);
setElementHidden(remediationFields, !shouldShow);
+ const reissueGroup = document.getElementById('safetyReissueGroup');
+ const reissueInput = document.getElementById('editReissue');
+ const reissueOffered = shouldShow && offersReissue(logItem, action);
+ if (reissueGroup && reissueInput) {
+ setElementHidden(reissueGroup, !reissueOffered);
+ if (forcePopulate || !reissueOffered) {
+ reissueInput.checked = false;
+ }
+ const noun = action === 'BlockUser' ? 'block' : 'suspension';
+ document.getElementById('editReissueLabel').textContent = `Request this ${noun} again`;
+ document.getElementById('editReissueHelp').textContent = action === 'SuspendUser'
+ ? 'Creates a new approval request with the notification and restore date below. It applies only after another eligible reviewer approves it.'
+ : 'Creates a new approval request with the notification below. It applies only after another eligible reviewer approves it.';
+ }
+ // Saving the same suspension or block again, without asking for it again, requests
+ // nothing, so the notification and restore date would not be used. Nor would they for
+ // one still waiting for approval, which can't be changed until it is decided.
+ const awaitingApproval = isRestrictionAction(action)
+ && logItem.action === action
+ && String(logItem.action_request_status || '').toLowerCase() === 'pending';
+ const requestsNothing = awaitingApproval || (reissueOffered && !(reissueInput && reissueInput.checked));
+
if (!shouldShow) {
remediationHelp.textContent = '';
notificationMessage.value = '';
notificationMessage.dataset.generatedMessage = '';
notificationMessage.dataset.action = action;
suspendInput.value = '';
+ suspendInput.dataset.action = action;
setElementHidden(suspendGroup, true);
return;
}
+ // A warning already sent is not sent again, so saving this record again only updates it.
+ const warningAlreadySent = action === 'WarnUser' && isExecutedWarning(logItem);
+ setElementHidden(notificationGroup, warningAlreadySent || requestsNothing);
+
const helpTextMap = {
- WarnUser: 'Warn user sends a notification to the affected user. If this reviewer also has the required Control Center approval role, the warning is approved and sent immediately.',
- SuspendUser: 'Suspend user uses the Control Center access restriction workflow. Reviewers without approval authority create a pending request instead of applying the suspension immediately.',
- BlockUser: 'Block user applies a permanent access restriction through the same Control Center access workflow, with no automatic restore date.',
+ WarnUser: 'Warn user sends the warning to the affected user as soon as you save, without a second reviewer. The user must acknowledge it the next time they use SimpleChat.',
+ SuspendUser: 'Suspend user restricts access until the restore date. Because it restricts access, saving creates an approval request, and the suspension applies only after another eligible reviewer approves it.',
+ BlockUser: 'Block user restricts access with no automatic restore date. Saving creates an approval request, and the block applies only after another eligible reviewer approves it.',
};
- remediationHelp.textContent = helpTextMap[action] || '';
+ if (warningAlreadySent) {
+ remediationHelp.textContent = 'This warning was already sent. Saving updates the review without sending the warning again.';
+ } else if (requestsNothing) {
+ remediationHelp.textContent = describeExistingRestriction(logItem, action);
+ } else {
+ remediationHelp.textContent = helpTextMap[action] || '';
+ }
const generatedMessage = buildDefaultNotificationMessage(logItem, action);
const savedMessage = logItem.action === action ? logItem.action_notification_message : '';
@@ -512,14 +642,16 @@
notificationMessage.dataset.generatedMessage = nextMessage;
notificationMessage.dataset.action = action;
- const showSuspendUntil = action === 'SuspendUser';
+ const showSuspendUntil = action === 'SuspendUser' && !requestsNothing;
setElementHidden(suspendGroup, !showSuspendUntil);
- if (showSuspendUntil) {
- const restoreDate = logItem.action === action ? logItem.action_datetime_to_allow : '';
- suspendInput.value = toLocalDateTimeInputValue(restoreDate);
- } else {
- suspendInput.value = '';
- }
+ if (forcePopulate || suspendInput.dataset.action !== action) {
+ // The last restore time is offered again only while it is still ahead.
+ const restoreDate = logItem.action === action && isFutureDate(logItem.action_datetime_to_allow)
+ ? logItem.action_datetime_to_allow
+ : '';
+ suspendInput.value = action === 'SuspendUser' ? toLocalDateTimeInputValue(restoreDate) : '';
+ }
+ suspendInput.dataset.action = action;
}
function createTextCell(text, className, title) {
@@ -594,7 +726,7 @@
setTextContent('safetyResolvedCount', data.resolved_count || 0);
setTextContent('safetyDismissedCount', data.dismissed_count || 0);
setTextContent('safetyRecentCount', data.recent_30_day_count || 0);
- setTextContent('safetyEscalatedCount', (data.escalate_count || 0) + (data.block_user_count || 0));
+ setTextContent('safetyBlockedCount', data.block_user_count || 0);
setTextContent('safetyStatsNewSummary', data.new_count || 0);
setTextContent('safetyStatsInReviewSummary', data.in_review_count || 0);
@@ -603,7 +735,12 @@
setTextContent('safetyStatsNoneActionSummary', data.none_action_count || 0);
setTextContent('safetyStatsWarnSummary', data.warn_user_count || 0);
setTextContent('safetyStatsSuspendSummary', data.suspend_user_count || 0);
- setTextContent('safetyStatsEscalateSummary', (data.escalate_count || 0) + (data.block_user_count || 0));
+ setTextContent('safetyStatsBlockSummary', data.block_user_count || 0);
+ // Escalations recorded before the action was removed are still counted, and only
+ // shown when there are any.
+ const legacyEscalations = data.escalate_count || 0;
+ setTextContent('safetyStatsLegacyEscalateSummary', legacyEscalations);
+ setElementHidden(document.getElementById('safetyStatsLegacyEscalateRow'), legacyEscalations <= 0);
}
async function renderSafetyRows(items) {
@@ -752,6 +889,7 @@
setTextContent('editMessage', item.message || '');
appendCategoryBadges(document.getElementById('editCategories'), item, 'No triggered categories');
document.getElementById('editStatus').value = item.status || 'New';
+ syncLegacyEscalateOption(document.getElementById('editAction'), item);
document.getElementById('editAction').value = item.action || 'None';
for (const option of document.getElementById('editAction').options) {
option.disabled = item.content_origin === 'assistant' && SAFETY_REMEDIATION_ACTIONS.has(option.value);
@@ -759,6 +897,7 @@
document.getElementById('editNotes').value = item.notes || '';
document.getElementById('editLogId').value = item.id || '';
setTextContent('safetyEditStatus', '');
+ updateWarningAcknowledgment(item);
const archiveButton = document.getElementById('archiveSafetyBtn');
if (archiveButton) {
archiveButton.dataset.archived = item.isArchived ? 'true' : 'false';
@@ -782,14 +921,18 @@
return;
}
- const action = document.getElementById('editAction')?.value || 'None';
+ const action = readSelectedAction();
const payload = {
status: document.getElementById('editStatus')?.value || 'New',
action: action,
notes: document.getElementById('editNotes')?.value || '',
};
- if (SAFETY_REMEDIATION_ACTIONS.has(action)) {
+ // A suspension or block is requested when it is newly chosen, or asked for again.
+ const activeItem = state.activeItem || {};
+ const reissue = offersReissue(activeItem, action) && Boolean(document.getElementById('editReissue')?.checked);
+ const requestsRestriction = isRestrictionAction(action) && (activeItem.action !== action || reissue);
+ if (action === 'WarnUser' || requestsRestriction) {
payload.notification_message = document.getElementById('editNotificationMessage')?.value || '';
if (action === 'SuspendUser') {
@@ -798,8 +941,14 @@
if (!payload.datetime_to_allow) {
throw new Error('Restore access date is required for a suspension.');
}
+ if (!isFutureDate(payload.datetime_to_allow)) {
+ throw new Error('Choose a restore date and time in the future.');
+ }
}
}
+ if (reissue) {
+ payload.reissue = true;
+ }
if (statusElement) {
statusElement.textContent = 'Saving review...';
@@ -986,12 +1135,21 @@
if (saveButton) {
saveButton.addEventListener('click', function () {
+ if (saveButton.disabled) {
+ return;
+ }
+ // A second click while the save is in flight would send a second request.
+ saveButton.disabled = true;
+ saveButton.setAttribute('aria-busy', 'true');
saveSafetyChanges().catch(function (error) {
const statusElement = document.getElementById('safetyEditStatus');
if (statusElement) {
statusElement.textContent = error.message;
statusElement.className = 'small text-danger me-auto';
}
+ }).finally(function () {
+ saveButton.disabled = false;
+ saveButton.removeAttribute('aria-busy');
});
});
}
@@ -1006,6 +1164,15 @@
});
}
+ const reissueInput = document.getElementById('editReissue');
+ if (reissueInput) {
+ reissueInput.addEventListener('change', function () {
+ if (state.activeItem) {
+ updateRemediationFields(state.activeItem, false);
+ }
+ });
+ }
+
if (archiveButton) {
archiveButton.addEventListener('click', function () {
const logId = document.getElementById('editLogId')?.value || '';
diff --git a/application/single_app/static/js/profile/profile-tabs.js b/application/single_app/static/js/profile/profile-tabs.js
index 8de4c556e..01fe9df03 100644
--- a/application/single_app/static/js/profile/profile-tabs.js
+++ b/application/single_app/static/js/profile/profile-tabs.js
@@ -1253,6 +1253,14 @@
}
}
+ // Escalate is no longer recorded; older records keep it and are labelled as legacy.
+ function formatViolationAction(action) {
+ if (action === 'Escalate') {
+ return 'Escalated (legacy)';
+ }
+ return action || 'None';
+ }
+
function renderViolationTableRows(items) {
const tbody = document.querySelector('#profile-violations-table tbody');
if (!tbody) {
@@ -1272,7 +1280,7 @@
row.appendChild(createTextCell(logItem.message || '', 'table-message-cell', logItem.message || ''));
row.appendChild(createSafetyCategoryCell(logItem));
row.appendChild(createTextCell(logItem.status || 'New'));
- row.appendChild(createTextCell(logItem.action || 'None'));
+ row.appendChild(createTextCell(formatViolationAction(logItem.action)));
row.appendChild(createTextCell(logItem.user_notes || '', 'table-note-cell', logItem.user_notes || ''));
const detailsCell = document.createElement('td');
@@ -1346,7 +1354,7 @@
setTextContent('profile-violation-detail-message', selectedItem.message || '');
appendSafetyCategoryBadges(document.getElementById('profile-violation-detail-categories'), selectedItem, '-');
setTextContent('profile-violation-detail-status', selectedItem.status || 'New');
- setTextContent('profile-violation-detail-action', selectedItem.action || 'None');
+ setTextContent('profile-violation-detail-action', formatViolationAction(selectedItem.action));
document.getElementById('profile-violation-detail-hidden-id').value = selectedItem.id || '';
document.getElementById('profile-violation-detail-user-notes').value = selectedItem.user_notes || '';
setTextContent('profile-violation-save-status', '');
diff --git a/application/single_app/templates/access_restricted.html b/application/single_app/templates/access_restricted.html
new file mode 100644
index 000000000..438563ea3
--- /dev/null
+++ b/application/single_app/templates/access_restricted.html
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+ Access restricted - {{ app_settings.app_title or 'SimpleChat' }}
+
+
+
+
+
+ {% if app_settings.classification_banner_enabled and app_settings.classification_banner_text %}
+
+ {{ app_settings.classification_banner_text }}
+
+ {% endif %}
+
+
+
+
+
+ {% if app_settings.show_logo %}
+ {% if app_settings.custom_logo_base64 %}
+
+ {% else %}
+
+ {% endif %}
+ {% endif %}
+ {% if not app_settings.hide_app_title %}
+
{{ app_settings.app_title or 'SimpleChat' }}
+ {% endif %}
+
+
+ {% if restriction %}
+
Access restricted
+
{{ restriction.title }}
+
{{ restriction.message }}
+
+
+
Access returns
+ {% if restriction.kind == 'suspended' and restriction.until %}
+
+
+
+ {% else %}
+
No automatic restore date. Contact your administrator about this decision.
+ {% endif %}
+ {% if restriction.reference_id %}
+
const viewModal = new bootstrap.Modal(document.getElementById('viewViolationModal')); // Bootstrap 5 modal instance
// --- DATA FETCHING & RENDERING ---
+ // Escalate is no longer recorded; older records keep it and are labelled as legacy.
+ function formatViolationAction(action) {
+ if (action === "Escalate") {
+ return "Escalated (legacy)";
+ }
+ return action || "None";
+ }
+
function fetchMySafetyLogs() {
// Show loading state
tableBody.html(`
@@ -296,7 +303,7 @@
+
+ );
+}
diff --git a/application/v2_ui/src/components/review/FeedbackRetest.tsx b/application/v2_ui/src/components/review/FeedbackRetest.tsx
new file mode 100644
index 000000000..a29f0562d
--- /dev/null
+++ b/application/v2_ui/src/components/review/FeedbackRetest.tsx
@@ -0,0 +1,70 @@
+// FeedbackRetest.tsx
+// Sends a feedback record's prompt to the current model configuration and shows the new
+// answer beside the response the user rated, so a reviewer can tell whether a change since
+// then already addresses the feedback.
+//
+// The retest runs only when asked: it calls the model, which takes time and costs tokens.
+// Nothing is saved; the record keeps the response the user saw.
+
+import { useState } from 'react';
+import { FlaskConical, Loader2 } from 'lucide-react';
+import { GlassButton } from '../ui/primitives';
+import { errorText, retestFeedbackPrompt } from '../../lib/reviewCenterApi';
+import type { FeedbackRecord } from '../../lib/reviewCenter';
+import { ReviewNotice, ReviewTextBlock } from './ReviewParts';
+
+export function FeedbackRetest({ record, testIdPrefix }: { record: FeedbackRecord; testIdPrefix: string }) {
+ const [running, setRunning] = useState(false);
+ const [answer, setAnswer] = useState(null);
+ const [error, setError] = useState('');
+
+ const run = async () => {
+ setRunning(true);
+ setError('');
+ try {
+ const result = await retestFeedbackPrompt(record.id, record.prompt ?? '');
+ setAnswer(result.retestResponse || 'The model returned no response.');
+ } catch (cause) {
+ setError(errorText(cause, 'The prompt could not be retested.'));
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ return (
+
+ Sends the original prompt to the current model configuration. Nothing is saved.
+
+
+ {error ? {error} : null}
+
+
+
+ {answer !== null ? (
+
+ ) : (
+
+
Retest response
+
+ {running ? 'Waiting for the model…' : 'Retest the prompt to compare a current answer here.'}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/application/v2_ui/src/components/review/ReviewBulkBar.tsx b/application/v2_ui/src/components/review/ReviewBulkBar.tsx
new file mode 100644
index 000000000..8a24ae5f7
--- /dev/null
+++ b/application/v2_ui/src/components/review/ReviewBulkBar.tsx
@@ -0,0 +1,130 @@
+// ReviewBulkBar.tsx
+// The bar a Review center workbench shows once records are checked: how many, the actions
+// that apply to all of them, progress while they run, and what happened to each.
+//
+// Every bulk action reports per record. A batch that partly fails says which records were
+// left unchanged and why, in the server's words, so the reviewer can act on each one
+// rather than guess. The section supplies its own action buttons; this bar only frames
+// them, which is where a later "Triage with AI" command joins the same bar.
+
+import type { ReactNode } from 'react';
+import { CheckCheck, Loader2, X } from 'lucide-react';
+import { GlassButton } from '../ui/primitives';
+import { ReviewNotice } from './ReviewParts';
+
+export interface BulkRunProgress {
+ /** What is running, such as "Archiving". */
+ label: string;
+ done: number;
+ total: number;
+}
+
+export interface BulkRunReport {
+ /** The sentence that leads the report, such as "Archived 18 of 20 violations." */
+ summary: string;
+ failures: { id: string; label: string; message: string }[];
+ /** How the report reads; by default a warning when any record failed. */
+ tone?: 'ok' | 'warn';
+}
+
+export function ReviewBulkBar({
+ count,
+ summary,
+ offerMatching,
+ matchingBusy = false,
+ onSelectMatching,
+ onClear,
+ actions,
+ progress,
+ report,
+ onDismissReport,
+ testIdPrefix,
+}: {
+ /** How many records are checked. */
+ count: number;
+ /** The sentence describing what is checked. */
+ summary: string;
+ /** "Select all N matching", offered when the whole page is checked and more records match. */
+ offerMatching?: { total: number; noun: string } | null;
+ matchingBusy?: boolean;
+ onSelectMatching?: () => void;
+ onClear: () => void;
+ actions: ReactNode;
+ progress: BulkRunProgress | null;
+ report: BulkRunReport | null;
+ onDismissReport: () => void;
+ testIdPrefix: string;
+}) {
+ if (!count && !progress && !report) return null;
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx b/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx
new file mode 100644
index 000000000..233ba9547
--- /dev/null
+++ b/application/v2_ui/src/components/review/ReviewEditorPlaceholder.tsx
@@ -0,0 +1,22 @@
+// ReviewEditorPlaceholder.tsx
+// What a Review center editor page shows while its record loads, or in place of a record
+// that could not be loaded: the state, and the way back to the workbench.
+
+import type { ReactNode } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { ArrowLeft } from 'lucide-react';
+import { GlassButton } from '../ui/primitives';
+
+export function ReviewEditorPlaceholder({ backTo, children }: { backTo: string; children: ReactNode }) {
+ const navigate = useNavigate();
+ return (
+
+
+ navigate(backTo)}>
+ Back
+
+
+
{children}
+
+ );
+}
diff --git a/application/v2_ui/src/components/review/ReviewList.tsx b/application/v2_ui/src/components/review/ReviewList.tsx
new file mode 100644
index 000000000..aa9ae8dff
--- /dev/null
+++ b/application/v2_ui/src/components/review/ReviewList.tsx
@@ -0,0 +1,164 @@
+// ReviewList.tsx
+// The list column of a Review center workbench: one-line rows, each with a checkbox for bulk
+// actions and a button that opens the row in the detail pane.
+//
+// Drawn the way the Workflows workbench draws its list -- a sticky header, one line per row
+// with its status at the end -- with a checkbox in front of each row. Up and Down move between
+// rows, and Home and End go to the first and last, keeping to the control in focus: from a
+// checkbox to the next row's checkbox, from a row to the next row. Only the current row's
+// controls are in the tab order, so the list is one stop on the way through the page.
+// Shift-clicking a checkbox selects the range from the last one checked.
+
+import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
+import { clsx } from 'clsx';
+import { Skeleton } from '../ui/primitives';
+import { useIndeterminate } from './ReviewParts';
+
+export interface ReviewListRow {
+ id: string;
+ title: string;
+ meta: string;
+ /** The row's state, drawn at its end. Decorative: the meta and the detail say it in words. */
+ status: ReactNode;
+}
+
+export function ReviewList({
+ label,
+ columnLabel,
+ statusLabel,
+ rows,
+ loading,
+ empty,
+ selectedId,
+ onSelect,
+ checkedIds,
+ onToggleCheck,
+ onToggleAll,
+ footer,
+ testIdPrefix,
+}: {
+ /** The list's accessible name, such as "Feedback". */
+ label: string;
+ columnLabel: string;
+ statusLabel: string;
+ rows: readonly ReviewListRow[];
+ loading: boolean;
+ /** Shown in place of rows when the page has none. */
+ empty: ReactNode;
+ selectedId: string | null;
+ onSelect: (id: string) => void;
+ checkedIds: readonly string[];
+ onToggleCheck: (id: string, range: boolean) => void;
+ onToggleAll: () => void;
+ footer?: ReactNode;
+ testIdPrefix: string;
+}) {
+ const [focusedId, setFocusedId] = useState(null);
+ const checked = new Set(checkedIds);
+ const pageChecked = rows.length > 0 && rows.every((row) => checked.has(row.id));
+ const somePageChecked = rows.some((row) => checked.has(row.id));
+ const selectAllRef = useIndeterminate(pageChecked, somePageChecked);
+ const tabStopId =
+ (focusedId && rows.some((row) => row.id === focusedId) && focusedId)
+ || (selectedId && rows.some((row) => row.id === selectedId) && selectedId)
+ || rows[0]?.id;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
+ const target = event.target as HTMLElement;
+ const kind = target.dataset.reviewControl;
+ if (kind !== 'row' && kind !== 'check') return;
+ const controls = Array.from(
+ event.currentTarget.querySelectorAll(`[data-review-control="${kind}"]`),
+ );
+ if (!controls.length) return;
+ event.preventDefault();
+ const index = controls.indexOf(target);
+ const next = event.key === 'Home' ? 0
+ : event.key === 'End' ? controls.length - 1
+ : event.key === 'ArrowDown' ? Math.min(controls.length - 1, index + 1)
+ : Math.max(0, index - 1);
+ controls[next]?.focus();
+ };
+
+ return (
+
+ );
+}
+
+/** The narrower filters a dashboard link applied, each removable on its own. */
+export function FilterChips({ chips }: { chips: readonly FilterChip[] }) {
+ if (!chips.length) return null;
+ return (
+
+ {chips.map((chip) => (
+
+
+
+ ))}
+
+ );
+}
+
+export function ReviewPager({
+ page,
+ pageSize,
+ total,
+ loading,
+ noun,
+ onPage,
+ onPageSize,
+}: {
+ page: number;
+ pageSize: number;
+ total: number;
+ loading: boolean;
+ noun: string;
+ onPage: (page: number) => void;
+ onPageSize: (size: number) => void;
+}) {
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
+ const sizeId = useId();
+ return (
+
+ );
+}
+
+/** A labelled read-only fact in a detail pane or editor. Nothing is drawn for an empty value. */
+export function ReviewFact({ label, children }: { label: string; children: ReactNode }) {
+ if (children === null || children === undefined || children === '' || children === false) return null;
+ return (
+
+
{label}
+
{children}
+
+ );
+}
+
+/** Long text, such as a prompt or an AI response, in a bounded scroll box. */
+export function ReviewTextBlock({ label, text, empty, testId }: { label: string; text?: string | null; empty: string; testId?: string }) {
+ return (
+
+ >
+ );
+}
+
+/** Keeps a checkbox's indeterminate state in step, which has no attribute of its own. */
+export function useIndeterminate(checked: boolean, partial: boolean) {
+ const ref = useRef(null);
+ useEffect(() => {
+ if (ref.current) ref.current.indeterminate = partial && !checked;
+ }, [checked, partial]);
+ return ref;
+}
diff --git a/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx b/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx
new file mode 100644
index 000000000..432617f3d
--- /dev/null
+++ b/application/v2_ui/src/components/review/ReviewWorkbenchLayout.tsx
@@ -0,0 +1,78 @@
+// ReviewWorkbenchLayout.tsx
+// The frame of a Review center workbench, drawn as the Workflows workbench is: the search,
+// filters and bulk bar above, then a list of one-line rows beside the selected record's
+// detail once there is room, stacked on narrow screens.
+
+import type { ReactNode } from 'react';
+import { useLocation } from 'react-router-dom';
+import { CircleCheck, TriangleAlert } from 'lucide-react';
+import { isRecord } from '../../lib/workspaceAuthoring';
+
+/** What a record's editor leaves for the workbench after a save, in the location state. */
+export interface ReviewSavedNotice {
+ message: string;
+ warning?: boolean;
+}
+
+/** The save an editor just returned from, if the workbench was opened that way. */
+export function useReviewSavedNotice(): ReviewSavedNotice | null {
+ const location = useLocation();
+ const state = isRecord(location.state) ? location.state : null;
+ const notice = state && isRecord(state.reviewNotice) ? state.reviewNotice : null;
+ if (!notice || typeof notice.message !== 'string' || !notice.message) return null;
+ return { message: notice.message, warning: notice.warning === true };
+}
+
+export function ReviewSavedStatus({ notice, testId }: { notice: ReviewSavedNotice | null; testId: string }) {
+ if (!notice) return null;
+ const Icon = notice.warning ? TriangleAlert : CircleCheck;
+ return (
+
+
+ {notice.message}
+
+ );
+}
+
+export function ReviewWorkbenchLayout({
+ testId,
+ label,
+ header,
+ list,
+ pager,
+ detail,
+}: {
+ testId: string;
+ /** The workbench's name, for the group around it. */
+ label: string;
+ /** Search, filters, notices and the bulk bar. */
+ header: ReactNode;
+ list: ReactNode;
+ pager: ReactNode;
+ detail: ReactNode;
+}) {
+ return (
+
+
{header}
+
+
+
+
{list}
+ {pager}
+
+
{detail}
+
+
+
+ );
+}
+
+/** The detail pane's placeholder while nothing is selected. */
+export function ReviewDetailEmpty({ title, description }: { title: string; description: string }) {
+ return (
+
+
{title}
+
{description}
+
+ );
+}
diff --git a/application/v2_ui/src/components/review/useReviewWorkbench.ts b/application/v2_ui/src/components/review/useReviewWorkbench.ts
new file mode 100644
index 000000000..96e7a8e46
--- /dev/null
+++ b/application/v2_ui/src/components/review/useReviewWorkbench.ts
@@ -0,0 +1,189 @@
+// useReviewWorkbench.ts
+// The state a Review center workbench keeps: the page of records its filters address, the
+// records checked for a bulk action, and the bulk run in progress with its report.
+//
+// The filters, the page and the open record live in the address, so a workbench can be
+// linked to, reloaded and returned to from a record's editor exactly as it was left. The
+// checked records do not: they are a working set, pruned to the rows still shown after
+// every reload.
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { errorText, type BulkOperation, type MatchingIds, type ReviewPage } from '../../lib/reviewCenterApi';
+import {
+ buildBulkReport,
+ readReviewPaging,
+ type ReviewBulkResult,
+} from '../../lib/reviewCenter';
+import {
+ checkedIds,
+ EMPTY_REVIEW_SELECTION,
+ keepFailures,
+ matchingSelection,
+ pruneReviewSelection,
+ selectionSummary,
+ selectMatching,
+ toggleChecked,
+ togglePageChecked,
+ type ReviewSelection,
+} from '../../lib/reviewSelection';
+import type { BulkRunProgress, BulkRunReport } from './ReviewBulkBar';
+
+export interface ReviewNoun {
+ singular: string;
+ plural: string;
+}
+
+export function useReviewWorkbench({
+ filterKey,
+ reloadKey,
+ loadPage,
+ loadMatchingIds,
+ runBulk,
+ noun,
+ describe,
+}: {
+ /** The filters as their query string: a change reloads the list and drops "every matching". */
+ filterKey: string;
+ /** Bumped by the page's Refresh. */
+ reloadKey: number;
+ loadPage: (page: number, pageSize: number, signal: AbortSignal) => Promise>;
+ loadMatchingIds: () => Promise;
+ runBulk: (
+ operations: readonly BulkOperation[],
+ onProgress: (done: number, total: number) => void,
+ ) => Promise;
+ noun: ReviewNoun;
+ /** How a record is named in a bulk report. */
+ describe: (id: string, items: readonly T[]) => string;
+}) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const paging = readReviewPaging(searchParams);
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [reload, setReload] = useState(0);
+ const [selection, setSelection] = useState(EMPTY_REVIEW_SELECTION);
+ const [matchingBusy, setMatchingBusy] = useState(false);
+ const [progress, setProgress] = useState(null);
+ const [report, setReport] = useState(null);
+ const loadRef = useRef(loadPage);
+ loadRef.current = loadPage;
+
+ useEffect(() => {
+ const controller = new AbortController();
+ setLoading(true);
+ setError('');
+ loadRef.current(paging.page, paging.pageSize, controller.signal)
+ .then((result) => {
+ if (controller.signal.aborted) return;
+ setItems(result.items);
+ setTotal(result.total);
+ setSelection((current) => pruneReviewSelection(
+ current,
+ result.items.map((item) => item.id),
+ filterKey,
+ ));
+ })
+ .catch((cause) => {
+ if (!controller.signal.aborted) setError(errorText(cause, `The ${noun.plural} could not be loaded.`));
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false);
+ });
+ return () => controller.abort();
+ // The noun only words the error.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filterKey, paging.page, paging.pageSize, reloadKey, reload]);
+
+ const orderedIds = useMemo(() => items.map((item) => item.id), [items]);
+ const checked = checkedIds(selection, filterKey);
+ const matching = matchingSelection(selection, filterKey);
+ const pageFullyChecked = orderedIds.length > 0 && orderedIds.every((id) => checked.includes(id));
+
+ /** Change address parameters, leaving the rest as they are. */
+ const updateParams = useCallback((changes: Record, options?: { keepPage?: boolean }) => {
+ setSearchParams((current) => {
+ const next = new URLSearchParams(current);
+ for (const [key, value] of Object.entries(changes)) {
+ if (value === null || value === '') next.delete(key);
+ else next.set(key, value);
+ }
+ if (!options?.keepPage) next.delete('page');
+ return next;
+ }, { replace: true });
+ }, [setSearchParams]);
+
+ const toggle = (id: string, range: boolean) =>
+ setSelection((current) => toggleChecked(current, id, range, orderedIds, filterKey));
+ const togglePage = () => setSelection((current) => togglePageChecked(current, orderedIds, filterKey));
+ const clearSelection = () => setSelection(EMPTY_REVIEW_SELECTION);
+
+ const chooseMatching = async () => {
+ setMatchingBusy(true);
+ setReport(null);
+ try {
+ const result = await loadMatchingIds();
+ setSelection(selectMatching(result, filterKey));
+ } catch (cause) {
+ setReport({ summary: errorText(cause, `Every matching ${noun.singular} could not be selected.`), failures: [] });
+ } finally {
+ setMatchingBusy(false);
+ }
+ };
+
+ /**
+ * Run one bulk operation, in batches, then report on each record and reload. Without
+ * `ids` it runs over the checked records, and only those it could not change stay
+ * checked; with `ids`, such as one record's own Archive, the checked records are left
+ * as they are.
+ */
+ const runBulkOperation = async (
+ label: string,
+ verb: string,
+ build: (id: string) => BulkOperation,
+ ids?: readonly string[],
+ ): Promise => {
+ const targets = ids ? [...ids] : [...checked];
+ if (!targets.length) return [];
+ setReport(null);
+ setProgress({ label, done: 0, total: targets.length });
+ const snapshot = items;
+ try {
+ const results = await runBulk(targets.map(build), (done, all) => setProgress({ label, done, total: all }));
+ const built = buildBulkReport(results, verb, noun, (id) => describe(id, snapshot));
+ setReport(built);
+ if (!ids) setSelection(keepFailures(built.failures.map((failure) => failure.id)));
+ return results;
+ } finally {
+ setProgress(null);
+ setReload((value) => value + 1);
+ }
+ };
+
+ return {
+ searchParams,
+ paging,
+ updateParams,
+ items,
+ total,
+ loading,
+ error,
+ reload: () => setReload((value) => value + 1),
+ orderedIds,
+ checked,
+ matching,
+ pageFullyChecked,
+ toggle,
+ togglePage,
+ clearSelection,
+ chooseMatching,
+ matchingBusy,
+ runBulkOperation,
+ progress,
+ report,
+ setReport,
+ summary: selectionSummary(selection, filterKey, noun),
+ };
+}
diff --git a/application/v2_ui/src/components/settings/ViolationsTab.tsx b/application/v2_ui/src/components/settings/ViolationsTab.tsx
index ae9a1c7ee..0e0893a68 100644
--- a/application/v2_ui/src/components/settings/ViolationsTab.tsx
+++ b/application/v2_ui/src/components/settings/ViolationsTab.tsx
@@ -24,7 +24,12 @@ const PAGE_SIZE = 10;
const STATUSES = ['New', 'In-Review', 'Resolved', 'Dismissed'];
/** Actions an administrator can record against a violation. */
-const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'Escalate', 'BlockUser'];
+const ACTIONS = ['None', 'WarnUser', 'SuspendUser', 'BlockUser'];
+
+/** Escalate is no longer recorded; older records keep it and are labelled as legacy. */
+function actionLabel(action: string): string {
+ return action === 'Escalate' ? 'Escalated (legacy)' : action;
+}
interface TriggeredCategory {
category?: string;
@@ -41,6 +46,32 @@ interface SafetyLog {
admin_notes?: string;
created_at?: string;
last_updated?: string;
+ /** Set on a warning the administrators sent: `pending` until it is acknowledged. */
+ warning_acknowledgment_status?: 'pending' | 'acknowledged' | 'not_tracked' | null;
+ warning_acknowledged_at?: string | null;
+}
+
+/** Whether the user has acknowledged a warning they were sent, as one line. */
+function WarningAcknowledgment({ log }: { log: SafetyLog }) {
+ if (log.warning_acknowledgment_status === 'acknowledged') {
+ const acknowledged = log.warning_acknowledged_at ? new Date(log.warning_acknowledged_at) : null;
+ return (
+
- These messages were allowed through when a required check could not finish. Rechecking uses current rules; confirmed findings can remove AI replies from saved and shared chat, but cannot undo earlier views or external actions.
-
- {draft.action === 'WarnUser'
- ? 'The warning is sent immediately only when this reviewer has the required approval role; otherwise it becomes a pending request.'
- : draft.action === 'SuspendUser'
- ? 'Reviewers without approval authority create a pending request instead of applying a suspension.'
- : 'This access restriction uses the same approval workflow as Control Center.'}
-
- {selected.content_origin === 'assistant' &&
AI-generated findings cannot be used to warn or restrict a user.
+ }
+ title={load.missing ? 'Feedback not found' : 'The feedback could not load'}
+ description={load.missing ? 'This feedback was deleted or is no longer available.' : load.message}
+ action={load.missing ? undefined : (
+ setAttempt((value) => value + 1)}>Retry
+ )}
+ />
+
+ ),
+ },
+ {
+ id: 'review',
+ label: 'Your review',
+ description: 'What you found, what was done about it, and what to tell the user.',
+ icon: ClipboardCheck,
+ content: (
+
+ update({ acknowledged: value })}
+ label="Acknowledged"
+ description="Marks the feedback as reviewed, so it leaves the queue of feedback awaiting review."
+ />
+
+
+
+
+
+
+ update({ notifyUser: value })}
+ label="Notify the user"
+ description={current.responseToUser.trim()
+ ? 'When you save, the user gets a notification with your response that opens their feedback.'
+ : 'When you save, the user gets a notification that their feedback was reviewed, which opens their feedback.'}
+ />
+
+ {feedbackFiltersApplied(filters) || filters.search
+ ? 'Try another search, or clear the filters.'
+ : 'Feedback users give on AI responses appears here.'}
+
+
+ )}
+ selectedId={selected?.id ?? null}
+ onSelect={(id) => updateParams({ selected: id }, { keepPage: true })}
+ checkedIds={checked}
+ onToggleCheck={workbench.toggle}
+ onToggleAll={workbench.togglePage}
+ testIdPrefix="v2-feedback"
+ />
+ )}
+ pager={(
+ updateParams({ page: String(page), selected: null }, { keepPage: true })}
+ onPageSize={(size) => updateParams({ size: String(size), selected: null })}
+ />
+ )}
+ detail={selected ? (
+ openEditor(selected.id)}
+ onArchive={() => void workbench.runBulkOperation(
+ selected.isArchived ? 'Restoring' : 'Archiving',
+ selected.isArchived ? 'Restored' : 'Archived',
+ (id) => ({ id, op: 'archive', archived: !selected.isArchived }),
+ [selected.id],
+ )}
+ onDelete={() => setPendingDelete({ ids: [selected.id], fromSelection: false })}
+ />
+ ) : (
+
+ )}
+ />
+ {pendingDelete ? (
+ {
+ if (!busy) setPendingDelete(null);
+ }}
+ onConfirm={() => {
+ const target = pendingDelete;
+ setPendingDelete(null);
+ void workbench.runBulkOperation(
+ 'Deleting',
+ 'Deleted',
+ (id) => ({ id, op: 'delete' }),
+ target.fromSelection ? undefined : target.ids,
+ );
+ }}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/application/v2_ui/src/pages/review/ReviewCenterPage.tsx b/application/v2_ui/src/pages/review/ReviewCenterPage.tsx
new file mode 100644
index 000000000..1cda8fd4c
--- /dev/null
+++ b/application/v2_ui/src/pages/review/ReviewCenterPage.tsx
@@ -0,0 +1,159 @@
+// ReviewCenterPage.tsx
+// The admin Review center: feedback and safety review in one place, laid out like the
+// Approvals page -- a rail of each section's pages, and the chosen page beside it.
+//
+// /admin/review opens the first section the signed-in user may review. Each section has a
+// dashboard whose figures open its workbench already filtered, a workbench for reviewing
+// records one at a time or many at once, and full-page editors at the workbench's address
+// followed by a record id. Access mirrors the server's decorators (lib/reviewAccess.ts); the
+// server still answers every request on its own terms.
+
+import { useCallback, useMemo, useState, type ReactNode } from 'react';
+import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom';
+import { RefreshCw, ShieldOff } from 'lucide-react';
+import { CategoryRailPage, type RailSection } from '../../components/layout/CategoryRail';
+import { PageHeader } from '../../components/layout/PageHeader';
+import { EmptyState, GlassButton } from '../../components/ui/primitives';
+import { reviewAccessInput, reviewSections, type ReviewSectionId } from '../../lib/reviewAccess';
+import { safeReviewViewHref } from '../../lib/reviewCenter';
+import { useBootstrapStore } from '../../stores/bootstrapStore';
+import { useUserSettingsStore } from '../../stores/userSettingsStore';
+import {
+ REVIEW_SECTION_LABELS,
+ reviewEntriesFor,
+ reviewEntryPath,
+ type ReviewEntry,
+} from './reviewCenterSections';
+
+const SECTION_IDS: readonly ReviewSectionId[] = ['feedback', 'safety'];
+
+function NotAvailable({ title, description, action }: { title: string; description: string; action?: ReactNode }) {
+ return (
+
+
+ {legacyEscalations.toLocaleString()} active {legacyEscalations === 1 ? 'violation is' : 'violations are'} {LEGACY_ESCALATE_LABEL.toLowerCase()}
+
+ . Escalate can no longer be chosen; those records keep it until a reviewer changes the action.
+
+ }
+ title={load.missing ? 'Violation not found' : 'The violation could not load'}
+ description={load.missing ? 'This violation was deleted or is no longer available.' : load.message}
+ action={load.missing ? undefined : (
+ setAttempt((value) => value + 1)}>Retry
+ )}
+ />
+
+
+ );
+ }
+
+ const record = load.record;
+ const current = draft as SafetyDraft;
+ const initial = draftFrom(record);
+ const dirty = !sameDraft(current, initial);
+ const locked = isSafetyRecordLocked(record);
+ const previousAction = record.action || 'None';
+ const fromAssistant = record.content_origin === 'assistant';
+ const sends = sendsNotice(record, current);
+ const restricting = APPROVAL_REQUIRED_ACTIONS.has(current.action);
+ const offerReissue = offersRestrictionReissue(record, current.action);
+ const actionWord = current.action === 'BlockUser' ? 'block' : 'suspension';
+ const remediation = remediationStatusText(record);
+ const acknowledgment = warningAcknowledgmentText(record);
+ const badge = safetyActionBadge(record);
+
+ const update = (changes: Partial) => setDraft((value) => (value ? withDefaults(record, { ...value, ...changes }) : value));
+ const choosePreset = (preset: SuspendPreset) => {
+ if (preset === 'custom') {
+ update({ preset, until: fromLocalDateTimeInput(current.customUntil) ?? '' });
+ return;
+ }
+ update({ preset, until: suspendPresetUntil(preset)?.toISOString() ?? '' });
+ };
+
+ const save = async () => {
+ if (sends && current.action === 'SuspendUser') {
+ if (!current.until) {
+ setStale(false);
+ setSaveError('Choose how long the suspension lasts.');
+ return;
+ }
+ if (new Date(current.until).getTime() <= Date.now()) {
+ setStale(false);
+ setSaveError('Choose a time in the future for access to return.');
+ return;
+ }
+ }
+ const payload: SafetyReviewChanges = {
+ status: current.status,
+ action: current.action,
+ notes: current.notes,
+ etag: record.etag,
+ };
+ if (sends) {
+ payload.notification_title = current.title.trim();
+ payload.notification_message = current.message.trim();
+ if (current.action === 'SuspendUser') payload.datetime_to_allow = current.until;
+ }
+ if (offerReissue && current.reissue) payload.reissue = true;
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const result = await saveSafetyReview(record.id, payload);
+ const message = [result.message || 'Review saved.', result.audit_warning].filter(Boolean).join(' ');
+ navigate(backTo, {
+ replace: true,
+ state: {
+ workspaceEditorSaved: true,
+ workspaceEditorFrom: location.key,
+ reviewNotice: { message, warning: Boolean(result.audit_warning) },
+ },
+ });
+ } catch (cause) {
+ setStale(needsReload(cause));
+ setSaveError(errorText(cause, 'The review could not be saved.'));
+ setSaving(false);
+ }
+ };
+
+ const approvalLink = record.action_request_id ? (
+
+ Open the approval request
+
+ ) : null;
+
+ let actionCopy: string | null = null;
+ if (current.action === 'WarnUser') {
+ actionCopy = isExecutedWarning(record) && previousAction === 'WarnUser'
+ ? 'This warning was already sent. Saving updates the review without sending the warning again.'
+ : 'The warning is sent to the user as soon as you save, without a second reviewer. The user must acknowledge it the next time they use SimpleChat.';
+ } else if (restricting && sends) {
+ actionCopy = current.action === 'SuspendUser'
+ ? 'A suspension restricts access, so saving creates an approval request. It applies only after another eligible reviewer approves it.'
+ : 'A block restricts access, so saving creates an approval request. It applies only after another eligible reviewer approves it.';
+ } else if (restricting) {
+ actionCopy = existingRestrictionText(record, current.action);
+ }
+
+ return (
+
+
{remediation} This violation cannot be changed until {safetyRequestState(record) === 'sending' ? 'the warning is sent' : 'the request is decided'}.
+ {locked ? (
+
+ {remediation} {sending
+ ? 'Until it finishes this violation cannot be changed, archived or deleted.'
+ : 'Until it is decided this violation cannot be changed or deleted.'}
+
+ ) : null}
+