diff --git a/deployment/default.nix b/deployment/default.nix index 89bc89d9..baed4ce2 100644 --- a/deployment/default.nix +++ b/deployment/default.nix @@ -29,153 +29,177 @@ ## 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. +deployment-configuration: let inherit (lib) mkIf; - in ## Regular arguments of a NixOps4 deployment module. -{ providers, ... }: - +{ config, providers, ... }: +let + panelConfig = config.deployment; +in { - providers = { inherit (nixops4.modules.nixops4Provider) local; }; - - resources = - let - ## NOTE: All of these secrets are publicly available in this source file - ## and will end up in the Nix store. We don't care as they are only ever - ## used for testing anyway. - ## - ## FIXME: Generate and store in NixOps4's state. - mastodonS3KeyConfig = - { pkgs, ... }: - { - s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK3515373e4c851ebaad366558"; - s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34"; - }; - peertubeS3KeyConfig = - { pkgs, ... }: - { - s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK1f9feea9960f6f95ff404c9b"; - s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7295c4201966a02c2c3d25b5cea4a5ff782966a2415e3a196f91924631191395"; - }; - pixelfedS3KeyConfig = - { pkgs, ... }: - { - s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GKb5615457d44214411e673b7b"; - s3SecretKeyFile = pkgs.writeText "s3SecretKey" "5be6799a88ca9b9d813d1a806b64f15efa49482dbe15339ddfaf7f19cf434987"; - }; - - makeConfigurationResource = resourceModule: config: { - type = providers.local.exec; - imports = [ - nixops4-nixos.modules.nixops4Resource.nixos - resourceModule - - { - ## NOTE: With NixOps4, there are several levels and all of them live - ## in the NixOS module system: - ## - ## 1. Each NixOps4 deployment is a module. - ## 2. Each NixOps4 resource is a module. This very comment is - ## inside an attrset imported as a module in a resource. - ## 3. Each NixOps4 'configuration' resource contains an attribute - ## 'nixos.module', itself a NixOS configuration module. - nixos.module = - { ... }: - { - imports = [ - config - fediversity - ]; - }; - } - ]; - }; - - in - - { - garage-configuration = makeConfigurationResource garageConfigurationResource ( - { pkgs, ... }: - mkIf (panelConfig.mastodon.enable || panelConfig.peertube.enable || panelConfig.pixelfed.enable) { - fediversity = { - inherit (panelConfig) domain; - garage.enable = true; - pixelfed = pixelfedS3KeyConfig { inherit pkgs; }; - mastodon = mastodonS3KeyConfig { inherit pkgs; }; - peertube = peertubeS3KeyConfig { inherit pkgs; }; - }; - } - ); - - mastodon-configuration = makeConfigurationResource mastodonConfigurationResource ( - { pkgs, ... }: - mkIf panelConfig.mastodon.enable { - fediversity = { - inherit (panelConfig) domain; - temp.initialUser = { - inherit (panelConfig.initialUser) username email displayName; - # FIXME: disgusting, but nvm, this is going to be replaced by - # proper central authentication at some point - passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; - }; - - mastodon = mastodonS3KeyConfig { inherit pkgs; } // { - enable = true; - }; - - temp.cores = 1; # FIXME: should come from NixOps4 eventually - }; - } - ); - - peertube-configuration = makeConfigurationResource peertubeConfigurationResource ( - { pkgs, ... }: - mkIf panelConfig.peertube.enable { - fediversity = { - inherit (panelConfig) domain; - temp.initialUser = { - inherit (panelConfig.initialUser) username email displayName; - # FIXME: disgusting, but nvm, this is going to be replaced by - # proper central authentication at some point - passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; - }; - - peertube = peertubeS3KeyConfig { inherit pkgs; } // { - enable = true; - ## NOTE: Only ever used for testing anyway. - ## - ## FIXME: Generate and store in NixOps4's state. - secretsFile = pkgs.writeText "secret" "574e093907d1157ac0f8e760a6deb1035402003af5763135bae9cbd6abe32b24"; - }; - }; - } - ); - - pixelfed-configuration = makeConfigurationResource pixelfedConfigurationResource ( - { pkgs, ... }: - mkIf panelConfig.pixelfed.enable { - fediversity = { - inherit (panelConfig) domain; - temp.initialUser = { - inherit (panelConfig.initialUser) username email displayName; - # FIXME: disgusting, but nvm, this is going to be replaced by - # proper central authentication at some point - passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; - }; - - pixelfed = pixelfedS3KeyConfig { inherit pkgs; } // { - enable = true; - }; - }; - } - ); + options = { + deployment = lib.mkOption { + description = '' + Configuration to be deployed + ''; + # XXX(@fricklerhandwerk): + # misusing this will produce obscure errors that will be truncated by NixOps4 + type = lib.types.submodule ./options.nix; }; + }; + + config = { + deployment = deployment-configuration; + #// { + # TODO(@fricklerhandwerk): + # the Clan JSON schema converter always marks "object" types as optional, + # which means we need to deal with that *somewhere*, and as a hack it's done here. + # right now, since initial form contents are produced from Pydantic default values, we get None for those nested objects, which translates to `null` here. + #mastodon = optionalAttrs (isNull deployment-configuration.mastodon) { }; + #peertube = optionalAttrs (isNull deployment-configuration.peertube) { }; + #pixelfed = optionalAttrs (isNull deployment-configuration.pixelfed) { }; + #}; + providers = { + inherit (nixops4.modules.nixops4Provider) local; + }; + + resources = + let + ## NOTE: All of these secrets are publicly available in this source file + ## and will end up in the Nix store. We don't care as they are only ever + ## used for testing anyway. + ## + ## FIXME: Generate and store in NixOps4's state. + mastodonS3KeyConfig = + { pkgs, ... }: + { + s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK3515373e4c851ebaad366558"; + s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7d37d093435a41f2aab8f13c19ba067d9776c90215f56614adad6ece597dbb34"; + }; + peertubeS3KeyConfig = + { pkgs, ... }: + { + s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GK1f9feea9960f6f95ff404c9b"; + s3SecretKeyFile = pkgs.writeText "s3SecretKey" "7295c4201966a02c2c3d25b5cea4a5ff782966a2415e3a196f91924631191395"; + }; + pixelfedS3KeyConfig = + { pkgs, ... }: + { + s3AccessKeyFile = pkgs.writeText "s3AccessKey" "GKb5615457d44214411e673b7b"; + s3SecretKeyFile = pkgs.writeText "s3SecretKey" "5be6799a88ca9b9d813d1a806b64f15efa49482dbe15339ddfaf7f19cf434987"; + }; + + makeConfigurationResource = resourceModule: deployment-configuration: { + type = providers.local.exec; + imports = [ + nixops4-nixos.modules.nixops4Resource.nixos + resourceModule + + { + ## NOTE: With NixOps4, there are several levels and all of them live + ## in the NixOS module system: + ## + ## 1. Each NixOps4 deployment is a module. + ## 2. Each NixOps4 resource is a module. This very comment is + ## inside an attrset imported as a module in a resource. + ## 3. Each NixOps4 'configuration' resource contains an attribute + ## 'nixos.module', itself a NixOS configuration module. + nixos.module = + { ... }: + { + imports = [ + deployment-configuration + fediversity + ]; + }; + } + ]; + }; + + in + + { + garage-configuration = makeConfigurationResource garageConfigurationResource ( + { pkgs, ... }: + mkIf (panelConfig.mastodon.enable || panelConfig.peertube.enable || panelConfig.pixelfed.enable) { + fediversity = { + inherit (panelConfig) domain; + garage.enable = true; + pixelfed = pixelfedS3KeyConfig { inherit pkgs; }; + mastodon = mastodonS3KeyConfig { inherit pkgs; }; + peertube = peertubeS3KeyConfig { inherit pkgs; }; + }; + } + ); + + mastodon-configuration = makeConfigurationResource mastodonConfigurationResource ( + { pkgs, ... }: + mkIf panelConfig.mastodon.enable { + fediversity = { + inherit (panelConfig) domain; + temp.initialUser = { + inherit (panelConfig.initialUser) username email displayName; + # FIXME: disgusting, but nvm, this is going to be replaced by + # proper central authentication at some point + passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; + }; + + mastodon = mastodonS3KeyConfig { inherit pkgs; } // { + enable = true; + }; + + temp.cores = 1; # FIXME: should come from NixOps4 eventually + }; + } + ); + + peertube-configuration = makeConfigurationResource peertubeConfigurationResource ( + { pkgs, ... }: + mkIf panelConfig.peertube.enable { + fediversity = { + inherit (panelConfig) domain; + temp.initialUser = { + inherit (panelConfig.initialUser) username email displayName; + # FIXME: disgusting, but nvm, this is going to be replaced by + # proper central authentication at some point + passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; + }; + + peertube = peertubeS3KeyConfig { inherit pkgs; } // { + enable = true; + ## NOTE: Only ever used for testing anyway. + ## + ## FIXME: Generate and store in NixOps4's state. + secretsFile = pkgs.writeText "secret" "574e093907d1157ac0f8e760a6deb1035402003af5763135bae9cbd6abe32b24"; + }; + }; + } + ); + + pixelfed-configuration = makeConfigurationResource pixelfedConfigurationResource ( + { pkgs, ... }: + mkIf panelConfig.pixelfed.enable { + fediversity = { + inherit (panelConfig) domain; + temp.initialUser = { + inherit (panelConfig.initialUser) username email displayName; + # FIXME: disgusting, but nvm, this is going to be replaced by + # proper central authentication at some point + passwordFile = pkgs.writeText "password" panelConfig.initialUser.password; + }; + + pixelfed = pixelfedS3KeyConfig { inherit pkgs; } // { + enable = true; + }; + }; + } + ); + }; + }; } diff --git a/deployment/options.nix b/deployment/options.nix new file mode 100644 index 00000000..27d38bce --- /dev/null +++ b/deployment/options.nix @@ -0,0 +1,64 @@ +/** + 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`, and must not access `config` for their default values. +*/ +{ + lib, + ... +}: +let + inherit (lib) types mkOption; +in +{ + options = { + enable = lib.mkEnableOption "Fediversity configuration"; + domain = mkOption { + type = + with types; + enum [ + "fediversity.net" + ]; + description = '' + Apex domain under which the services will be deployed. + ''; + default = "fediversity.net"; + }; + pixelfed = { + enable = lib.mkEnableOption "Pixelfed"; + }; + peertube = { + enable = lib.mkEnableOption "Peertube"; + }; + mastodon = { + enable = lib.mkEnableOption "Mastodon"; + }; + initialUser = mkOption { + description = '' + Some services require an initial user to access them. + This option sets the credentials for such an initial user. + ''; + type = types.submodule { + options = { + 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..41655c61 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 { } { + }; + 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 '/from pydantic/a\ + from drf_pydantic import BaseModel' > $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 = '' + install -m 644 ${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..b4dd8665 --- /dev/null +++ b/panel/jsonschema.nix @@ -0,0 +1,434 @@ +# 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."$exportedModuleInfo".required or false); + 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 + // (lib.reqursiveUpdate exposedModuleInfo { + "$exportedModuleInfo" = { + required = true; + }; + }) + // 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; + }; +} diff --git a/panel/src/panel/models.py b/panel/src/panel/models.py index 67426cb1..4652e183 100644 --- a/panel/src/panel/models.py +++ b/panel/src/panel/models.py @@ -1,13 +1,13 @@ from django.db import models from django.contrib.auth.models import User -from panel.configuration import forms +from panel.configuration import schema from pydantic import BaseModel def get_default_config(): - return forms.Configuration().model_dump_json() + return schema.Model().model_dump_json() class Configuration(models.Model): @@ -26,4 +26,4 @@ class Configuration(models.Model): @property def parsed_value(self) -> BaseModel: - return forms.Configuration.model_validate_json(self.value) + return schema.Model.model_validate_json(self.value) 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..ac4e68c7 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 %}