From 8c070d08d50ab7aec09880d638724bcfbcb2735b Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Wed, 21 Jan 2026 14:20:36 +0100 Subject: [PATCH] feat(admin): restore admin-defined module visibility - Add settings.disabled_module_ids (JSON) to store admin-disabled module IDs - Migration 110: add disabled_module_ids column to settings - ModuleRegistry.is_enabled() respects settings.disabled_module_ids - Admin > System Settings: new 'Module visibility' section with toggles for all non-core modules; disabled modules are hidden from all users. - Core modules stay always on; default empty list keeps current behavior. --- app/models/settings.py | 4 ++ app/routes/admin.py | 25 ++++++++ app/templates/admin/settings.html | 43 +++++++++++++ app/utils/module_registry.py | 8 ++- .../versions/110_add_disabled_module_ids.py | 60 +++++++++++++++++++ 5 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/110_add_disabled_module_ids.py diff --git a/app/models/settings.py b/app/models/settings.py index 3cc2f5f2..4934b05b 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -55,6 +55,9 @@ class Settings(db.Model): # Privacy and analytics settings allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics + # Module visibility: admin-disabled module IDs (e.g. ["gantt", "leads"]). Empty/None = all enabled. + disabled_module_ids = db.Column(db.JSON, default=list, nullable=True) + # Kiosk mode settings kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False) kiosk_auto_logout_minutes = db.Column(db.Integer, default=15, nullable=False) @@ -374,6 +377,7 @@ class Settings(db.Model): "invoice_pdf_template_css": self.invoice_pdf_template_css, "invoice_pdf_design_json": self.invoice_pdf_design_json, "allow_analytics": self.allow_analytics, + "disabled_module_ids": (self.disabled_module_ids if self.disabled_module_ids is not None else []), "mail_enabled": self.mail_enabled, "mail_server": self.mail_server, "mail_port": self.mail_port, diff --git a/app/routes/admin.py b/app/routes/admin.py index 2b4406c5..b08ee630 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -949,6 +949,16 @@ def settings(): "kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"), } + # Module visibility: non-CORE modules for admin toggles + from app.utils.module_registry import ModuleRegistry, ModuleCategory + + ModuleRegistry.initialize_defaults() + modules_by_category = {} + for cat in ModuleCategory: + mods = [m for m in ModuleRegistry.get_by_category(cat) if m.category != ModuleCategory.CORE] + if mods: + modules_by_category[cat] = mods + if request.method == "POST": # Validate timezone timezone = request.form.get("timezone") or settings_obj.timezone @@ -964,6 +974,8 @@ def settings(): timezones=timezones, kiosk_settings=kiosk_settings, peppol_env_enabled=peppol_env_enabled, + modules_by_category=modules_by_category, + ModuleCategory=ModuleCategory, ) # Update basic settings @@ -1059,6 +1071,15 @@ def settings(): app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics) app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics}) + # Module visibility: build disabled_module_ids from unchecked module_enabled_* checkboxes + if hasattr(settings_obj, "disabled_module_ids"): + disabled = [] + for mods in modules_by_category.values(): + for m in mods: + if ("module_enabled_" + m.id) not in request.form: + disabled.append(m.id) + settings_obj.disabled_module_ids = disabled + # Ensure settings object is in the session (important for new instances) if settings_obj not in db.session: db.session.add(settings_obj) @@ -1071,6 +1092,8 @@ def settings(): timezones=timezones, kiosk_settings=kiosk_settings, peppol_env_enabled=peppol_env_enabled, + modules_by_category=modules_by_category, + ModuleCategory=ModuleCategory, ) # #region agent log try: @@ -1099,6 +1122,8 @@ def settings(): timezones=timezones, kiosk_settings=kiosk_settings, peppol_env_enabled=peppol_env_enabled, + modules_by_category=modules_by_category, + ModuleCategory=ModuleCategory, ) diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index ff35e473..68b159fe 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -263,6 +263,49 @@ + + {% if modules_by_category is defined and modules_by_category %} +
+

