from __future__ import annotations import json from typing import Any from django import forms from django.http import HttpRequest, HttpResponse from django.http import Http404 from django.http import JsonResponse from django.shortcuts import redirect from django.conf import settings as django_settings from django.utils.http import url_has_allowed_host_and_scheme from django.views import View from inertia import render from labs.models import SiteSetting from labs.site_settings.defaults import DEFAULTS from labs.site_settings.types import ( SettingType, coerce_from_spec_default, coerce_from_storage, coerce_to_storage, ) from .base import PermissionRequiredAdminMixin _KEEP_CURRENT_VALUE = object() def _get_spec(key: str) -> dict[str, Any]: try: spec = DEFAULTS[key] except KeyError as e: raise Http404(f"Unknown site setting '{key}'") from e if not isinstance(spec, dict): raise Http404(f"Invalid spec for site setting '{key}'") return spec def _parse_bool(raw_value: str) -> bool: value = raw_value.strip().lower() if value in {"1", "true", "yes", "y", "on"}: return True if value in {"0", "false", "no", "n", "off"}: return False raise ValueError("Enter either true or false") def _parse_user_value(setting_type: SettingType, raw_value: str) -> Any: if setting_type == SettingType.STRING: return raw_value if setting_type == SettingType.INT: if raw_value.strip() == "": raise ValueError("Enter a valid integer") return int(raw_value) if setting_type == SettingType.BOOL: if raw_value.strip() == "": raise ValueError("Enter either true or false") return _parse_bool(raw_value) if setting_type == SettingType.JSON: if raw_value.strip() == "": raise ValueError("Enter valid JSON") return json.loads(raw_value) raise ValueError(f"Unsupported type: {setting_type}") def _mask_display_value(value: str) -> str: if not value: return "" if len(value) <= 2: return f"{value[:1]}****" if len(value) <= 4: return f"{value[:1]}****{value[-1:]}" if len(value) <= 8: return f"{value[:2]}****{value[-2:]}" return f"{value[:4]}****{value[-4:]}" def _serialize_value_for_props(setting_type: SettingType, value: Any) -> Any: if setting_type == SettingType.JSON: # Ensure JSON settings are always JSON-serializable. json.dumps(value) return value if setting_type == SettingType.BOOL: return bool(value) if setting_type == SettingType.INT: return int(value) if setting_type == SettingType.STRING: return str(value) raise ValueError(f"Unsupported type: {setting_type}") def _get_next_url(request: HttpRequest) -> str | None: next_url = (request.POST.get("next") or "").strip() if not next_url: return None if not url_has_allowed_host_and_scheme( next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure(), ): return None if not next_url.startswith("/admin/site-settings"): return None return next_url def _wants_json_response(request: HttpRequest) -> bool: accept = request.headers.get("Accept", "") is_inertia = bool(request.headers.get("X-Inertia")) return (not is_inertia) and ("application/json" in accept) def _build_setting_item(key: str, override: SiteSetting | None) -> dict[str, Any]: spec = _get_spec(key) setting_type = SettingType(spec["type"]) default_value = coerce_from_spec_default(setting_type, spec["default"]) masked = bool(spec.get("masked", False)) label = str(spec.get("label") or key) i18n = bool(spec.get("i18n", False)) choices = spec.get("choices") override_error: str | None = None override_value: Any | None = None if override is not None: try: db_type = SettingType(override.key_type) except ValueError: override_error = f"Invalid DB type '{override.key_type}'" else: if db_type != setting_type: override_error = ( f"Type mismatch (spec={setting_type.value}, db={db_type.value})" ) else: try: override_value = coerce_from_storage(db_type, override.value) except (ValueError, json.JSONDecodeError, TypeError) as e: override_error = str(e) effective_value = ( override_value if override is not None and override_error is None else default_value ) masked_value: str | None = None if masked: if isinstance(effective_value, str): masked_value = _mask_display_value(effective_value) else: masked_value = "****" if effective_value else "" return { "key": key, "type": setting_type.value, "label": label, "description": spec.get("description", ""), "category": spec.get("category", ""), "i18n": i18n, "choices": choices, "masked": masked, "overridden": override is not None, "override_updated_at": override.updated_at.isoformat() if override is not None and override.updated_at else None, "override_error": override_error, "masked_value": masked_value, "default_value": None if masked else _serialize_value_for_props(setting_type, default_value), "effective_value": None if masked else _serialize_value_for_props(setting_type, effective_value), "is_set": None if not masked else bool(effective_value), } class SiteSettingOverrideForm(forms.Form): value = forms.CharField( label="Value", required=False, widget=forms.Textarea(attrs={"rows": 8}), ) def __init__( self, *args, setting_key: str, setting_type: SettingType, masked: bool, override_exists: bool, **kwargs, ) -> None: super().__init__(*args, **kwargs) self.setting_key = setting_key self.setting_type = setting_type self.masked = masked self.override_exists = override_exists def clean_value(self) -> Any: raw_value = self.cleaned_data.get("value") or "" if self.masked and raw_value == "": # For masked settings we don't display the current value; leaving the # field empty means "keep current" (if any). if self.override_exists: return _KEEP_CURRENT_VALUE raise forms.ValidationError("Value is required") try: return _parse_user_value(self.setting_type, raw_value) except (ValueError, json.JSONDecodeError) as e: raise forms.ValidationError(str(e)) from e class SiteSettingListView(PermissionRequiredAdminMixin, View): """Defaults-driven list view for site settings (like Discourse).""" permission_required = "labs.view_sitesetting" component = "site-settings/Index" def get(self, request: HttpRequest) -> HttpResponse: db_settings = list( SiteSetting.objects.filter(key__in=DEFAULTS.keys()).only( "key", "key_type", "value", "updated_at" ) ) overrides_by_key = {s.key: s for s in db_settings} items: list[dict[str, Any]] = [] for key in sorted(DEFAULTS.keys()): items.append(_build_setting_item(key, overrides_by_key.get(key))) categories = sorted({i["category"] for i in items if i.get("category")}) return render( request, self.component, { "settings": items, "categories": categories, "languages": [ {"code": code, "name": name} for code, name in django_settings.LANGUAGES ], }, ) class SiteSettingUpdateView(PermissionRequiredAdminMixin, View): """Update a single site setting override (no detail page).""" permission_required = "labs.view_sitesetting" def get(self, request: HttpRequest, key: str) -> HttpResponse: return redirect("/admin/site-settings/") def post(self, request: HttpRequest, key: str) -> HttpResponse: wants_json = _wants_json_response(request) if not request.user.has_perm("labs.change_sitesetting"): if wants_json: return JsonResponse( {"ok": False, "error": "Permission denied"}, status=403, ) return redirect("/admin/site-settings/") next_url = _get_next_url(request) redirect_url = next_url or "/admin/site-settings/" spec = _get_spec(key) setting_type = SettingType(spec["type"]) masked = bool(spec.get("masked", False)) default_value = coerce_from_spec_default(setting_type, spec["default"]) override_exists = SiteSetting.objects.filter(key=key).exists() form = SiteSettingOverrideForm( data=request.POST, setting_key=key, setting_type=setting_type, masked=masked, override_exists=override_exists, ) if not form.is_valid(): if wants_json: return JsonResponse( {"ok": False, "errors": form.errors}, status=400, ) request.session["flash_error"] = "; ".join( [" ".join(v) for v in form.errors.values()] ) return redirect(redirect_url) value = form.cleaned_data["value"] if value is _KEEP_CURRENT_VALUE: if not wants_json: request.session["flash_success"] = "No changes" return redirect(redirect_url) override = ( SiteSetting.objects.filter(key=key) .only("key", "key_type", "value", "updated_at") .first() ) return JsonResponse({"ok": True, "setting": _build_setting_item(key, override)}) if value == default_value: deleted, _ = SiteSetting.objects.filter(key=key).delete() if not wants_json: request.session["flash_success"] = ( "Reset to default" if deleted else "Already using default" ) return redirect(redirect_url) return JsonResponse( {"ok": True, "setting": _build_setting_item(key, None)}, ) SiteSetting.objects.update_or_create( key=key, defaults={ "key_type": setting_type.value, "value": coerce_to_storage(setting_type, value), }, ) if not wants_json: request.session["flash_success"] = "Site setting updated" return redirect(redirect_url) override = ( SiteSetting.objects.filter(key=key) .only("key", "key_type", "value", "updated_at") .first() ) return JsonResponse({"ok": True, "setting": _build_setting_item(key, override)}) class SiteSettingResetView(PermissionRequiredAdminMixin, View): permission_required = "labs.view_sitesetting" def post(self, request: HttpRequest, key: str) -> HttpResponse: wants_json = _wants_json_response(request) if not request.user.has_perm("labs.change_sitesetting"): if wants_json: return JsonResponse( {"ok": False, "error": "Permission denied"}, status=403, ) return redirect("/admin/site-settings/") next_url = _get_next_url(request) redirect_url = next_url or "/admin/site-settings/" _get_spec(key) deleted, _ = SiteSetting.objects.filter(key=key).delete() if not wants_json: request.session["flash_success"] = ( "Reset to default" if deleted else "Already using default" ) return redirect(redirect_url) return JsonResponse({"ok": True, "setting": _build_setting_item(key, None)})