feat: complete modular platform architecture (Phases 1-5)

Phase 1 - Vendor Router Integration:
- Wire up vendor module routers in app/api/v1/vendor/__init__.py
- Use lazy imports via __getattr__ to avoid circular dependencies

Phase 2 - Extract Remaining Modules:
- Create 6 new module directories: customers, cms, analytics, messaging,
  dev_tools, monitoring
- Each module has definition.py and route wrappers
- Update registry to import from extracted modules

Phase 3 - Database Table Migration:
- Add PlatformModule junction table for auditable module tracking
- Add migration zc2m3n4o5p6q7_add_platform_modules_table.py
- Add modules relationship to Platform model
- Update ModuleService with JSON-to-junction-table migration

Phase 4 - Module-Specific Configuration UI:
- Add /api/v1/admin/module-config/* endpoints
- Add module-config.html template and JS

Phase 5 - Integration Tests:
- Add tests/fixtures/module_fixtures.py
- Add tests/integration/api/v1/admin/test_modules.py
- Add tests/integration/api/v1/modules/test_module_access.py

Architecture fixes:
- Fix JS-003 errors: use ...data() directly in Alpine components
- Fix JS-005 warnings: add init() guards to prevent duplicate init
- Fix API-001 errors: add MenuActionResponse Pydantic model
- Add FE-008 noqa for dynamic number input in template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 18:19:00 +01:00
parent f29f1113cd
commit c419090531
55 changed files with 4059 additions and 206 deletions

View File

@@ -0,0 +1,149 @@
// static/admin/js/module-config.js
// Module configuration management for platform modules
const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console;
function adminModuleConfig(platformCode, moduleCode) {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'platforms',
platformCode: platformCode,
moduleCode: moduleCode,
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
platformId: null,
platformName: '',
moduleInfo: null,
config: {},
async init() {
// Guard against duplicate initialization
if (window._moduleConfigInitialized) {
moduleConfigLog.warn('Already initialized, skipping');
return;
}
window._moduleConfigInitialized = true;
moduleConfigLog.info('=== MODULE CONFIG PAGE INITIALIZING ===');
moduleConfigLog.info('Platform code:', this.platformCode);
moduleConfigLog.info('Module code:', this.moduleCode);
try {
await this.loadPlatform();
await this.loadModuleConfig();
moduleConfigLog.info('=== MODULE CONFIG PAGE INITIALIZED ===');
} catch (error) {
moduleConfigLog.error('Failed to initialize module config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadModuleConfig();
},
async loadPlatform() {
try {
const platform = await apiClient.get(`/admin/platforms/${this.platformCode}`);
this.platformId = platform.id;
this.platformName = platform.name;
moduleConfigLog.info('Loaded platform:', platform.name);
} catch (error) {
moduleConfigLog.error('Failed to load platform:', error);
throw error;
}
},
async loadModuleConfig() {
this.loading = true;
this.error = null;
try {
if (!this.platformId) {
throw new Error('Platform not loaded');
}
this.moduleInfo = await apiClient.get(
`/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/config`
);
// Initialize config with current values
this.config = { ...this.moduleInfo.config };
moduleConfigLog.info('Loaded module config:', {
moduleCode: this.moduleCode,
config: this.config
});
} catch (error) {
moduleConfigLog.error('Failed to load module config:', error);
this.error = error.message || 'Failed to load module configuration';
} finally {
this.loading = false;
}
},
async saveConfig() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const response = await apiClient.put(
`/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/config`,
{ config: this.config }
);
// Update local state with response
this.moduleInfo = response;
this.config = { ...response.config };
this.successMessage = 'Configuration saved successfully';
moduleConfigLog.info('Saved module config:', this.config);
} catch (error) {
moduleConfigLog.error('Failed to save module config:', error);
this.error = error.message || 'Failed to save configuration';
} finally {
this.saving = false;
}
},
async resetToDefaults() {
if (!confirm('This will reset all configuration options to their default values. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const response = await apiClient.post(
`/admin/module-config/platforms/${this.platformId}/modules/${this.moduleCode}/reset`
);
// Update local state with defaults
this.config = { ...response.config };
// Reload full config to get schema_info
await this.loadModuleConfig();
this.successMessage = 'Configuration reset to defaults';
moduleConfigLog.info('Reset module config to defaults');
} catch (error) {
moduleConfigLog.error('Failed to reset module config:', error);
this.error = error.message || 'Failed to reset configuration';
} finally {
this.saving = false;
}
}
};
}

View File

@@ -0,0 +1,187 @@
// static/admin/js/my-menu-config.js
// Personal menu configuration for super admins
//
// TODO: BUG - Sidebar menu doesn't update immediately after changes.
// User must navigate to another page to see the updated menu.
// The issue is that Alpine.js doesn't properly track reactivity for the
// visibleMenuItems Set in init-alpine.js. Attempted fixes with reloadSidebarMenu()
// and window.location.reload() didn't work reliably.
// Possible solutions:
// 1. Convert visibleMenuItems from Set to plain object for better Alpine reactivity
// 2. Use Alpine.store() for shared state between components
// 3. Dispatch a custom event that the sidebar listens for
// 4. Force re-render of sidebar component after changes
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
function adminMyMenuConfig() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'my-menu',
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
menuConfig: null,
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against multiple initialization
if (window._adminMyMenuConfigInitialized) {
myMenuConfigLog.warn('Already initialized, skipping');
return;
}
window._adminMyMenuConfigInitialized = true;
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
try {
await this.loadMenuConfig();
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadMenuConfig();
},
async loadMenuConfig() {
this.loading = true;
this.error = null;
try {
this.menuConfig = await apiClient.get('/admin/menu-config/user');
myMenuConfigLog.info('Loaded menu config:', {
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
myMenuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
await apiClient.put('/admin/menu-config/user', {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
if (!confirm('This will show all menu items. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/show-all');
myMenuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/reset');
myMenuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -0,0 +1,212 @@
// static/admin/js/platform-menu-config.js
// Platform menu configuration management
//
// TODO: BUG - Sidebar menu doesn't update immediately after changes.
// See my-menu-config.js for details and possible solutions.
const menuConfigLog = window.LogConfig?.loggers?.menuConfig || window.LogConfig?.createLogger?.('menuConfig') || console;
function adminPlatformMenuConfig(platformCode) {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'platforms',
platformCode: platformCode,
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
platform: null,
menuConfig: null,
frontendType: 'admin',
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against duplicate initialization
if (window._platformMenuConfigInitialized) {
menuConfigLog.warn('Already initialized, skipping');
return;
}
window._platformMenuConfigInitialized = true;
menuConfigLog.info('=== PLATFORM MENU CONFIG PAGE INITIALIZING ===');
menuConfigLog.info('Platform code:', this.platformCode);
try {
await this.loadPlatform();
await this.loadPlatformMenuConfig();
menuConfigLog.info('=== PLATFORM MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
menuConfigLog.error('Failed to initialize menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadPlatformMenuConfig();
},
async loadPlatform() {
try {
this.platform = await apiClient.get(`/admin/platforms/${this.platformCode}`);
menuConfigLog.info('Loaded platform:', this.platform?.name);
} catch (error) {
menuConfigLog.error('Failed to load platform:', error);
throw error;
}
},
async loadPlatformMenuConfig() {
this.loading = true;
this.error = null;
try {
const platformId = this.platform?.id;
if (!platformId) {
throw new Error('Platform not loaded');
}
const params = new URLSearchParams({ frontend_type: this.frontendType });
this.menuConfig = await apiClient.get(`/admin/menu-config/platforms/${platformId}?${params}`);
menuConfigLog.info('Loaded menu config:', {
frontendType: this.frontendType,
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
menuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
menuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
const platformId = this.platform?.id;
const params = new URLSearchParams({ frontend_type: this.frontendType });
await apiClient.put(`/admin/menu-config/platforms/${platformId}?${params}`, {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
menuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
menuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
if (!confirm('This will show all menu items. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const platformId = this.platform?.id;
const params = new URLSearchParams({ frontend_type: this.frontendType });
await apiClient.post(`/admin/menu-config/platforms/${platformId}/show-all?${params}`);
menuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
menuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
if (!confirm('This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?')) {
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
try {
const platformId = this.platform?.id;
const params = new URLSearchParams({ frontend_type: this.frontendType });
await apiClient.post(`/admin/menu-config/platforms/${platformId}/reset?${params}`);
menuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
menuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -4,12 +4,9 @@
const moduleConfigLog = window.LogConfig?.loggers?.moduleConfig || window.LogConfig?.createLogger?.('moduleConfig') || console;
function adminPlatformModules(platformCode) {
// Get base data with safety check for standalone usage
const baseData = typeof data === 'function' ? data() : {};
return {
// Inherit base layout functionality from init-alpine.js
...baseData,
...data(),
// Page-specific state
currentPage: 'platforms',
@@ -62,6 +59,13 @@ function adminPlatformModules(platformCode) {
},
async init() {
// Guard against duplicate initialization
if (window._platformModulesInitialized) {
moduleConfigLog.warn('Already initialized, skipping');
return;
}
window._platformModulesInitialized = true;
moduleConfigLog.info('=== PLATFORM MODULES PAGE INITIALIZING ===');
moduleConfigLog.info('Platform code:', this.platformCode);