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.
This commit is contained in:
Dries Peeters
2026-01-21 14:20:36 +01:00
parent 6881e554ce
commit 8c070d08d5
5 changed files with 139 additions and 1 deletions
+4
View File
@@ -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,
+25
View File
@@ -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,
)
+43
View File
@@ -263,6 +263,49 @@
</div>
</div>
<!-- Module visibility -->
{% if modules_by_category is defined and modules_by_category %}
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Module Visibility') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('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.') }}
</p>
{% set _d = (settings.disabled_module_ids or []) %}
<div class="space-y-6">
{% for category, modules in modules_by_category.items() %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-3">
{% 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 %}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{% for module in modules %}
<div class="flex items-center">
<input type="checkbox" name="module_enabled_{{ module.id }}" id="module_enabled_{{ module.id }}"
{% if module.id not in _d %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<label for="module_enabled_{{ module.id }}" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">
{{ module.name }}
</label>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<p class="mt-3 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Disabling "Reports" hides the whole Reports submenu including Report Builder and Scheduled Reports.') }}
</p>
</div>
{% endif %}
<!-- Analytics Settings -->
<div>
<h2 class="text-lg font-semibold mb-4">{{ _('Privacy & Analytics') }}</h2>
+7 -1
View File
@@ -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
@@ -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")