fix: merge main branch and renumber migration 027→037

Resolve 3 merge conflicts and renumber the automation_hooks migration
to follow main's migration chain (036_add_document_translation_fields).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
- app/utils/settings_service.py: add automation_hooks_enabled alongside compliance_enabled
- tests/conftest.py: add AutomationHook alongside AuditLog/ComplianceTemplate imports

Migration renumbered:
- 027_add_automation_hooks → 037_add_automation_hooks
- down_revision: 026_add_scheduled_jobs → 036_add_document_translation_fields

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-16 22:40:15 +00:00
parent 6a83d51d88
commit 204000aabc
318 changed files with 317516 additions and 2858 deletions
+46 -46
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}Settings - DocuElevate{% endblock %}
{% block title %}{{ _("settings.title") }} - DocuElevate{% endblock %}
{# Category icon mapping #}
{% set category_icons = {
@@ -48,27 +48,27 @@
<div class="mb-6">
<div class="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
<div>
<h1 class="text-3xl font-bold mb-1">Application Settings</h1>
<h1 class="text-3xl font-bold mb-1">{{ _("settings.page_heading") }}</h1>
<p class="text-gray-500 text-sm">
Manage configuration. Priority: <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">DB</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 ml-1">ENV</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 ml-1">DEFAULT</span>
<span class="mx-1.5">·</span> <span class="text-red-600">*</span> = restart required <span class="mx-1.5">·</span> <i class="fas fa-lock text-xs" aria-hidden="true"></i> = encrypted at rest
{{ _("settings.manage_config_prefix") }} <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ _("settings.source_db_badge") }}</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 ml-1">{{ _("settings.source_env_badge") }}</span> &gt;
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 ml-1">{{ _("settings.source_default_badge") }}</span>
<span class="mx-1.5">·</span> <span class="text-red-600">*</span> {{ _("settings.restart_required_suffix") }} <span class="mx-1.5">·</span> <i class="fas fa-lock text-xs" aria-hidden="true"></i> {{ _("settings.encrypted_suffix") }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<a href="/setup?step=1"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-magic mr-1.5" aria-hidden="true"></i> Wizard
<i class="fas fa-magic mr-1.5" aria-hidden="true"></i> {{ _("settings.wizard_btn") }}
</a>
<a href="/database-wizard"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-database mr-1.5" aria-hidden="true"></i> DB Wizard
<i class="fas fa-database mr-1.5" aria-hidden="true"></i> {{ _("settings.db_wizard_btn") }}
</a>
<div class="relative" x-data="{ exportOpen: false }">
<button @click="exportOpen = !exportOpen"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
<i class="fas fa-download mr-1.5" aria-hidden="true"></i> Export
<i class="fas fa-download mr-1.5" aria-hidden="true"></i> {{ _("settings.export_btn") }}
<i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i>
</button>
<div x-show="exportOpen" @click.outside="exportOpen = false" x-transition
@@ -76,18 +76,18 @@
<a href="/api/settings/export-env?source=db"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
@click="exportOpen = false">
<i class="fas fa-database mr-2 text-green-600" aria-hidden="true"></i>DB settings only
<i class="fas fa-database mr-2 text-green-600" aria-hidden="true"></i>{{ _("settings.export_db_only") }}
</a>
<a href="/api/settings/export-env?source=effective"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
@click="exportOpen = false">
<i class="fas fa-layer-group mr-2 text-blue-600" aria-hidden="true"></i>Full effective config
<i class="fas fa-layer-group mr-2 text-blue-600" aria-hidden="true"></i>{{ _("settings.export_full_config") }}
</a>
</div>
</div>
<a href="/admin/settings/audit-log"
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-history mr-1.5" aria-hidden="true"></i> Audit Log
<i class="fas fa-history mr-1.5" aria-hidden="true"></i> {{ _("settings.audit_log_btn") }}
</a>
</div>
</div>
@@ -99,14 +99,14 @@
type="search"
x-model="searchQuery"
@input="filterSettings()"
placeholder="Search settings by name, key, or description…"
placeholder="{{ _('settings.search_placeholder') }}"
class="w-full pl-10 pr-20 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
aria-label="Search settings"
aria-label="{{ _('settings.search_aria_label') }}"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
<span x-show="searchQuery" x-text="matchCount + ' found'" x-transition class="text-xs text-gray-400"></span>
<button x-show="searchQuery" @click="searchQuery = ''; filterSettings()" type="button"
class="text-gray-400 hover:text-gray-600" aria-label="Clear search">
class="text-gray-400 hover:text-gray-600" aria-label="{{ _('settings.search_clear_aria') }}">
<i class="fas fa-times"></i>
</button>
</div>
@@ -125,9 +125,9 @@
<!-- Two-column layout -->
<div class="flex gap-6">
<!-- Sidebar Navigation (hidden on small screens) -->
<aside class="hidden lg:block w-56 flex-shrink-0" aria-label="Settings categories">
<aside class="hidden lg:block w-56 flex-shrink-0" aria-label="{{ _('settings.categories_aria') }}">
<nav class="settings-sidebar space-y-0.5 bg-white rounded-lg border border-gray-200 p-2 shadow-sm">
<p class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Categories</p>
<p class="px-3 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("settings.categories_heading") }}</p>
{% for category, settings_list in settings_data.items() %}
<button
type="button"
@@ -149,11 +149,11 @@
<div class="flex-1 min-w-0">
<!-- Mobile category jump (visible on small screens only) -->
<div class="lg:hidden mb-4">
<label for="mobile-category-select" class="sr-only">Jump to category</label>
<label for="mobile-category-select" class="sr-only">{{ _("settings.jump_to_category") }}</label>
<select id="mobile-category-select"
@change="scrollToCategory($event.target.value)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm shadow-sm">
<option value="">Jump to category</option>
<option value="">{{ _("settings.jump_to_category") }}</option>
{% for category in settings_data.keys() %}
<option value="{{ category }}">{{ category }} ({{ settings_data[category]|length }})</option>
{% endfor %}
@@ -163,8 +163,8 @@
<!-- No results state -->
<div x-show="searchQuery && matchCount === 0" class="settings-no-results bg-white rounded-lg border border-gray-200 shadow-sm">
<i class="fas fa-search text-4xl text-gray-300 mb-3"></i>
<p class="text-gray-500 text-lg font-medium">No settings found</p>
<p class="text-gray-400 text-sm mt-1">Try a different search term or <button type="button" @click="searchQuery = ''; filterSettings()" class="text-blue-500 hover:underline">clear the search</button></p>
<p class="text-gray-500 text-lg font-medium">{{ _("settings.no_results_heading") }}</p>
<p class="text-gray-400 text-sm mt-1">{{ _("settings.no_results_hint") }} <button type="button" @click="searchQuery = ''; filterSettings()" class="text-blue-500 hover:underline">{{ _("settings.no_results_clear") }}</button></p>
</div>
<form @submit.prevent="saveSettings">
@@ -191,7 +191,7 @@
<h2 class="text-lg font-semibold text-gray-800">{{ category }}</h2>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full tabular-nums"
x-text="(visibleCount['{{ category|e }}'] ?? {{ settings_list|length }}) + ' settings'">
{{ settings_list|length }} settings
{{ settings_list|length }} {{ _("settings.settings_count_suffix") }}
</span>
</div>
<i class="fas fa-chevron-down text-gray-400 category-chevron"
@@ -225,7 +225,7 @@
<span class="text-red-600" title="Restart required">*</span>
{% endif %}
{% if setting.metadata.required %}
<span class="text-red-600 text-xs">(required)</span>
<span class="text-red-600 text-xs">{{ _("settings.required_label") }}</span>
{% endif %}
</label>
<!-- Source Badge -->
@@ -262,7 +262,7 @@
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label for="{{ setting.key }}" class="ml-2 text-sm text-gray-700">
Enable {{ setting.key.replace('_', ' ').title() }}
{{ _("settings.boolean_enable_prefix") }} {{ setting.key.replace('_', ' ').title() }}
</label>
</div>
{% elif setting.metadata.type == 'slider' %}
@@ -302,7 +302,7 @@
</label>
{% endfor %}
</div>
<p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
<p class="mt-1 text-xs text-gray-400">{{ _("settings.effective_value_label") }} <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
{% elif setting.metadata.type == 'user_autocomplete' %}
<!-- User Autocomplete (WCAG 2.1 AA combobox pattern) -->
<div class="relative" x-data="userAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
@@ -320,7 +320,7 @@
@keydown.enter.prevent="selectHighlighted()"
@keydown.escape="showSuggestions = false"
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Start typing to search users…"
placeholder="{{ _('settings.user_autocomplete_placeholder') }}"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
@@ -371,11 +371,11 @@
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
role="status"
>
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matching users found
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> {{ _("settings.user_autocomplete_empty") }}
</div>
<p class="text-xs text-gray-400 mt-1" id="{{ setting.key }}_help">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Type to search existing users by name, or enter any identifier manually.
{{ _("settings.user_autocomplete_hint") }}
</p>
</div>
{% elif setting.metadata.type == 'autocomplete' %}
@@ -395,7 +395,7 @@
@keydown.enter.prevent="selectHighlighted()"
@keydown.escape="showSuggestions = false"
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Type to search or enter a value…"
placeholder="{{ _('settings.autocomplete_placeholder') }}"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
@@ -445,11 +445,11 @@
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
role="status"
>
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matches — you can still type a custom value
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> {{ _("settings.autocomplete_no_matches") }}
</div>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Type to search known values, or enter any custom value.
{{ _("settings.autocomplete_hint") }}
</p>
</div>
{% elif setting.metadata.type == 'model_picker' %}
@@ -462,7 +462,7 @@
list="{{ setting.key }}_models"
x-model="formData['{{ setting.key }}']"
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Select a common model or type a custom name…"
placeholder="{{ _('settings.model_picker_placeholder') }}"
autocomplete="off"
/>
<datalist id="{{ setting.key }}_models">
@@ -472,7 +472,7 @@
</datalist>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Pick from the list or type any model name supported by your provider.
{{ _("settings.model_picker_hint") }}
</p>
</div>
{% elif setting.metadata.options %}
@@ -483,7 +483,7 @@
x-model="formData['{{ setting.key }}']"
class="setting-input w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">— select —</option>
<option value="">{{ _("common.select_placeholder") }}</option>
{% for opt in setting.metadata.options %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
@@ -503,15 +503,15 @@
autocomplete="off"
/>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 space-x-2">
<span class="text-xs text-gray-400" title="Value is encrypted at rest in database">
<span class="text-xs text-gray-400" title="{{ _('settings.encrypted_title') }}">
<i class="fas fa-lock"></i>
</span>
<button
type="button"
@click="togglePassword('{{ setting.key }}')"
class="text-gray-400 hover:text-gray-600 focus:outline-none"
title="Show/hide value"
aria-label="Toggle password visibility"
title="{{ _('settings.toggle_visibility_title') }}"
aria-label="{{ _('settings.toggle_visibility_aria') }}"
>
<i :class="showPassword['{{ setting.key }}'] ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
@@ -540,10 +540,10 @@
@click="revertSetting('{{ setting.key }}')"
:disabled="revertingKey === '{{ setting.key }}'"
class="px-3 py-1 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Remove DB override and revert to environment variable or default"
title="{{ _('settings.revert_title') }}"
>
<span x-show="revertingKey !== '{{ setting.key }}'"><i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Remove from DB</span>
<span x-show="revertingKey === '{{ setting.key }}'">Reverting</span>
<span x-show="revertingKey !== '{{ setting.key }}'"><i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("settings.revert_btn") }}</span>
<span x-show="revertingKey === '{{ setting.key }}'">{{ _("settings.reverting") }}</span>
</button>
<button
type="button"
@@ -552,10 +552,10 @@
@click="saveSetting('{{ setting.key }}')"
:disabled="savingKey === '{{ setting.key }}'"
class="px-3 py-1 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Save this setting"
title="{{ _('settings.save_setting_title') }}"
>
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1" aria-hidden="true"></i>Save</span>
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1" aria-hidden="true"></i>{{ _("common.save") }}</span>
<span x-show="savingKey === '{{ setting.key }}'">{{ _("settings.saving_state") }}</span>
</button>
</div>
</div>
@@ -573,15 +573,15 @@
@click="resetForm"
class="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Reset
{{ _("common.reset") }}
</button>
<button
type="submit"
:disabled="saving"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span x-show="!saving">Save All Changes</span>
<span x-show="saving">Saving...</span>
<span x-show="!saving">{{ _("settings.save_all_btn") }}</span>
<span x-show="saving">{{ _("settings.saving_state") }}</span>
</button>
</div>
</form>