{{ _('Module Visibility') }}

+

+ {{ _('Disable modules to hide them from all users. Disabled modules will not appear in the sidebar. Core modules (Dashboard, Projects, Timer, etc.) are always enabled.') }} +

+ {% set _d = (settings.disabled_module_ids or []) %} +
+ {% for category, modules in modules_by_category.items() %} +
+

+ {% if category == ModuleCategory.TIME_TRACKING %}{{ _('Time Tracking') }} + {% elif category == ModuleCategory.PROJECT_MANAGEMENT %}{{ _('Project Management') }} + {% elif category == ModuleCategory.CRM %}{{ _('CRM') }} + {% elif category == ModuleCategory.FINANCE %}{{ _('Finance & Expenses') }} + {% elif category == ModuleCategory.INVENTORY %}{{ _('Inventory') }} + {% elif category == ModuleCategory.ANALYTICS %}{{ _('Analytics') }} + {% elif category == ModuleCategory.TOOLS %}{{ _('Tools & Data') }} + {% elif category == ModuleCategory.ADVANCED %}{{ _('Advanced') }} + {% else %}{{ category.value|title }}{% endif %} +

+
+ {% for module in modules %} +
+ + +
+ {% endfor %} +
+
+ {% endfor %} +
+

+ {{ _('Disabling "Reports" hides the whole Reports submenu including Report Builder and Scheduled Reports.') }} +

+
+ {% endif %} +

{{ _('Privacy & Analytics') }}

diff --git a/app/utils/module_registry.py b/app/utils/module_registry.py index 0c7d125a..983005f2 100644 --- a/app/utils/module_registry.py +++ b/app/utils/module_registry.py @@ -107,7 +107,13 @@ class ModuleRegistry: for dep_id in module.dependencies: if not cls.is_enabled(dep_id, settings, user): return False - + + # Admin-disabled modules (settings.disabled_module_ids) + if settings: + disabled = getattr(settings, "disabled_module_ids", None) or [] + if isinstance(disabled, list) and module_id in disabled: + return False + return True @classmethod diff --git a/migrations/versions/110_add_disabled_module_ids.py b/migrations/versions/110_add_disabled_module_ids.py new file mode 100644 index 00000000..2c93a1f1 --- /dev/null +++ b/migrations/versions/110_add_disabled_module_ids.py @@ -0,0 +1,60 @@ +"""Add disabled_module_ids to settings for admin module visibility control + +Revision ID: 110_add_disabled_module_ids +Revises: 109_add_pdf_template_date_format +Create Date: 2025-01-30 + +Admin can disable modules system-wide via settings.disabled_module_ids (JSON array). +Empty or NULL means no modules are disabled (all enabled). +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = "110_add_disabled_module_ids" +down_revision = "109_add_pdf_template_date_format" +branch_labels = None +depends_on = None + + +def upgrade(): + """Add disabled_module_ids (JSON/JSONB) to settings table.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "settings" not in inspector.get_table_names(): + return + + settings_cols = {c["name"] for c in inspector.get_columns("settings")} + if "disabled_module_ids" in settings_cols: + return + + # Use JSON for SQLite/MySQL, JSONB for PostgreSQL + is_pg = bind.dialect.name == "postgresql" + col_type = JSONB() if is_pg else sa.JSON() + + op.add_column( + "settings", + sa.Column("disabled_module_ids", col_type, nullable=True), + ) + + +def downgrade(): + """Remove disabled_module_ids from settings table.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + is_sqlite = bind.dialect.name == "sqlite" + + if "settings" not in inspector.get_table_names(): + return + + settings_cols = {c["name"] for c in inspector.get_columns("settings")} + if "disabled_module_ids" not in settings_cols: + return + + if is_sqlite: + with op.batch_alter_table("settings", schema=None) as batch_op: + batch_op.drop_column("disabled_module_ids") + else: + op.drop_column("settings", "disabled_module_ids")