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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions application/single_app/admin_app_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@
"role": "ControlCenterDashboardReader",
"label": "Control Center dashboard, read only",
"section_id": "control-center-overview-section",
# Independent of the ControlCenterAdmin requirement: control_center_required
# and the navigation both admit a reader whether or not that is enforced.
"grants": (
"The Control Center dashboard without the management features. Only "
"takes effect while the ControlCenterAdmin requirement is enforced."
"The Control Center dashboard without the management features, whether "
"or not the ControlCenterAdmin requirement is enforced."
),
"when_off": "There is no dashboard-only tier; access is all or nothing.",
"depends_on": "require_member_of_control_center_admin",
"depends_on": None,
},
{
"key": "require_member_of_create_group",
Expand Down
762 changes: 662 additions & 100 deletions application/single_app/admin_settings_fields.py

Large diffs are not rendered by default.

63 changes: 24 additions & 39 deletions application/single_app/background_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
from config import cosmos_m365_execution_runs_container, cosmos_settings_container, exceptions
from content_screening.jobs import check_due_scan_jobs_once
from functions_appinsights import log_event
from functions_control_center import (
from functions_control_center import execute_control_center_refresh
from functions_control_center_schedule import (
calculate_next_control_center_auto_refresh_run,
execute_control_center_refresh,
get_control_center_auto_refresh_schedule,
is_control_center_auto_refresh_due,
parse_control_center_auto_refresh_datetime,
Expand All @@ -38,6 +38,7 @@
from functions_data_management import check_due_data_management_jobs_once
from functions_documents import process_due_document_search_metadata_syncs
from functions_file_sync import check_due_file_sync_sources_once
from functions_logging_timers import LOGGING_TIMERS, is_logging_turnoff_due
from functions_keyvault_reminders import (
KEY_VAULT_SECRET_REMINDER_LOCK_NAME,
check_due_key_vault_secret_reminders_once,
Expand Down Expand Up @@ -220,46 +221,30 @@ def _should_run_retention_policy(settings, current_time):


def check_logging_timers_once():
"""Disable temporary logging settings after their timer expires."""
"""Disable temporary logging settings after their timer expires.

Turnoff times are compared in UTC. Ones stored before timers were kept in UTC
carry no offset and are read as server-local time, which is how they were
written, so a timer set before an upgrade still ends when it was meant to.
"""
settings = get_settings()
current_time = datetime.now()
settings_updates = {}

if (
settings.get('enable_debug_logging', False)
and settings.get('debug_logging_timer_enabled', False)
and settings.get('debug_logging_turnoff_time')
):
turnoff_time = settings.get('debug_logging_turnoff_time')
if isinstance(turnoff_time, str):
try:
turnoff_time = datetime.fromisoformat(turnoff_time)
except Exception:
turnoff_time = None

if turnoff_time and current_time >= turnoff_time:
debug_print(f"logging timer expired at {turnoff_time}. Disabling debug logging.")
settings_updates['enable_debug_logging'] = False
settings_updates['debug_logging_timer_enabled'] = False
settings_updates['debug_logging_turnoff_time'] = None

if (
settings.get('enable_file_processing_logs', False)
and settings.get('file_processing_logs_timer_enabled', False)
and settings.get('file_processing_logs_turnoff_time')
):
turnoff_time = settings.get('file_processing_logs_turnoff_time')
if isinstance(turnoff_time, str):
try:
turnoff_time = datetime.fromisoformat(turnoff_time)
except Exception:
turnoff_time = None

if turnoff_time and current_time >= turnoff_time:
print(f"File processing logs timer expired at {turnoff_time}. Disabling file processing logs.")
settings_updates['enable_file_processing_logs'] = False
settings_updates['file_processing_logs_timer_enabled'] = False
settings_updates['file_processing_logs_turnoff_time'] = None
for timer_keys in LOGGING_TIMERS.values():
enabled_key = timer_keys['enabled_key']
timer_key = timer_keys['timer_key']
turnoff_key = timer_keys['turnoff_key']
if not (settings.get(enabled_key, False) and settings.get(timer_key, False)):
continue

turnoff_time = settings.get(turnoff_key)
if not is_logging_turnoff_due(turnoff_time):
continue

debug_print(f"[LOGGING_TIMERS] Timer for {enabled_key} expired at {turnoff_time}. Turning it off.")
settings_updates[enabled_key] = False
settings_updates[timer_key] = False
settings_updates[turnoff_key] = None

if settings_updates:
return update_settings(settings_updates, expected_etag=settings.get('_etag'))
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.268"
VERSION = "0.261.269"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
18 changes: 18 additions & 0 deletions application/single_app/functions_appinsights.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,24 @@ def get_appinsights_logger():

return None


def get_appinsights_runtime_state() -> Dict[str, bool]:
"""Report how Application Insights was wired when this process started.

The settings document records what an administrator asked for; this reports
what the running process is doing. The two differ until the App Service
restarts, because ``setup_appinsights_logging`` configures the exporter and the
global logger once, at startup. Only booleans are returned, so the connection
string itself never leaves the server.
"""
return {
'connection_configured': bool(os.environ.get('APPLICATIONINSIGHTS_CONNECTION_STRING')),
'exporter_configured': bool(_azure_monitor_configured),
'global_logging_active': (
_appinsights_logger is not None and _appinsights_logger is logging.getLogger()
),
}

# --- Logging function for Application Insights ---
def log_event(
message: Any,
Expand Down
139 changes: 4 additions & 135 deletions application/single_app/functions_control_center.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,146 +4,15 @@
Version: 0.250.102
"""

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from datetime import datetime, timezone

from config import cosmos_user_settings_container, cosmos_groups_container
from functions_debug import debug_print
from functions_settings import get_settings, update_settings
from functions_appinsights import log_event


CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR = 2
CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE = 0
CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIME = '02:00'
CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE = 'America/New_York'


def normalize_control_center_auto_refresh_time(
schedule_time=None,
schedule_hour=None,
schedule_minute=None,
schedule_timezone=None,
):
"""Return a normalized daily refresh rule with an IANA timezone."""
normalized_hour = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_HOUR
normalized_minute = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_MINUTE

if isinstance(schedule_time, str) and schedule_time.strip():
time_parts = schedule_time.strip().split(':')
if len(time_parts) >= 2:
try:
parsed_hour = int(time_parts[0])
parsed_minute = int(time_parts[1])
if 0 <= parsed_hour <= 23 and 0 <= parsed_minute <= 59:
normalized_hour = parsed_hour
normalized_minute = parsed_minute
except (TypeError, ValueError):
pass
else:
try:
parsed_hour = int(schedule_hour)
if 0 <= parsed_hour <= 23:
normalized_hour = parsed_hour
except (TypeError, ValueError):
pass

try:
parsed_minute = int(schedule_minute)
if 0 <= parsed_minute <= 59:
normalized_minute = parsed_minute
except (TypeError, ValueError):
pass

normalized_timezone = (
schedule_timezone.strip()
if isinstance(schedule_timezone, str) and schedule_timezone.strip()
else CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE
)
try:
ZoneInfo(normalized_timezone)
except (ZoneInfoNotFoundError, ValueError):
normalized_timezone = CONTROL_CENTER_DEFAULT_AUTO_REFRESH_TIMEZONE

return {
'hour': normalized_hour,
'minute': normalized_minute,
'time': f"{normalized_hour:02d}:{normalized_minute:02d}",
'timezone': normalized_timezone,
}


def get_control_center_auto_refresh_schedule(settings=None):
"""Normalize schedule fields from app settings."""
settings = settings or {}
return normalize_control_center_auto_refresh_time(
settings.get('control_center_auto_refresh_time'),
settings.get('control_center_auto_refresh_hour'),
settings.get('control_center_auto_refresh_minute'),
settings.get('control_center_auto_refresh_timezone'),
)


def calculate_next_control_center_auto_refresh_run(settings=None, current_time=None):
"""Calculate the next daily Control Center refresh as a UTC datetime."""
current_time = current_time or datetime.now(timezone.utc)
if current_time.tzinfo is None:
current_time = current_time.replace(tzinfo=timezone.utc)
else:
current_time = current_time.astimezone(timezone.utc)

schedule = get_control_center_auto_refresh_schedule(settings)
schedule_timezone = ZoneInfo(schedule['timezone'])
local_current_time = current_time.astimezone(schedule_timezone)
next_run_local = local_current_time.replace(
hour=schedule['hour'],
minute=schedule['minute'],
second=0,
microsecond=0,
)
if next_run_local <= local_current_time:
next_run_local += timedelta(days=1)

return next_run_local.astimezone(timezone.utc)


def parse_control_center_auto_refresh_datetime(timestamp_value):
"""Parse an ISO timestamp as a timezone-aware UTC datetime."""
if not timestamp_value:
return None

try:
if isinstance(timestamp_value, datetime):
parsed_datetime = timestamp_value
else:
normalized_value = timestamp_value.replace('Z', '+00:00') if isinstance(timestamp_value, str) else timestamp_value
parsed_datetime = datetime.fromisoformat(normalized_value)
if parsed_datetime.tzinfo is None:
parsed_datetime = parsed_datetime.replace(tzinfo=timezone.utc)
return parsed_datetime.astimezone(timezone.utc)
except (TypeError, ValueError):
return None


def is_control_center_auto_refresh_due(settings=None, current_time=None):
"""Return whether an enabled schedule has reached its saved UTC next run."""
settings = settings or {}
if not settings.get('control_center_auto_refresh_enabled', True):
return False

next_run = parse_control_center_auto_refresh_datetime(
settings.get('control_center_auto_refresh_next_run')
)
if not next_run:
return False

current_time = current_time or datetime.now(timezone.utc)
if current_time.tzinfo is None:
current_time = current_time.replace(tzinfo=timezone.utc)
else:
current_time = current_time.astimezone(timezone.utc)

return current_time >= next_run
# The schedule rules are pure and live in functions_control_center_schedule, so the
# admin settings normalizer can share them without importing config.
from functions_control_center_schedule import calculate_next_control_center_auto_refresh_run


def execute_control_center_refresh(manual_execution=False):
Expand Down
Loading
Loading