From cd28bbbc24f57a6c2e85635f963f25eaf5749f36 Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Tue, 25 Mar 2025 00:56:23 +0100 Subject: [PATCH 01/10] POC: render pydantic schema as module in Python --- panel/src/panel/configuration/to_module.py | 53 ++++++++++++++++++++++ panel/src/panel/tests/test_to_module.py | 44 ++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 panel/src/panel/configuration/to_module.py create mode 100644 panel/src/panel/tests/test_to_module.py diff --git a/panel/src/panel/configuration/to_module.py b/panel/src/panel/configuration/to_module.py new file mode 100644 index 00000000..36e3c131 --- /dev/null +++ b/panel/src/panel/configuration/to_module.py @@ -0,0 +1,53 @@ +import json +import textwrap + +# TODO(@fricklerhandwerk): mix this in as a method on the Pydantic Schema itself +def generate_module(schema, service_name): + properties = schema.get("properties", {}) + required = schema.get("required", []) + + options = [] + for name, prop in properties.items(): + nix_type = { + "string": "types.str", + "integer": "types.int", + "boolean": "types.bool" + }[prop["type"]] + + desc = prop.get("description") + + default = None + if name not in required and "default" in prop: + to_nix_value = { + "string": lambda v: f'"{v}"', + "boolean": lambda v: str(v).lower(), + "integer": lambda v: str(v), + "number": lambda v: str(v) + } + default = to_nix_value[prop["type"]](prop["default"]) + + option = textwrap.dedent(f""" + {name} = mkOption {{{f''' + description = "{desc}";''' if desc else ''} + type = {nix_type};{f''' + default = {default};''' if default is not None else ''} + }}; + """) + + options.append(option.strip()) + + module = textwrap.dedent(f""" + {{ lib, ... }}: + + let + cfg = config.services.{service_name}; + inherit (lib) mkOption types; + in + {{ + options.services.{service_name} = {{ + {textwrap.indent("\n\n".join(options), ' ').strip()} + }}; + }} + """) + + return module diff --git a/panel/src/panel/tests/test_to_module.py b/panel/src/panel/tests/test_to_module.py new file mode 100644 index 00000000..f3c2ae13 --- /dev/null +++ b/panel/src/panel/tests/test_to_module.py @@ -0,0 +1,44 @@ +from django.test import TestCase +from pydantic import BaseModel, Field +import textwrap + +from panel.configuration import to_module + + +class ConvertToModule(TestCase): + def test_minimal_module(self): + class Example(BaseModel): + name: str = Field(..., description="Service name") + port: int = Field(8080) + enable: bool = Field(True, description="Enable service") + + expected = textwrap.dedent(""" + { lib, ... }: + + let + cfg = config.services.myservice; + inherit (lib) mkOption types; + in + { + options.services.myservice = { + name = mkOption { + description = "Service name"; + type = types.str; + }; + + port = mkOption { + type = types.int; + default = 8080; + }; + + enable = mkOption { + description = "Enable service"; + type = types.bool; + default = true; + }; + }; + } + """) + + actual = to_module.generate_module(Example.schema(), "myservice") + self.assertEqual(actual, expected) -- 2.48.1 From 9192e29c4219a268a1bb14c1b154f0d7295a7139 Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Tue, 25 Mar 2025 01:30:26 +0100 Subject: [PATCH 02/10] POC: render jsonschema as module in Nix --- panel/nix/jsonschema-to-module.nix | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 panel/nix/jsonschema-to-module.nix diff --git a/panel/nix/jsonschema-to-module.nix b/panel/nix/jsonschema-to-module.nix new file mode 100644 index 00000000..4eb18ed9 --- /dev/null +++ b/panel/nix/jsonschema-to-module.nix @@ -0,0 +1,81 @@ +{ lib, ... }: + +let + inherit (lib) + fromJSON + mapAttrs + attrValues + concatStringsSep + elem + ; + + option = + required: name: prop: + let + description = + if prop ? description then + '' + description = "${prop.description}"; + '' + else + ""; + default = + if (!elem name required && prop ? default) then + '' + default = ${to-value prop.type prop.description}; + '' + else + ""; + + to-type = + type: + { + string = "str"; + integer = "int"; + boolean = "bool"; + } + .${type} or (throw "Unsupported schema type: ${type}"); + + to-value = + type: value: + { + string = ''"${toString value}"''; + integer = toString value; + boolean = if value then "true" else "false"; + } + .${type} or (throw "Unsupported value type: ${type}"); + in + # TODO: squash + '' + ${name} = mkOption { + ${ + # TODO: indent + description + } + type = types.${to-type prop.type}; + ${ + # TODO: indent + default + } + }; + ''; +in +jsonschema: name: # TODO: can't we get the name from the schema? +let + schema = fromJSON jsonschema; + properties = schema.properties or { }; + required = schema.required or [ ]; + options = concatStringsSep "\n\n" (attrValues (mapAttrs (option required) properties)); +in +# TODO: test this +'' + { lib, ... }: + let + inherit (lib) mkOption types; + in + { + options.services.${name} = { + ${options} + }; + } +'' -- 2.48.1 From d4a5b997622f0a4620732df84ad1487bdb099cc2 Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Mon, 31 Mar 2025 09:41:13 +0200 Subject: [PATCH 03/10] POC: generate Pydantic models from NixOS modules --- deployment/default.nix | 15 +- deployment/options.nix | 54 +++ npins/sources.json | 15 +- panel/.gitignore | 1 + panel/default.nix | 17 + panel/jsonschema.nix | 430 ++++++++++++++++++ panel/nix/package.nix | 1 + .../python-packages/drf-pydantic/default.nix | 40 ++ 8 files changed, 564 insertions(+), 9 deletions(-) create mode 100644 deployment/options.nix create mode 100644 panel/jsonschema.nix create mode 100644 panel/nix/python-packages/drf-pydantic/default.nix diff --git a/deployment/default.nix b/deployment/default.nix index 89bc89d9..4d8aab01 100644 --- a/deployment/default.nix +++ b/deployment/default.nix @@ -29,22 +29,23 @@ ## From the hosting provider's perspective, the function is meant to be ## partially applied only until here. -## Information on the specific deployment that we request. This is the -## information coming from the FediPanel. -## -## FIXME: lock step the interface with the definitions in the FediPanel -panelConfig: +## Information on the specific deployment that we request. +## This is the information coming from the FediPanel. +config: let inherit (lib) mkIf; - + # TODO(@fricklerhandwerk): misusing this will produce obscure errors that will be truncated by NixOps4 + panelConfig = (lib.evalModules { modules = [ ./options.nix ]; }).config; in ## Regular arguments of a NixOps4 deployment module. { providers, ... }: { - providers = { inherit (nixops4.modules.nixops4Provider) local; }; + providers = { + inherit (nixops4.modules.nixops4Provider) local; + }; resources = let diff --git a/deployment/options.nix b/deployment/options.nix new file mode 100644 index 00000000..1de9265f --- /dev/null +++ b/deployment/options.nix @@ -0,0 +1,54 @@ +/** + Deployment options as to be presented in the front end. + + These are converted to JSON schema in order to generate front-end forms etc. + For this to work, options must not have types `functionTo` or `package` (or `submodule` until [submodule introspection](https://github.com/NixOS/nixpkgs/pull/391544) is merged), and must not access `config` for their default values. +*/ +{ + lib, + ... +}: +let + inherit (lib) types mkOption; +in +{ + options = { + domain = mkOption { + type = + with types; + enum [ + "fediversity.net" + ]; + description = '' + Apex domain under which the services will be deployed. + ''; + }; + pixelfed = { + enable = lib.mkEnableOption "Pixelfed"; + }; + peertube = { + enable = lib.mkEnableOption "Peertube"; + }; + mastodon = { + enable = lib.mkEnableOption "Mastodon"; + }; + initialUser = { + displayName = mkOption { + type = types.str; + description = "Display name of the user"; + }; + username = mkOption { + type = types.str; + description = "Username for login"; + }; + email = mkOption { + type = types.str; + description = "User's email address"; + }; + password = mkOption { + type = types.str; + description = "Password for login"; + }; + }; + }; +} diff --git a/npins/sources.json b/npins/sources.json index 45efd6b4..530e9aca 100644 --- a/npins/sources.json +++ b/npins/sources.json @@ -1,5 +1,16 @@ { "pins": { + "clan-core": { + "type": "Git", + "repository": { + "type": "Git", + "url": "https://git.clan.lol/clan/clan-core" + }, + "branch": "main", + "revision": "dcb2231332bb4ebc91663914a3bb05ffb875b6d9", + "url": null, + "hash": "15zvwbzkbm0m2zar3vf698sqk7s84vvprjkfl9hy43jk911qsdgh" + }, "htmx": { "type": "GitRelease", "repository": { @@ -30,8 +41,8 @@ "nixpkgs": { "type": "Channel", "name": "nixpkgs-unstable", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre711046.8edf06bea5bc/nixexprs.tar.xz", - "hash": "1mwsn0rvfm603svrq3pca4c51zlix5gkyr4gl6pxhhq3q6xs5s8y" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre776128.eb0e0f21f15c/nixexprs.tar.xz", + "hash": "0l04lkdi3slwwlgwyr8x0argzxcxm16a4hkijfxbjhlj44y1bkif" } }, "version": 3 diff --git a/panel/.gitignore b/panel/.gitignore index 5ab365e7..50b110e3 100644 --- a/panel/.gitignore +++ b/panel/.gitignore @@ -1,6 +1,7 @@ # Nix .direnv result* +src/panel/configuration/schema.py # Python *.pyc diff --git a/panel/default.nix b/panel/default.nix index 767802be..74d813dc 100644 --- a/panel/default.nix +++ b/panel/default.nix @@ -12,12 +12,27 @@ let manage = pkgs.writeScriptBin "manage" '' exec ${pkgs.lib.getExe pkgs.python3} ${toString ./src/manage.py} $@ ''; + jsonschema = pkgs.callPackage ./jsonschema.nix { } { + includeDefaults = false; + }; + frontend-options = jsonschema.parseModule ../deployment/options.nix; + schema = with builtins; toFile "schema.json" (toJSON frontend-options); + codegen = "${pkgs.python3Packages.datamodel-code-generator}/bin/datamodel-codegen"; + pydantic = + pkgs.runCommand "schema.py" + { + } + '' + ${codegen} --input ${schema} | sed 's/from pydantic/from drf_pydantic/' > $out + ''; in { + inherit frontend-options; shell = pkgs.mkShellNoCC { inputsFrom = [ (pkgs.callPackage ./nix/package.nix { }) ]; packages = [ pkgs.npins + pkgs.jq manage ]; env = import ./env.nix { inherit lib pkgs; } // { @@ -26,6 +41,8 @@ in DATABASE_URL = "sqlite:///${toString ./src}/db.sqlite3"; }; shellHook = '' + cp -f ${pydantic} ${builtins.toString ./src/panel/configuration/schema.py} + ln -sf ${sources.htmx}/dist/htmx.js src/panel/static/htmx.min.js # in production, secrets are passed via CREDENTIALS_DIRECTORY by systemd. # use this directory for testing with local secrets diff --git a/panel/jsonschema.nix b/panel/jsonschema.nix new file mode 100644 index 00000000..ca833827 --- /dev/null +++ b/panel/jsonschema.nix @@ -0,0 +1,430 @@ +# TODO(@fricklerhandwerk): +# temporarily copied from +# https://git.clan.lol/clan/clan-core/src/branch/main/lib/jsonschema/default.nix +# to work around a bug in JSON Schema generation; this needs a PR to upstream +{ + lib, +}: +{ + excludedTypes ? [ + "functionTo" + "package" + ], + includeDefaults ? true, + header ? { + "$schema" = "http://json-schema.org/draft-07/schema#"; + }, + specialArgs ? { }, +}: +let + # remove _module attribute from options + clean = opts: builtins.removeAttrs opts [ "_module" ]; + + # throw error if option type is not supported + notSupported = + option: + lib.trace option throw '' + option type '${option.type.name}' ('${option.type.description}') not supported by jsonschema converter + location: ${lib.concatStringsSep "." option.loc} + ''; + + # Exclude the option if its type is in the excludedTypes list + # or if the option has a defaultText attribute + isExcludedOption = option: (lib.elem (option.type.name or null) excludedTypes); + + filterExcluded = lib.filter (opt: !isExcludedOption opt); + + excludedOptionNames = [ "_freeformOptions" ]; + filterExcludedAttrs = lib.filterAttrs ( + name: opt: !isExcludedOption opt && !builtins.elem name excludedOptionNames + ); + + # Filter out options where the visible attribute is set to false + filterInvisibleOpts = lib.filterAttrs (_name: opt: opt.visible or true); + + # Constant: Used for the 'any' type + allBasicTypes = [ + "boolean" + "integer" + "number" + "string" + "array" + "object" + "null" + ]; +in +rec { + # parses a nixos module to a jsonschema + parseModule = + module: + let + evaled = lib.evalModules { + modules = [ module ]; + inherit specialArgs; + }; + in + parseOptions evaled.options { }; + + # get default value from option + + # Returns '{ default = Value; }' + # - '{}' if no default is present. + # - Value is "" (string literal) if the option has a defaultText attribute. This means we cannot evaluate default safely + getDefaultFrom = + opt: + if !includeDefaults then + { } + else if opt ? defaultText then + { + # dont add default to jsonschema. It seems to alter the type + # default = ""; + } + else + lib.optionalAttrs (opt ? default) { + default = opt.default; + }; + + parseSubOptions = + { + option, + prefix ? [ ], + }: + let + subOptions = option.type.getSubOptions option.loc; + in + parseOptions subOptions { + addHeader = false; + path = option.loc ++ prefix; + }; + + makeModuleInfo = + { + path, + defaultText ? null, + }: + { + "$exportedModuleInfo" = + { + inherit path; + } + // lib.optionalAttrs (defaultText != null) { + inherit defaultText; + }; + }; + + # parses a set of evaluated nixos options to a jsonschema + parseOptions = + options: + { + # The top-level header object should specify at least the schema version + # Can be customized if needed + # By default the header is not added to the schema + addHeader ? true, + path ? [ ], + }: + let + options' = filterInvisibleOpts (filterExcludedAttrs (clean options)); + # parse options to jsonschema properties + properties = lib.mapAttrs (_name: option: (parseOption' (path ++ [ _name ]) option)) options'; + # TODO: figure out how to handle if prop.anyOf is used + isRequired = prop: !(prop ? default || prop.type or null == "object"); + requiredProps = lib.filterAttrs (_: prop: isRequired prop) properties; + required = lib.optionalAttrs (requiredProps != { }) { required = lib.attrNames requiredProps; }; + header' = if addHeader then header else { }; + + # freeformType is a special type + freeformDefs = options._module.freeformType.definitions or [ ]; + checkFreeformDefs = + defs: + if (builtins.length defs) != 1 then + throw "parseOptions: freeformType definitions not supported" + else + defs; + # It seems that freeformType has [ null ] + freeformProperties = + if freeformDefs != [ ] && builtins.head freeformDefs != null then + # freeformType has only one definition + parseOption { + # options._module.freeformType.definitions + type = builtins.head (checkFreeformDefs freeformDefs); + _type = "option"; + loc = path; + } + else + { }; + + # Metadata about the module that is made available to the schema via '$propagatedModuleInfo' + exportedModuleInfo = makeModuleInfo { + inherit path; + }; + in + # return jsonschema + header' + // exportedModuleInfo + // required + // { + type = "object"; + inherit properties; + additionalProperties = false; + } + // freeformProperties; + + # parses and evaluated nixos option to a jsonschema property definition + parseOption = parseOption' [ ]; + parseOption' = + currentPath: option: + let + default = getDefaultFrom option; + example = lib.optionalAttrs (option ? example) { + examples = + if (builtins.typeOf option.example) == "list" then option.example else [ option.example ]; + }; + description = lib.optionalAttrs (option ? description) { + description = option.description.text or option.description; + }; + exposedModuleInfo = lib.optionalAttrs true (makeModuleInfo { + path = option.loc; + defaultText = option.defaultText or null; + }); + in + # either type + # TODO: if all nested options are excluded, the parent should be excluded too + if + option.type.name or null == "either" || option.type.name or null == "coercedTo" + # return jsonschema property definition for either + then + let + optionsList' = [ + { + type = option.type.nestedTypes.left or option.type.nestedTypes.coercedType; + _type = "option"; + loc = option.loc; + } + { + type = option.type.nestedTypes.right or option.type.nestedTypes.finalType; + _type = "option"; + loc = option.loc; + } + ]; + optionsList = filterExcluded optionsList'; + in + exposedModuleInfo // default // example // description // { oneOf = map parseOption optionsList; } + # handle nested options (not a submodule) + # foo.bar = mkOption { type = str; }; + else if !option ? _type then + (parseOptions option { + addHeader = false; + path = currentPath; + }) + # throw if not an option + else if option._type != "option" && option._type != "option-type" then + throw "parseOption: not an option" + # parse nullOr + else if + option.type.name == "nullOr" + # return jsonschema property definition for nullOr + then + let + nestedOption = { + type = option.type.nestedTypes.elemType; + _type = "option"; + loc = option.loc; + }; + in + default + // exposedModuleInfo + // example + // description + // { + oneOf = [ + { type = "null"; } + ] ++ (lib.optional (!isExcludedOption nestedOption) (parseOption nestedOption)); + } + # parse bool + else if + option.type.name == "bool" + # return jsonschema property definition for bool + then + exposedModuleInfo // default // example // description // { type = "boolean"; } + # parse float + else if + option.type.name == "float" + # return jsonschema property definition for float + then + exposedModuleInfo // default // example // description // { type = "number"; } + # parse int + else if + (option.type.name == "int" || option.type.name == "positiveInt") + # return jsonschema property definition for int + then + exposedModuleInfo // default // example // description // { type = "integer"; } + # TODO: Add support for intMatching in jsonschema + # parse port type aka. "unsignedInt16" + else if + option.type.name == "unsignedInt16" + || option.type.name == "unsignedInt" + || option.type.name == "pkcs11" + || option.type.name == "intBetween" + then + exposedModuleInfo // default // example // description // { type = "integer"; } + # parse string + # TODO: parse more precise string types + else if + option.type.name == "str" + || option.type.name == "singleLineStr" + || option.type.name == "passwdEntry str" + || option.type.name == "passwdEntry path" + # return jsonschema property definition for string + then + exposedModuleInfo // default // example // description // { type = "string"; } + # TODO: Add support for stringMatching in jsonschema + # parse stringMatching + else if lib.strings.hasPrefix "strMatching" option.type.name then + exposedModuleInfo // default // example // description // { type = "string"; } + # TODO: Add support for separatedString in jsonschema + else if lib.strings.hasPrefix "separatedString" option.type.name then + exposedModuleInfo // default // example // description // { type = "string"; } + # parse string + else if + option.type.name == "path" + # return jsonschema property definition for path + then + exposedModuleInfo // default // example // description // { type = "string"; } + # parse anything + else if + option.type.name == "anything" + # return jsonschema property definition for anything + then + exposedModuleInfo // default // example // description // { type = allBasicTypes; } + # parse unspecified + else if + option.type.name == "unspecified" + # return jsonschema property definition for unspecified + then + exposedModuleInfo // default // example // description // { type = allBasicTypes; } + # parse raw + else if + option.type.name == "raw" + # return jsonschema property definition for raw + then + exposedModuleInfo // default // example // description // { type = allBasicTypes; } + # parse enum + else if + option.type.name == "enum" + # return jsonschema property definition for enum + then + exposedModuleInfo + // default + // example + // description + // { + enum = option.type.functor.payload.values; + } + # parse listOf submodule + else if + option.type.name == "listOf" && option.type.nestedTypes.elemType.name == "submodule" + # return jsonschema property definition for listOf submodule + then + default + // exposedModuleInfo + // example + // description + // { + type = "array"; + items = parseSubOptions { inherit option; }; + } + # parse list + else if + (option.type.name == "listOf") + # return jsonschema property definition for list + then + let + nestedOption = { + type = option.type.nestedTypes.elemType; + _type = "option"; + loc = option.loc; + }; + in + default + // exposedModuleInfo + // example + // description + // { + type = "array"; + } + // (lib.optionalAttrs (!isExcludedOption nestedOption) { items = parseOption nestedOption; }) + # parse list of unspecified + else if + (option.type.name == "listOf") && (option.type.nestedTypes.elemType.name == "unspecified") + # return jsonschema property definition for list + then + exposedModuleInfo // default // example // description // { type = "array"; } + # parse attrsOf submodule + else if + option.type.name == "attrsOf" && option.type.nestedTypes.elemType.name == "submodule" + # return jsonschema property definition for attrsOf submodule + then + default + // exposedModuleInfo + // example + // description + // { + type = "object"; + additionalProperties = parseSubOptions { + inherit option; + prefix = [ "" ]; + }; + } + # parse attrs + else if + option.type.name == "attrs" + # return jsonschema property definition for attrs + then + default + // exposedModuleInfo + // example + // description + // { + type = "object"; + additionalProperties = true; + } + # parse attrsOf + # TODO: if nested option is excluded, the parent should be excluded too + else if + option.type.name == "attrsOf" || option.type.name == "lazyAttrsOf" + # return jsonschema property definition for attrs + then + let + nestedOption = { + type = option.type.nestedTypes.elemType; + _type = "option"; + loc = option.loc; + }; + in + default + // exposedModuleInfo + // example + // description + // { + type = "object"; + additionalProperties = + if !isExcludedOption nestedOption then + parseOption { + type = option.type.nestedTypes.elemType; + _type = "option"; + loc = option.loc; + } + else + false; + } + # parse submodule + else if + option.type.name == "submodule" + # return jsonschema property definition for submodule + # then (lib.attrNames (option.type.getSubOptions option.loc).opt) + then + exposedModuleInfo // example // description // parseSubOptions { inherit option; } + # throw error if option type is not supported + else + notSupported option; +} diff --git a/panel/nix/package.nix b/panel/nix/package.nix index 9337887c..9741b1a9 100644 --- a/panel/nix/package.nix +++ b/panel/nix/package.nix @@ -45,6 +45,7 @@ python3.pkgs.buildPythonPackage { django-debug-toolbar django-libsass django-pydantic-field + drf-pydantic django_4 setuptools ]; diff --git a/panel/nix/python-packages/drf-pydantic/default.nix b/panel/nix/python-packages/drf-pydantic/default.nix new file mode 100644 index 00000000..2fb017a6 --- /dev/null +++ b/panel/nix/python-packages/drf-pydantic/default.nix @@ -0,0 +1,40 @@ +{ + lib, + buildPythonPackage, + fetchFromGitHub, + setuptools, + django, + pydantic, + hatchling, + djangorestframework, +}: + +buildPythonPackage rec { + pname = "drf-pydantic"; + version = "v2.7.1"; + pyproject = true; + + src = fetchFromGitHub { + owner = "georgebv"; + repo = pname; + rev = version; + hash = "sha256-ABtSoxj/+HHq4hj4Yb6bEiyOl00TCO/9tvBzhv6afxM="; + }; + + nativeBuildInputs = [ + setuptools + hatchling + ]; + + propagatedBuildInputs = [ + django + pydantic + djangorestframework + ]; + + meta = with lib; { + description = ""; + homepage = "https://github.com/${src.owner}/${pname}"; + license = licenses.mit; + }; +} -- 2.48.1 From 066671d8608f48bfbf99f3148fa2c23a3c54a23a Mon Sep 17 00:00:00 2001 From: Valentin Gagarin Date: Mon, 31 Mar 2025 14:02:59 +0200 Subject: [PATCH 04/10] WIP: unbreak --- panel/default.nix | 3 ++- panel/src/panel/settings.py | 1 + .../panel/templates/configuration_form.html | 3 ++- panel/src/panel/views.py | 19 ++++++++++++++----- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/panel/default.nix b/panel/default.nix index 74d813dc..724404ef 100644 --- a/panel/default.nix +++ b/panel/default.nix @@ -23,7 +23,8 @@ let { } '' - ${codegen} --input ${schema} | sed 's/from pydantic/from drf_pydantic/' > $out + ${codegen} --input ${schema} | sed '/from pydantic/a\ + from drf_pydantic import BaseModel' > $out ''; in { diff --git a/panel/src/panel/settings.py b/panel/src/panel/settings.py index b270612c..2d6084d2 100644 --- a/panel/src/panel/settings.py +++ b/panel/src/panel/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_pydantic_field', + 'rest_framework', 'debug_toolbar', 'compressor', ] diff --git a/panel/src/panel/templates/configuration_form.html b/panel/src/panel/templates/configuration_form.html index 4795d35d..8df2acd4 100644 --- a/panel/src/panel/templates/configuration_form.html +++ b/panel/src/panel/templates/configuration_form.html @@ -1,9 +1,10 @@ {% extends "base.html" %} +{% load rest_framework %} {% block content %}
{% csrf_token %} - {{ form.as_p }} + {% render_form serializer %}