Crafted with in Zurich - {% load version_tags %} - - {% get_version_or_env %}
diff --git a/.env.example b/.env.example
index 63df700..998150c 100644
--- a/.env.example
+++ b/.env.example
@@ -4,10 +4,6 @@
# When the environment is "development", DEBUG is set to True.
SERVALA_ENVIRONMENT='development'
-# Set to "False" to disable the beta testing banner at the top of every page.
-# Defaults to "True".
-SERVALA_SHOW_BETA_BANNER='True'
-
# Set SERVALA_PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions and to remain able to read encrypted fields!
# In order to retire the previous key, run the ``reencrypt_fields`` command. Once you drop the previous secret key from
# the rotation, all sessions that still rely on that key will be invalidated (i.e., users will have to log in again).
diff --git a/.forgejo/workflows/build-deploy-prod.yaml b/.forgejo/workflows/build-deploy-prod.yaml
index ddaeb1c..53d1e32 100644
--- a/.forgejo/workflows/build-deploy-prod.yaml
+++ b/.forgejo/workflows/build-deploy-prod.yaml
@@ -12,9 +12,6 @@ on:
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
- release:
- types:
- - published
jobs:
build:
@@ -26,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -72,7 +69,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Determine image tag
id: determine-tag
diff --git a/.forgejo/workflows/build-deploy-staging.yaml b/.forgejo/workflows/build-deploy-staging.yaml
index 8f438cd..93c77b2 100644
--- a/.forgejo/workflows/build-deploy-staging.yaml
+++ b/.forgejo/workflows/build-deploy-staging.yaml
@@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19
diff --git a/.forgejo/workflows/docs.yaml b/.forgejo/workflows/docs.yaml
index b1e5fe5..0b6c77c 100644
--- a/.forgejo/workflows/docs.yaml
+++ b/.forgejo/workflows/docs.yaml
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -49,7 +49,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19
diff --git a/.forgejo/workflows/renovate.yaml b/.forgejo/workflows/renovate.yaml
index a2577d9..97f1c2b 100644
--- a/.forgejo/workflows/renovate.yaml
+++ b/.forgejo/workflows/renovate.yaml
@@ -11,15 +11,15 @@ jobs:
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v5
with:
- node-version: "24"
+ node-version: "22"
- name: Renovate
- uses: https://github.com/renovatebot/github-action@v44.0.5
+ uses: https://github.com/renovatebot/github-action@v43.0.14
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:
diff --git a/.forgejo/workflows/tests.yaml b/.forgejo/workflows/tests.yaml
index b2cddd0..767e526 100644
--- a/.forgejo/workflows/tests.yaml
+++ b/.forgejo/workflows/tests.yaml
@@ -18,15 +18,15 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v5
with:
- node-version: "24"
+ node-version: "22"
- name: Install uv
- uses: https://github.com/astral-sh/setup-uv@v7
+ uses: https://github.com/astral-sh/setup-uv@v6
- name: Run tests
run: uv run --env-file=.env.example pytest
diff --git a/.python-version b/.python-version
index 6324d40..24ee5b1 100644
--- a/.python-version
+++ b/.python-version
@@ -1 +1 @@
-3.14
+3.13
diff --git a/Dockerfile b/Dockerfile
index b1af0a7..5727f03 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.14-slim
+FROM python:3.13-slim
EXPOSE 8000
WORKDIR /app
diff --git a/README.md b/README.md
index eaa1cdd..536d69f 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
The Servala Self-Service Portal
-Latest release: 2025.11.17-0
+Latest release: 2025.10.03-0
## Documentation
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index d133fa2..cff0b83 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -5,7 +5,6 @@
** xref:web-portal-admin.adoc[Admin]
** xref:web-portal-controlplanes.adoc[Control-Planes]
** xref:web-portal-billingentity.adoc[Billing Entities]
-** xref:web-portal-changelog.adoc[Changelog]
* xref:web-portal-planning.adoc[]
** xref:user-stories.adoc[]
@@ -17,4 +16,4 @@
** xref:api.adoc[]
* Cloud Providers
-** xref:exoscale-osb.adoc[]
+** xref:exoscale-osb.adoc[]
\ No newline at end of file
diff --git a/docs/modules/ROOT/pages/web-portal-changelog.adoc b/docs/modules/ROOT/pages/web-portal-changelog.adoc
deleted file mode 100644
index 0c5de9c..0000000
--- a/docs/modules/ROOT/pages/web-portal-changelog.adoc
+++ /dev/null
@@ -1,97 +0,0 @@
-= Portal Changelog
-
-== 2025.11.17-0
-
-=== API
-* Exoscale offboarding MVP (link:https://servala.app.codey.ch/servala/servala-portal/pulls/282[#282])
-
-=== UI/UX
-* Allow admins to disable the expert mode form (link:https://servala.app.codey.ch/servala/servala-portal/pulls/296[#296])
-* Support single (non-array) FQDN values (link:https://servala.app.codey.ch/servala/servala-portal/pulls/295[#295])
-* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
-* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
-* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
-* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
-* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
-* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
-
-=== dependencies
-* Update dependency django-template-partials to >=25.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/297[#297])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/298[#298])
-* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
-* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
-* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
-* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
-* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
-* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
-* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
-* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
-* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
-* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
-* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
-* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
-* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
-* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
-* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
-
-
-== 2025.11.13-0
-
-=== UI/UX
-* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
-* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
-* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
-* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
-* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
-* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
-* Restrict user input to more sensible ranges (link:https://servala.app.codey.ch/servala/servala-portal/pulls/251[#251])
-* Inline user info in service offering page (link:https://servala.app.codey.ch/servala/servala-portal/pulls/250[#250])
-
-=== bug
-* Fix generated FQDN not being submitted (link:https://servala.app.codey.ch/servala/servala-portal/pulls/249[#249])
-
-=== dependencies
-* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
-* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
-* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
-* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
-* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
-* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
-* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
-* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
-* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
-* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
-* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
-* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
-* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
-* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
-* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
-* Update dependency isort to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/252[#252])
-* Update dependency pillow to v12 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/253[#253])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/255[#255])
-* Update https://github.com/astral-sh/setup-uv action to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/254[#254])
-* Update dependency flake8-bugbear to v25 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/248[#248])
-* Update https://github.com/renovatebot/github-action action to v43.0.18 - autoclosed (link:https://servala.app.codey.ch/servala/servala-portal/pulls/239[#239])
-* Update actions/setup-node action to v6 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/247[#247])
-
-
-== 2025.10.27-0
-
-=== UI/UX
-* Restrict user input to more sensible ranges (link:https://servala.app.codey.ch/servala/servala-portal/pulls/251[#251])
-* Inline user info in service offering page (link:https://servala.app.codey.ch/servala/servala-portal/pulls/250[#250])
-
-=== bug
-* Fix generated FQDN not being submitted (link:https://servala.app.codey.ch/servala/servala-portal/pulls/249[#249])
-
-=== dependencies
-* Update dependency isort to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/252[#252])
-* Update dependency pillow to v12 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/253[#253])
-* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/255[#255])
-* Update https://github.com/astral-sh/setup-uv action to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/254[#254])
-* Update dependency flake8-bugbear to v25 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/248[#248])
-* Update https://github.com/renovatebot/github-action action to v43.0.18 - autoclosed (link:https://servala.app.codey.ch/servala/servala-portal/pulls/239[#239])
-* Update actions/setup-node action to v6 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/247[#247])
-
diff --git a/hack/README.md b/hack/README.md
deleted file mode 100644
index 6626efc..0000000
--- a/hack/README.md
+++ /dev/null
@@ -1,116 +0,0 @@
-# Automation Scripts
-
-This directory contains automation scripts for release management and changelog generation.
-
-## Scripts
-
-### bumpver-pre-commit-hook.sh
-
-**Purpose**: Generates a changelog based on merged Pull Requests since the last release.
-
-**What it does**:
-1. Queries the Forgejo API for merged pull requests since the last release tag
-2. Groups pull requests by their labels (first label if multiple labels are present)
-3. Formats the changes in AsciiDoc format with third-level headers for each label group
-4. Appends the changelog to `docs/modules/ROOT/pages/web-portal-changelog.adoc`
-5. Adds the changelog file to git staging area
-6. Saves the changelog content for the post-commit hook
-
-**Note**: Pull requests without labels will be grouped under "Uncategorized".
-
-**Requirements**:
-- `FORGEJO_TOKEN` environment variable must be set with a valid Forgejo API token
-- `jq` command-line JSON processor must be installed
-- `curl` must be installed
-
-### bumpver-post-commit-hook.sh
-
-**Purpose**: Creates a release on Forgejo after a version bump.
-
-**What it does**:
-1. Gets the current version from `pyproject.toml`
-2. Reads the changelog content generated by the pre-commit hook
-3. Converts AsciiDoc format to Markdown (headers and links)
-4. Creates or updates a release on Forgejo with the Markdown-formatted changelog
-5. Cleans up temporary changelog files
-
-**Note**: The script automatically converts AsciiDoc syntax to Markdown for Forgejo releases:
-- `=== Header` → `### Header`
-- `link:url[text]` → `[text](url)`
-
-**Requirements**:
-- `FORGEJO_TOKEN` environment variable must be set with a valid Forgejo API token
-- `jq` command-line JSON processor must be installed
-- `curl` must be installed
-
-## Setup
-
-### 1. Generate a Forgejo API Token
-
-1. Log in to Forgejo at https://servala.app.codey.ch
-2. Go to Settings → Applications → Generate New Token
-3. Give it a descriptive name (e.g., "bumpver-automation")
-4. Select the required permissions:
- - `repo` (Full control of repositories)
-5. Copy the generated token
-
-### 2. Configure the token
-
-Export the token as an environment variable:
-
-```bash
-export FORGEJO_TOKEN="your-token-here"
-```
-
-For permanent setup, add it to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.):
-
-```bash
-echo 'export FORGEJO_TOKEN="your-token-here"' >> ~/.bashrc
-```
-
-### 3. Update pyproject.toml
-
-Update the bumpver configuration in `pyproject.toml` to use these hooks:
-
-```toml
-[tool.bumpver]
-current_version = "2025.10.27-0"
-version_pattern = "YYYY.0M.0D-INC0"
-commit_message = "bump version {old_version} -> {new_version}"
-tag_message = "{new_version}"
-tag_scope = "default"
-pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
-post_commit_hook = "hack/bumpver-post-commit-hook.sh"
-commit = true
-tag = true
-push = true
-```
-
-## Usage
-
-Once configured, the hooks will run automatically when you bump the version:
-
-```bash
-# Or let bumpver determine the version based on the pattern
-uvx bumpver update
-```
-
-The workflow is:
-1. `bumpver` updates version files
-2. **Pre-commit hook** runs: generates changelog, updates changelog file, stages changes
-3. `bumpver` creates commit with version bump and changelog
-4. `bumpver` creates git tag
-5. **Post-commit hook** runs: creates Forgejo release
-6. `bumpver` pushes commit and tags to remote
-
-## Manual execution
-
-You can also run the scripts manually:
-
-```bash
-# Generate changelog (run before committing)
-./hack/pre-commit-hook.sh
-
-# Create release (run after committing and tagging)
-./hack/post-commit-hook.sh
-```
diff --git a/hack/bumpver-post-commit-hook.sh b/hack/bumpver-post-commit-hook.sh
deleted file mode 100755
index fdda006..0000000
--- a/hack/bumpver-post-commit-hook.sh
+++ /dev/null
@@ -1,149 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-# Post-commit hook for bumpver to create a Forgejo release
-# This script creates a release on Forgejo using the changelog generated in the pre-commit hook
-
-# Configuration
-FORGEJO_URL="https://servala.app.codey.ch"
-REPO_OWNER="servala"
-REPO_NAME="servala-portal"
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m' # No Color
-
-# Check for required environment variable
-if [ -z "${FORGEJO_TOKEN:-}" ]; then
- echo -e "${RED}Error: FORGEJO_TOKEN environment variable is not set${NC}"
- echo "Please set FORGEJO_TOKEN to your Forgejo API token"
- exit 1
-fi
-
-# Get the current version from pyproject.toml
-CURRENT_VERSION=$(grep -E 'current_version = ".*"' pyproject.toml | head -1 | sed -E 's/current_version = "(.*)"/\1/')
-echo -e "${GREEN}Creating release for version: ${CURRENT_VERSION}${NC}"
-
-# Get the latest tag (should match CURRENT_VERSION)
-LATEST_TAG=$(git tag -l --sort=-v:refname | head -1)
-if [ "$LATEST_TAG" != "$CURRENT_VERSION" ]; then
- echo -e "${YELLOW}Warning: Latest tag (${LATEST_TAG}) doesn't match current version (${CURRENT_VERSION})${NC}"
- echo -e "${YELLOW}Using current version: ${CURRENT_VERSION}${NC}"
-fi
-
-# Try to read the changelog generated by the pre-commit hook
-CHANGELOG_DIR=".git/changelog"
-CHANGELOG_FILE="${CHANGELOG_DIR}/${CURRENT_VERSION}.txt"
-
-if [ -f "$CHANGELOG_FILE" ]; then
- CHANGELOG_CONTENT=$(cat "$CHANGELOG_FILE")
- echo -e "${GREEN}Found changelog content from pre-commit hook${NC}"
-else
- echo -e "${YELLOW}Warning: Changelog file not found at ${CHANGELOG_FILE}${NC}"
- echo -e "${YELLOW}Generating changelog from tag information${NC}"
-
- # Fallback: use git log to get commits since previous tag
- PREVIOUS_TAG=$(git tag -l --sort=-v:refname | head -2 | tail -1)
- if [ -n "$PREVIOUS_TAG" ]; then
- CHANGELOG_CONTENT=$(git log --pretty=format:"* %s" "${PREVIOUS_TAG}..${LATEST_TAG}")
- else
- CHANGELOG_CONTENT="* Initial release"
- fi
-fi
-
-# Convert AsciiDoc to Markdown
-# 1. Convert === headers to ### (third-level headers)
-# 2. Convert link:url[text] to [text](url)
-CHANGELOG_MARKDOWN=$(echo "$CHANGELOG_CONTENT" | sed -E 's/^=== /### /g' | sed -E 's/link:([^[]+)\[([^]]+)\]/[\2](\1)/g')
-
-# Create release body in Markdown format
-RELEASE_BODY="## Release ${CURRENT_VERSION}
-
-${CHANGELOG_MARKDOWN}
-"
-
-# Check if release already exists
-API_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${CURRENT_VERSION}"
-EXISTING_RELEASE=$(curl -s -H "Authorization: token ${FORGEJO_TOKEN}" "${API_URL}")
-
-# Check if we got a release back (not an error)
-if echo "$EXISTING_RELEASE" | jq -e '.id' > /dev/null 2>&1; then
- echo -e "${YELLOW}Release ${CURRENT_VERSION} already exists${NC}"
- RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id')
- echo -e "${GREEN}Updating existing release (ID: ${RELEASE_ID})${NC}"
-
- # Update the existing release
- UPDATE_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
-
- RESPONSE=$(curl -s -X PATCH \
- -H "Authorization: token ${FORGEJO_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$(jq -n \
- --arg tag "${CURRENT_VERSION}" \
- --arg name "Release ${CURRENT_VERSION}" \
- --arg body "${RELEASE_BODY}" \
- '{
- tag_name: $tag,
- name: $name,
- body: $body
- }')" \
- "${UPDATE_URL}")
-
- if echo "$RESPONSE" | jq -e '.id' > /dev/null 2>&1; then
- RELEASE_URL=$(echo "$RESPONSE" | jq -r '.html_url')
- echo -e "${GREEN}Release updated successfully!${NC}"
- echo -e "${GREEN}Release URL: ${RELEASE_URL}${NC}"
- else
- echo -e "${RED}Error updating release${NC}"
- echo "$RESPONSE" | jq .
- exit 1
- fi
-else
- echo -e "${GREEN}Creating new release${NC}"
-
- # Create new release
- CREATE_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases"
-
- RESPONSE=$(curl -s -X POST \
- -H "Authorization: token ${FORGEJO_TOKEN}" \
- -H "Content-Type: application/json" \
- -d "$(jq -n \
- --arg tag "${CURRENT_VERSION}" \
- --arg name "Release ${CURRENT_VERSION}" \
- --arg body "${RELEASE_BODY}" \
- '{
- tag_name: $tag,
- name: $name,
- body: $body,
- draft: false,
- prerelease: false
- }')" \
- "${CREATE_URL}")
-
- if echo "$RESPONSE" | jq -e '.id' > /dev/null 2>&1; then
- RELEASE_URL=$(echo "$RESPONSE" | jq -r '.html_url')
- echo -e "${GREEN}Release created successfully!${NC}"
- echo -e "${GREEN}Release URL: ${RELEASE_URL}${NC}"
- else
- echo -e "${RED}Error creating release${NC}"
- echo "$RESPONSE" | jq .
- exit 1
- fi
-fi
-
-# Clean up the changelog file
-if [ -f "$CHANGELOG_FILE" ]; then
- rm -f "$CHANGELOG_FILE"
-fi
-
-# Fetch the tag that Forgejo created when we made the release
-echo -e "${GREEN}Fetching tags from remote to sync the tag created by Forgejo${NC}"
-if git fetch --tags; then
- echo -e "${GREEN}Tags synced successfully${NC}"
-else
- echo -e "${YELLOW}Warning: Failed to fetch tags from remote${NC}"
-fi
-
-exit 0
diff --git a/hack/bumpver-pre-commit-hook.sh b/hack/bumpver-pre-commit-hook.sh
deleted file mode 100755
index 89ce3c4..0000000
--- a/hack/bumpver-pre-commit-hook.sh
+++ /dev/null
@@ -1,134 +0,0 @@
-#!/bin/bash
-set -euo pipefail
-
-# Pre-commit hook for bumpver to generate changelog from Forgejo merge requests
-# This script queries the Forgejo API for merged pull requests since the last release
-# and appends them to the changelog file in AsciiDoc format
-
-# Configuration
-FORGEJO_URL="https://servala.app.codey.ch"
-REPO_OWNER="servala"
-REPO_NAME="servala-portal"
-CHANGELOG_FILE="docs/modules/ROOT/pages/web-portal-changelog.adoc"
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-NC='\033[0m' # No Color
-
-# Check for required environment variable
-if [ -z "${FORGEJO_TOKEN:-}" ]; then
- echo -e "${RED}Error: FORGEJO_TOKEN environment variable is not set${NC}"
- echo "Please set FORGEJO_TOKEN to your Forgejo API token"
- exit 1
-fi
-
-# Get the new version from pyproject.toml (bumpver updates this before running the hook)
-NEW_VERSION=$(grep -E 'current_version = ".*"' pyproject.toml | head -1 | sed -E 's/current_version = "(.*)"/\1/')
-echo -e "${GREEN}Generating changelog for version: ${NEW_VERSION}${NC}"
-
-# Get the previous tag
-PREVIOUS_TAG=$(git tag -l --sort=-v:refname | head -2 | tail -1)
-if [ -z "$PREVIOUS_TAG" ]; then
- echo -e "${YELLOW}Warning: No previous tag found, using all merge requests${NC}"
- # Get date of first commit
- SINCE_DATE=$(git log --reverse --format=%aI | head -1)
-else
- echo -e "${GREEN}Previous version: ${PREVIOUS_TAG}${NC}"
- # Get the date of the previous tag
- SINCE_DATE=$(git log -1 --format=%aI "${PREVIOUS_TAG}")
-fi
-
-echo -e "${GREEN}Fetching merge requests since ${SINCE_DATE}${NC}"
-
-# Query Forgejo API for closed/merged pull requests
-# Forgejo API returns pull requests sorted by updated time
-API_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls?state=closed&sort=updated&limit=100"
-
-RESPONSE=$(curl -s -H "Authorization: token ${FORGEJO_TOKEN}" "${API_URL}")
-
-# Check if curl was successful
-if [ $? -ne 0 ]; then
- echo -e "${RED}Error: Failed to fetch pull requests from Forgejo API${NC}"
- exit 1
-fi
-
-# Filter merged PRs since the previous tag and group by labels
-# Using jq to parse JSON, filter by merge date, and group by labels
-CHANGELOG_ENTRIES=$(echo "${RESPONSE}" | jq -r --arg since "${SINCE_DATE}" '
- # Filter merged PRs since the last tag
- [.[] | select(.merged_at != null and .merged_at > $since)] |
- sort_by(.merged_at) | reverse |
-
- # Group by primary label (first label if multiple, or "Uncategorized")
- group_by(
- if (.labels | length) > 0 then
- .labels[0].name
- else
- "Uncategorized"
- end
- ) |
-
- # Format each group
- map(
- # Group header
- "=== " + (
- if .[0].labels | length > 0 then
- .[0].labels[0].name
- else
- "Uncategorized"
- end
- ) + "\n" +
-
- # List items in this group
- (map("* " + .title + " (link:" + .html_url + "[#" + (.number | tostring) + "])") | join("\n"))
- ) |
- join("\n\n")
-')
-
-if [ -z "$CHANGELOG_ENTRIES" ]; then
- echo -e "${YELLOW}Warning: No merged pull requests found since ${PREVIOUS_TAG}${NC}"
- CHANGELOG_ENTRIES="=== Uncategorized\n\n* No changes recorded"
-fi
-
-# Create changelog section
-CHANGELOG_SECTION="
-== ${NEW_VERSION}
-
-${CHANGELOG_ENTRIES}
-"
-
-# Check if changelog file exists
-if [ ! -f "$CHANGELOG_FILE" ]; then
- echo -e "${RED}Error: Changelog file ${CHANGELOG_FILE} not found${NC}"
- exit 1
-fi
-
-# Create temporary file with new changelog entry at the top
-TMP_FILE=$(mktemp)
-{
- # Keep the title
- head -1 "$CHANGELOG_FILE"
- # Add the new changelog section
- echo "$CHANGELOG_SECTION"
- # Add the rest of the file (skip the title)
- tail -n +2 "$CHANGELOG_FILE"
-} > "$TMP_FILE"
-
-# Replace the original file
-mv "$TMP_FILE" "$CHANGELOG_FILE"
-chmod 0644 "$CHANGELOG_FILE"
-
-# Add the changelog file to git
-git add "$CHANGELOG_FILE"
-
-echo -e "${GREEN}Changelog updated successfully${NC}"
-echo -e "${GREEN}Added ${CHANGELOG_FILE} to git staging area${NC}"
-
-# Save changelog for post-commit hook
-CHANGELOG_DIR=".git/changelog"
-mkdir -p "$CHANGELOG_DIR"
-echo "$CHANGELOG_ENTRIES" > "${CHANGELOG_DIR}/${NEW_VERSION}.txt"
-
-exit 0
diff --git a/pyproject.toml b/pyproject.toml
index 3622227..467a9c1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,41 +3,41 @@ name = "servala"
version = "0.0.0"
description = "Servala portal server and frontend"
readme = "README.md"
-requires-python = ">=3.14.1"
+requires-python = ">=3.13"
dependencies = [
"argon2-cffi>=25.1.0",
- "cryptography>=46.0.3",
- "django==5.2.9",
- "django-allauth>=65.13.1",
- "django-auditlog>=3.3.0",
- "django-fernet-encrypted-fields>=0.3.1",
+ "cryptography>=46.0.1",
+ "django==5.2.7",
+ "django-allauth>=65.11.2",
+ "django-auditlog>=3.2.1",
+ "django-fernet-encrypted-fields>=0.3.0",
"django-jsonform>=2.23.2",
"django-scopes>=2.0.0",
"django-storages[s3]>=1.14.6",
- "django-template-partials>=25.3",
+ "django-template-partials>=25.2",
"jsonschema>=4.25.1",
- "kubernetes>=34.1.0",
- "pillow>=12.0.0",
- "psycopg2-binary>=2.9.11",
+ "kubernetes>=33.1.0",
+ "pillow>=11.3.0",
+ "psycopg2-binary>=2.9.10",
"pyjwt>=2.10.1",
"requests>=2.32.5",
"rules>=3.5",
- "sentry-sdk[django]>=2.47.0",
+ "sentry-sdk[django]>=2.39.0",
"urlman>=2.0.2",
]
[dependency-groups]
dev = [
- "black>=25.11.0",
+ "black>=25.9.0",
"bumpver>=2025.1131",
- "coverage>=7.12.0",
+ "coverage>=7.10.7",
"djlint>=1.36.4",
"flake8>=7.3.0",
- "flake8-bugbear>=25.11.29",
- "flake8-pyproject>=1.2.4",
- "isort>=7.0.0",
- "pytest>=9.0.1",
- "pytest-cov>=7.0.0",
+ "flake8-bugbear>=24.12.12",
+ "flake8-pyproject>=1.2.3",
+ "isort>=6.0.1",
+ "pytest>=8.4.2",
+ "pytest-cov>=6.3.0",
"pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
]
@@ -61,15 +61,15 @@ testpaths = "src/tests"
pythonpath = "src"
[tool.bumpver]
-current_version = "2025.11.17-0"
+current_version = "2025.10.03-0"
version_pattern = "YYYY.0M.0D-INC0"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
tag_scope = "default"
-pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
-post_commit_hook = "hack/bumpver-post-commit-hook.sh"
+pre_commit_hook = ""
+post_commit_hook = ""
commit = true
-tag = false
+tag = true
push = true
[tool.bumpver.file_patterns]
diff --git a/src/servala/__about__.py b/src/servala/__about__.py
index d6db270..f1e4ad8 100644
--- a/src/servala/__about__.py
+++ b/src/servala/__about__.py
@@ -1 +1 @@
-__version__ = "2025.11.17-0"
+__version__ = "2025.10.03-0"
diff --git a/src/servala/api/views.py b/src/servala/api/views.py
index 015e091..48845d0 100644
--- a/src/servala/api/views.py
+++ b/src/servala/api/views.py
@@ -1,27 +1,20 @@
import json
import logging
+from contextlib import suppress
from django.conf import settings
from django.contrib.auth.decorators import login_not_required
from django.core.mail import send_mail
from django.db import transaction
from django.http import JsonResponse
-from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
from servala.api.permissions import OSBBasicAuthPermission
from servala.core.exoscale import get_exoscale_origin
-from servala.core.models import (
- BillingEntity,
- Organization,
- OrganizationInvitation,
- OrganizationRole,
- User,
-)
-from servala.core.models.service import Service, ServiceInstance, ServiceOffering
-from servala.core.odoo import create_helpdesk_ticket
+from servala.core.models import BillingEntity, Organization, User
+from servala.core.models.service import Service, ServiceOffering
logger = logging.getLogger(__name__)
@@ -30,7 +23,9 @@ logger = logging.getLogger(__name__)
@method_decorator(login_not_required, name="dispatch")
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
"""
- OSB API endpoint for service instance management via Exoscale.
+ OSB API endpoint for service instance provisioning (onboarding).
+ Implements the PUT /v2/service_instances/:instance_id endpoint.
+ https://docs.servala.com/exoscale-osb.html#_onboarding
"""
def _error(self, error):
@@ -107,47 +102,66 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View):
)
exoscale_origin = get_exoscale_origin()
- try:
+ with suppress(Organization.DoesNotExist):
organization = Organization.objects.get(
osb_guid=organization_guid, origin=exoscale_origin
)
- if service in organization.limit_osb_services.all():
- return JsonResponse({"message": "Service already enabled"}, status=200)
- except Organization.DoesNotExist:
- try:
- with transaction.atomic():
- if exoscale_origin.billing_entity:
- billing_entity = exoscale_origin.billing_entity
- else:
- odoo_data = {
- "company_name": organization_display_name,
- "invoice_email": user.email,
- }
- billing_entity = BillingEntity.create_from_data(
- name=f"{organization_display_name} (Exoscale)",
- odoo_data=odoo_data,
- )
- organization = Organization(
- name=organization_display_name,
- billing_entity=billing_entity,
- origin=exoscale_origin,
- osb_guid=organization_guid,
- )
- organization = Organization.create_organization(organization)
- invitation = OrganizationInvitation.objects.create(
- organization=organization,
- email=user.email.lower(),
- role=OrganizationRole.OWNER,
- )
- invitation.send_invitation_email(request)
- except Exception:
- return JsonResponse({"error": "Internal server error"}, status=500)
+ self._send_service_welcome_email(
+ request, organization, user, service, service_offering
+ )
+ return JsonResponse({"message": "Service already enabled"}, status=200)
- organization.limit_osb_services.add(service)
- self._send_service_welcome_email(
- request, organization, user, service, service_offering
+ odoo_data = {
+ "company_name": organization_display_name,
+ "invoice_email": user.email,
+ }
+ try:
+ with transaction.atomic():
+ billing_entity = BillingEntity.create_from_data(
+ name=f"{organization_display_name} (Exoscale)", odoo_data=odoo_data
+ )
+ organization = Organization(
+ name=organization_display_name,
+ billing_entity=billing_entity,
+ origin=exoscale_origin,
+ osb_guid=organization_guid,
+ )
+ organization = Organization.create_organization(organization, user)
+
+ self._send_invitation_email(request, organization, user)
+ self._send_service_welcome_email(
+ request, organization, user, service, service_offering
+ )
+
+ return JsonResponse(
+ {"message": "Successfully enabled service"}, status=201
+ )
+
+ except Exception as e:
+ logger.error(f"Error creating organization for Exoscale: {str(e)}")
+ return JsonResponse({"error": "Internal server error"}, status=500)
+
+ def _send_invitation_email(self, request, organization, user):
+ subject = f"Welcome to Servala - {organization.name}"
+ url = request.build_absolute_uri(organization.urls.base)
+ message = f"""Hello {user.first_name or user.email},
+
+You have been invited to join the organization "{organization.name}" on Servala Portal.
+
+You can access your organization at: {url}
+
+Please use this email address ({user.email}) when prompted to log in.
+
+Best regards,
+The Servala Team"""
+
+ send_mail(
+ subject=subject,
+ message=message,
+ from_email=settings.EMAIL_DEFAULT_FROM,
+ recipient_list=[user.email],
+ fail_silently=False,
)
- return JsonResponse({"message": "Successfully enabled service"}, status=201)
def _send_service_welcome_email(
self, request, organization, user, service, service_offering
@@ -177,169 +191,3 @@ The Servala Team"""
recipient_list=[user.email],
fail_silently=False,
)
-
- def delete(self, request, instance_id):
- """
- This implements the Exoscale offboarding flow MVP.
- https://docs.servala.com/exoscale-osb.html#_offboarding
- """
- service_id = request.GET.get("service_id")
- plan_id = request.GET.get("plan_id")
-
- if not service_id:
- return self._error("service_id is required but missing.")
- if not plan_id:
- return self._error("plan_id is required but missing.")
-
- try:
- service = Service.objects.get(osb_service_id=service_id)
- service_offering = ServiceOffering.objects.get(
- osb_plan_id=plan_id, service=service
- )
- except Service.DoesNotExist:
- return self._error(f"Unknown service_id: {service_id}")
- except ServiceOffering.DoesNotExist:
- return self._error(
- f"Unknown plan_id: {plan_id} for service_id: {service_id}"
- )
-
- self._create_action_helpdesk_ticket(
- request=request,
- action="Offboard",
- instance_id=instance_id,
- service=service,
- service_offering=service_offering,
- )
-
- return JsonResponse({}, status=200)
-
- def patch(self, request, instance_id):
- """
- This implements the Exoscale suspension flow MVP.
- https://docs.servala.com/exoscale-osb.html#_suspension
- """
- try:
- data = json.loads(request.body)
- except json.JSONDecodeError:
- return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
-
- service_id = data.get("service_id")
- plan_id = data.get("plan_id")
-
- if not service_id:
- return self._error("service_id is required but missing.")
- if not plan_id:
- return self._error("plan_id is required but missing.")
-
- try:
- service = Service.objects.get(osb_service_id=service_id)
- # Special handling: when plan_id is "suspend", don't lookup service_offering
- service_offering = None
- if plan_id != "suspend":
- service_offering = ServiceOffering.objects.get(
- osb_plan_id=plan_id, service=service
- )
- except Service.DoesNotExist: # pragma: no-cover
- return self._error(f"Unknown service_id: {service_id}")
- except ServiceOffering.DoesNotExist: # pragma: no-cover
- return self._error(
- f"Unknown plan_id: {plan_id} for service_id: {service_id}"
- )
-
- self._create_action_helpdesk_ticket(
- request=request,
- action="Suspend",
- instance_id=instance_id,
- service=service,
- service_offering=service_offering,
- users=data.get("parameters", {}).get("users"),
- )
- return JsonResponse({}, status=200)
-
- def _get_admin_url(self, model_name, pk):
- admin_path = reverse(f"admin:{model_name}", args=[pk])
- return self.request.build_absolute_uri(admin_path)
-
- def _create_action_helpdesk_ticket(
- self, request, action, instance_id, service, service_offering=None, users=None
- ):
- """
- Create an Odoo helpdesk ticket for offboarding or suspension actions.
- This is an MVP implementation that creates a ticket for manual handling.
- """
- try:
- service_instance = None
- organization = None
- try:
- # Look for instances with this name in the service offering's context
- filter_kwargs = {"name": instance_id}
- if service_offering:
- filter_kwargs["context__service_offering"] = service_offering
-
- service_instance = (
- ServiceInstance.objects.filter(**filter_kwargs)
- .select_related("organization")
- .first()
- )
-
- if service_instance:
- organization = service_instance.organization
- except Exception: # pragma: no cover
- pass
-
- description_parts = [f"Action: {action}", f"Service: {service.name}"]
- if organization:
- org_url = self._get_admin_url(
- "core_organization_change", organization.pk
- )
- description_parts.append(
- f"Organization: {organization.name} - {org_url}"
- )
-
- if service_instance:
- instance_url = self._get_admin_url(
- "core_serviceinstance_change", service_instance.pk
- )
- description_parts.append(
- f"Instance: {service_instance.name} - {instance_url}"
- )
- else:
- description_parts.append(f"Instance: {instance_id}")
-
- if service_offering:
- offering_url = self._get_admin_url(
- "core_serviceoffering_change", service_offering.pk
- )
- description_parts.append(f"Service Offering: {offering_url}")
-
- if users:
- description_parts.append("
Users:")
- for user_data in users:
- email = user_data.get("email", "N/A")
- full_name = user_data.get("full_name", "N/A")
- role = user_data.get("role", "N/A")
-
- user_link = email
- if email and email != "N/A":
- try:
- user = User.objects.get(email=email.strip().lower())
- user_link = self._get_admin_url("core_user_change", user.pk)
- except User.DoesNotExist:
- pass
-
- description_parts.append(f" - {full_name} ({user_link}) - {role}")
-
- description = "
".join(description_parts)
-
- create_helpdesk_ticket(
- title=f"Exoscale OSB {action} - {service.name} - {instance_id}",
- description=description,
- )
- logger.info(
- f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}"
- )
-
- except Exception as e:
- logger.error(
- f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
- )
diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py
index 60fe147..073d444 100644
--- a/src/servala/core/admin.py
+++ b/src/servala/core/admin.py
@@ -1,6 +1,3 @@
-import json
-from pathlib import Path
-
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_jsonform.widgets import JSONFormWidget
@@ -12,7 +9,6 @@ from servala.core.models import (
ControlPlane,
ControlPlaneCRD,
Organization,
- OrganizationInvitation,
OrganizationMembership,
OrganizationOrigin,
Service,
@@ -66,15 +62,10 @@ class OrganizationAdmin(admin.ModelAdmin):
search_fields = ("name", "namespace")
autocomplete_fields = ("billing_entity", "origin")
inlines = (OrganizationMembershipInline,)
- filter_horizontal = ("limit_osb_services",)
def get_readonly_fields(self, request, obj=None):
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
readonly_fields.append("namespace") # Always read-only
-
- if obj and obj.has_inherited_billing_entity:
- readonly_fields.append("billing_entity")
-
return readonly_fields
@@ -86,16 +77,8 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin):
- list_display = (
- "name",
- "billing_entity",
- "default_odoo_sale_order_id",
- "hide_billing_address",
- )
- list_filter = ("hide_billing_address",)
+ list_display = ("name",)
search_fields = ("name",)
- autocomplete_fields = ("billing_entity",)
- filter_horizontal = ("limit_cloudproviders",)
@admin.register(OrganizationMembership)
@@ -107,58 +90,6 @@ class OrganizationMembershipAdmin(admin.ModelAdmin):
date_hierarchy = "date_joined"
-@admin.register(OrganizationInvitation)
-class OrganizationInvitationAdmin(admin.ModelAdmin):
- list_display = ("email", "organization", "role", "is_accepted", "created_at")
- list_filter = ("role", "created_at", "accepted_at", "organization")
- search_fields = ("email", "organization__name")
- autocomplete_fields = ("organization", "accepted_by")
- readonly_fields = (
- "secret",
- "accepted_by",
- "accepted_at",
- "created_at",
- "updated_at",
- )
- date_hierarchy = "created_at"
- actions = ["send_invitation_emails"]
-
- def is_accepted(self, obj):
- return obj.is_accepted
-
- is_accepted.boolean = True
- is_accepted.short_description = _("Accepted")
-
- def send_invitation_emails(self, request, queryset):
- pending_invitations = queryset.filter(accepted_by__isnull=True)
- sent_count = 0
- failed_count = 0
-
- for invitation in pending_invitations:
- try:
- invitation.send_invitation_email(request)
- sent_count += 1
- except Exception as e:
- failed_count += 1
- messages.error(
- request,
- _(f"Failed to send invitation to {invitation.email}: {str(e)}"),
- )
-
- if sent_count > 0:
- messages.success(
- request,
- _(f"Successfully sent {sent_count} invitation email(s)."),
- )
-
- if failed_count > 0:
- messages.warning(
- request, _(f"Failed to send {failed_count} invitation email(s).")
- )
-
- send_invitation_emails.short_description = _("Send invitation emails")
-
-
@admin.register(ServiceCategory)
class ServiceCategoryAdmin(admin.ModelAdmin):
list_display = ("name", "parent")
@@ -177,6 +108,7 @@ class ServiceAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
+ # JSON schema for external_links field
external_links_schema = {
"type": "array",
"title": "External Links",
@@ -209,6 +141,7 @@ class CloudProviderAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
+ # JSON schema for external_links field
external_links_schema = {
"type": "array",
"title": "External Links",
@@ -241,15 +174,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
fieldsets = (
(
None,
- {
- "fields": (
- "name",
- "description",
- "cloud_provider",
- "required_label",
- "wildcard_dns",
- )
- },
+ {"fields": ("name", "description", "cloud_provider", "required_label")},
),
(
_("API Credentials"),
@@ -319,29 +244,8 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
"description": _("API definition for the Kubernetes Custom Resource"),
},
),
- (
- _("Form Configuration"),
- {
- "fields": ("form_config", "hide_expert_mode"),
- "description": _(
- "Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec."
- ),
- },
- ),
)
- def get_form(self, request, obj=None, **kwargs):
- form = super().get_form(request, obj, **kwargs)
- schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
- with open(schema_path) as f:
- form_config_schema = json.load(f)
-
- if "form_config" in form.base_fields:
- form.base_fields["form_config"].widget = JSONFormWidget(
- schema=form_config_schema
- )
- return form
-
def get_exclude(self, request, obj=None):
# Exclude the original api_definition field as we're using our custom fields
return ["api_definition"]
@@ -400,23 +304,3 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
search_fields = ("description",)
autocomplete_fields = ("service", "provider")
inlines = (ControlPlaneCRDInline,)
-
- def get_form(self, request, obj=None, **kwargs):
- form = super().get_form(request, obj, **kwargs)
- external_links_schema = {
- "type": "array",
- "title": "External Links",
- "items": {
- "type": "object",
- "title": "Link",
- "properties": {
- "url": {"type": "string", "format": "uri", "title": "URL"},
- "title": {"type": "string", "title": "Title"},
- },
- "required": ["url", "title"],
- },
- }
- form.base_fields["external_links"].widget = JSONFormWidget(
- schema=external_links_schema
- )
- return form
diff --git a/src/servala/core/crd.py b/src/servala/core/crd.py
new file mode 100644
index 0000000..44c809b
--- /dev/null
+++ b/src/servala/core/crd.py
@@ -0,0 +1,419 @@
+import re
+
+from django import forms
+from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
+from django.db import models
+from django.forms.models import ModelForm, ModelFormMetaclass
+from django.utils.translation import gettext_lazy as _
+
+from servala.core.models import ServiceInstance
+
+
+class CRDModel(models.Model):
+ """Base class for all virtual CRD models"""
+
+ def __init__(self, **kwargs):
+ if spec := kwargs.pop("spec", None):
+ kwargs.update(unnest_data({"spec": spec}))
+ super().__init__(**kwargs)
+
+ class Meta:
+ abstract = True
+
+
+def duplicate_field(field_name, model):
+ # Get the field from the model
+ field = model._meta.get_field(field_name)
+
+ # Create a new field with the same attributes
+ new_field = type(field).__new__(type(field))
+ new_field.__dict__.update(field.__dict__)
+
+ # Ensure the field is not linked to the original model
+ new_field.model = None
+ new_field.auto_created = False
+
+ return new_field
+
+
+def generate_django_model(schema, group, version, kind):
+ """
+ Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
+ """
+ # We always need these three fields to know our own name and our full namespace
+ model_fields = {"__module__": "crd_models"}
+ for field_name in ("name", "organization", "context"):
+ model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
+
+ # All other fields are generated from the schema, except for the
+ # resourceRef object
+ spec = schema["properties"].get("spec") or {}
+ spec["properties"].pop("resourceRef", None)
+ model_fields.update(build_object_fields(spec, "spec", parent_required=False))
+
+ # Store the original schema on the model class
+ model_fields["SCHEMA"] = schema
+
+ meta_class = type("Meta", (), {"app_label": "crd_models"})
+ model_fields["Meta"] = meta_class
+
+ # create the model class
+ model_name = kind
+ model_class = type(model_name, (CRDModel,), model_fields)
+ return model_class
+
+
+def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
+ required_fields = schema.get("required") or []
+ properties = schema.get("properties") or {}
+ fields = {}
+
+ for field_name, field_schema in properties.items():
+ is_required = field_name in required_fields or parent_required
+ full_name = f"{name}.{field_name}"
+ result = get_django_field(
+ field_schema,
+ is_required,
+ field_name,
+ full_name,
+ verbose_name_prefix=verbose_name_prefix,
+ )
+ if isinstance(result, dict):
+ fields.update(result)
+ else:
+ fields[full_name] = result
+ return fields
+
+
+def deslugify(title):
+ if "_" in title:
+ title.replace("_", " ")
+ return title.title()
+ return re.sub(r"(? PostgreSQL Parameters).
- """
- ACRONYMS = {
- # Database systems
- "SQL": "SQL",
- "MYSQL": "MySQL",
- "POSTGRESQL": "PostgreSQL",
- "MARIADB": "MariaDB",
- "MSSQL": "MSSQL",
- "MONGODB": "MongoDB",
- "REDIS": "Redis",
- # Protocols
- "HTTP": "HTTP",
- "HTTPS": "HTTPS",
- "FTP": "FTP",
- "SFTP": "SFTP",
- "SSH": "SSH",
- "TLS": "TLS",
- "SSL": "SSL",
- # APIs
- "API": "API",
- "REST": "REST",
- "GRPC": "gRPC",
- "GRAPHQL": "GraphQL",
- # Networking
- "URL": "URL",
- "URI": "URI",
- "FQDN": "FQDN",
- "DNS": "DNS",
- "IP": "IP",
- "TCP": "TCP",
- "UDP": "UDP",
- # Data formats
- "JSON": "JSON",
- "XML": "XML",
- "YAML": "YAML",
- "CSV": "CSV",
- "HTML": "HTML",
- "CSS": "CSS",
- # Hardware
- "CPU": "CPU",
- "RAM": "RAM",
- "GPU": "GPU",
- "SSD": "SSD",
- "HDD": "HDD",
- # Identifiers
- "ID": "ID",
- "UUID": "UUID",
- "GUID": "GUID",
- "ARN": "ARN",
- # Cloud providers
- "AWS": "AWS",
- "GCP": "GCP",
- "AZURE": "Azure",
- "IBM": "IBM",
- # Kubernetes/Cloud
- "DB": "DB",
- "PVC": "PVC",
- "PV": "PV",
- "VPN": "VPN",
- # Auth
- "OS": "OS",
- "LDAP": "LDAP",
- "SAML": "SAML",
- "OAUTH": "OAuth",
- "JWT": "JWT",
- # AWS Services
- "S3": "S3",
- "EC2": "EC2",
- "RDS": "RDS",
- "EBS": "EBS",
- "IAM": "IAM",
- }
-
- if "_" in title:
- # Handle snake_case
- title = title.replace("_", " ")
- words = title.split()
- else:
- # Handle camelCase/PascalCase with smart splitting
- # This regex splits on:
- # - Transition from lowercase to uppercase (camelCase)
- # - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters)
- words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title)
-
- # Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL)
- merged_words = []
- i = 0
- while i < len(words):
- if i < len(words) - 1:
- # Check if current word + next word form a known acronym
- combined = (words[i] + words[i + 1]).upper()
- if combined in ACRONYMS:
- merged_words.append(combined)
- i += 2
- continue
- merged_words.append(words[i])
- i += 1
-
- # Capitalize each word, using proper casing for known acronyms
- result = []
- for word in merged_words:
- word_upper = word.upper()
- if word_upper in ACRONYMS:
- result.append(ACRONYMS[word_upper])
- else:
- result.append(word.capitalize())
-
- return " ".join(result)
diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py
index 090abba..baa85fb 100644
--- a/src/servala/core/forms.py
+++ b/src/servala/core/forms.py
@@ -1,34 +1,18 @@
-import json
-from pathlib import Path
-
-import jsonschema
from django import forms
from django.utils.translation import gettext_lazy as _
from django_jsonform.widgets import JSONFormWidget
-from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
from servala.core.models import ControlPlane, ServiceDefinition
CONTROL_PLANE_USER_INFO_SCHEMA = {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "title": {
- "type": "string",
- "title": "Title",
- },
- "content": {
- "type": "string",
- "title": "Content",
- },
- "help_text": {
- "type": "string",
- "title": "Help Text (optional)",
- },
+ "type": "object",
+ "properties": {
+ "CNAME Record": {
+ "title": "CNAME Record",
+ "type": "string",
},
- "required": ["title", "content"],
},
+ "additionalProperties": {"type": "string"},
}
@@ -101,12 +85,6 @@ class ControlPlaneAdminForm(forms.ModelForm):
return super().save(*args, **kwargs)
-def fields_empty(fields):
- if not fields:
- return True
- return all(not field.get("controlplane_field_mapping") for field in fields)
-
-
class ServiceDefinitionAdminForm(forms.ModelForm):
api_group = forms.CharField(
required=False,
@@ -135,10 +113,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
self.fields["api_version"].initial = api_def.get("version", "")
self.fields["api_kind"].initial = api_def.get("kind", "")
- schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
- with open(schema_path) as f:
- self.form_config_schema = json.load(f)
-
def clean(self):
cleaned_data = super().clean()
@@ -166,250 +140,8 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
api_def["kind"] = api_kind
cleaned_data["api_definition"] = api_def
- form_config = cleaned_data.get("form_config")
-
- # Convert empty form_config to None (no custom form)
- if form_config:
- if not form_config.get("fieldsets") or all(
- fields_empty(fieldset.get("fields"))
- for fieldset in form_config.get("fieldsets")
- ):
- form_config = None
- cleaned_data["form_config"] = None
-
- if form_config:
- form_config = self._normalize_form_config_types(form_config)
- cleaned_data["form_config"] = form_config
-
- try:
- jsonschema.validate(
- instance=form_config, schema=self.form_config_schema
- )
- except jsonschema.ValidationError as e:
- raise forms.ValidationError(
- {
- "form_config": _("Invalid form configuration: {}").format(
- e.message
- )
- }
- )
- except jsonschema.SchemaError as e:
- raise forms.ValidationError(
- {"form_config": _("Schema error: {}").format(e.message)}
- )
-
- self._validate_field_mappings(form_config, cleaned_data)
-
return cleaned_data
- def _normalize_form_config_types(self, form_config):
- """
- Normalize form_config by converting string representations of numbers
- to actual integers/floats. The JSON form widget sends all values
- as strings, but the schema expects proper types.
- """
- if not isinstance(form_config, dict):
- return form_config
-
- integer_fields = ["max_length", "rows", "min_values", "max_values"]
- number_fields = ["min_value", "max_value"]
-
- for fieldset in form_config.get("fieldsets", []):
- for field in fieldset.get("fields", []):
- for field_name in integer_fields:
- if field_name in field and field[field_name] is not None:
- value = field[field_name]
- if isinstance(value, str):
- try:
- field[field_name] = int(value) if value else None
- except (ValueError, TypeError):
- pass
-
- for field_name in number_fields:
- if field_name in field and field[field_name] is not None:
- value = field[field_name]
- if isinstance(value, str):
- try:
- field[field_name] = (
- int(value) if "." not in value else float(value)
- )
- except (ValueError, TypeError):
- pass
-
- return form_config
-
- def _validate_field_mappings(self, form_config, cleaned_data):
- if not self.instance.pk:
- return
- crd = self.instance.offering_control_planes.all().first()
- if not crd:
- return
-
- schema = None
- try:
- schema = crd.resource_schema
- except Exception:
- pass
-
- if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
- return
-
- valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"}
- included_mappings = set()
- errors = []
- for fieldset in form_config.get("fieldsets", []):
- for field in fieldset.get("fields", []):
- mapping = field.get("controlplane_field_mapping")
- included_mappings.add(mapping)
-
- # Validate that fields without defaults have required properties
- if mapping not in DEFAULT_FIELD_CONFIGS:
- if not field.get("label"):
- errors.append(
- _(
- "Field with mapping '{}' must have a 'label' property "
- "(or use a mapping with default config)"
- ).format(mapping)
- )
- if not field.get("type"):
- errors.append(
- _(
- "Field with mapping '{}' must have a 'type' property "
- "(or use a mapping with default config)"
- ).format(mapping)
- )
-
- if mapping and mapping not in valid_paths:
- field_name = field.get("label", field.get("name", mapping))
- errors.append(
- _(
- "Field '{}' has invalid mapping '{}'. Valid paths are: {}"
- ).format(
- field_name,
- mapping,
- ", ".join(sorted(valid_paths)[:10])
- + ("..." if len(valid_paths) > 10 else ""),
- )
- )
-
- if field.get("type") == "choice" and field.get("choices"):
- self._validate_choice_field(
- field, mapping, spec_schema, "spec", errors
- )
-
- for mandatory_field in MANDATORY_FIELDS:
- if mandatory_field not in included_mappings:
- errors.append(
- _(
- "Required field '{}' must be included in the form configuration"
- ).format(mandatory_field)
- )
-
- if errors:
- raise forms.ValidationError({"form_config": errors})
-
- def _validate_choice_field(self, field, mapping, spec_schema, prefix, errors):
- if not mapping:
- return
-
- field_name = field.get("label", mapping)
- custom_choices = field.get("choices", [])
-
- # Single-element choices [value] are transformed to [value, value]
- for i, choice in enumerate(custom_choices):
- if not isinstance(choice, (list, tuple)):
- errors.append(
- _(
- "Field '{}': Choice at index {} must be a list or tuple, "
- "but got: {}"
- ).format(field_name, i, repr(choice))
- )
- return
-
- choice_len = len(choice)
- if choice_len == 1:
- custom_choices[i] = [choice[0], choice[0]]
- elif choice_len == 0 or choice_len > 2:
- errors.append(
- _(
- "Field '{}': Choice at index {} must have 1 or 2 elements "
- "(got {}): {}"
- ).format(field_name, i, choice_len, repr(choice))
- )
- return
-
- field_schema = self._get_field_schema(spec_schema, mapping, prefix)
- if not field_schema:
- return
-
- control_plane_choices = field_schema.get("enum", [])
- if not control_plane_choices:
- return
-
- custom_choice_values = [choice[0] for choice in custom_choices]
-
- invalid_choices = [
- value
- for value in custom_choice_values
- if value not in control_plane_choices
- ]
-
- if invalid_choices:
- errors.append(
- _(
- "Field '{}' has invalid choice values: {}. "
- "Valid choices from control plane are: {}"
- ).format(
- field_name,
- ", ".join(f"'{c}'" for c in invalid_choices),
- ", ".join(f"'{c}'" for c in control_plane_choices),
- )
- )
-
- def _get_field_schema(self, schema, field_path, prefix):
- if not field_path or not schema:
- return None
-
- if field_path.startswith(prefix + "."):
- field_path = field_path[len(prefix) + 1 :]
-
- parts = field_path.split(".")
- current_schema = schema
-
- for part in parts:
- if not isinstance(current_schema, dict):
- return None
-
- properties = current_schema.get("properties", {})
- if part not in properties:
- return None
-
- current_schema = properties[part]
-
- return current_schema
-
- def _extract_field_paths(self, schema, prefix=""):
- paths = set()
-
- if not isinstance(schema, dict):
- return paths
-
- if "type" in schema and schema["type"] != "object":
- if prefix:
- paths.add(prefix)
-
- if schema.get("properties"):
- for prop_name, prop_schema in schema["properties"].items():
- new_prefix = f"{prefix}.{prop_name}" if prefix else prop_name
- paths.add(new_prefix)
- paths.update(self._extract_field_paths(prop_schema, new_prefix))
-
- if schema.get("type") == "array" and "items" in schema:
- if prefix:
- paths.add(prefix)
-
- return paths
-
def save(self, *args, **kwargs):
self.instance.api_definition = self.cleaned_data["api_definition"]
return super().save(*args, **kwargs)
diff --git a/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py b/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py
deleted file mode 100644
index 811c843..0000000
--- a/src/servala/core/migrations/0009_controlplane_wildcard_dns_and_more.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-22 09:38
-
-import django.db.models.deletion
-import rules.contrib.models
-from django.conf import settings
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0008_organization_osb_guid_service_osb_service_id_and_more"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="controlplane",
- name="wildcard_dns",
- field=models.CharField(
- blank=True,
- help_text="Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)",
- max_length=255,
- null=True,
- verbose_name="Wildcard DNS",
- ),
- ),
- migrations.AddField(
- model_name="organization",
- name="limit_osb_services",
- field=models.ManyToManyField(
- blank=True,
- related_name="+",
- to="core.service",
- verbose_name="Services activated from OSB",
- ),
- ),
- migrations.AddField(
- model_name="organizationorigin",
- name="billing_entity",
- field=models.ForeignKey(
- help_text="If set, this billing entity will be used on new organizations with this origin.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="origins",
- to="core.billingentity",
- verbose_name="Billing entity",
- ),
- ),
- migrations.AddField(
- model_name="organizationorigin",
- name="default_odoo_sale_order_id",
- field=models.IntegerField(
- blank=True,
- help_text="If set, this sale order will be used for new organizations with this origin.",
- null=True,
- verbose_name="Default Odoo Sale Order ID",
- ),
- ),
- migrations.AddField(
- model_name="organizationorigin",
- name="limit_cloudproviders",
- field=models.ManyToManyField(
- blank=True,
- help_text="If set, all organizations with this origin will be limited to these cloud providers.",
- related_name="+",
- to="core.cloudprovider",
- verbose_name="Limit to these Cloud providers",
- ),
- ),
- migrations.AddField(
- model_name="servicedefinition",
- name="advanced_fields",
- field=models.JSONField(
- blank=True,
- default=list,
- help_text=(
- "Array of field names that should be hidden behind an 'Advanced' toggle."
- "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])"
- ),
- null=True,
- verbose_name="Advanced fields",
- ),
- ),
- migrations.AddField(
- model_name="serviceoffering",
- name="external_links",
- field=models.JSONField(
- blank=True,
- help_text='JSON array of link objects: {"url": "…", "title": "…"}. ',
- null=True,
- verbose_name="External links",
- ),
- ),
- migrations.CreateModel(
- name="OrganizationInvitation",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "created_at",
- models.DateTimeField(auto_now_add=True, verbose_name="Created"),
- ),
- (
- "updated_at",
- models.DateTimeField(auto_now=True, verbose_name="Last updated"),
- ),
- (
- "email",
- models.EmailField(max_length=254, verbose_name="Email address"),
- ),
- (
- "role",
- models.CharField(
- choices=[
- ("member", "Member"),
- ("admin", "Administrator"),
- ("owner", "Owner"),
- ],
- default="member",
- max_length=20,
- verbose_name="Role",
- ),
- ),
- (
- "secret",
- models.CharField(
- editable=False,
- max_length=64,
- unique=True,
- verbose_name="Secret token",
- ),
- ),
- (
- "accepted_at",
- models.DateTimeField(
- blank=True, null=True, verbose_name="Accepted at"
- ),
- ),
- (
- "accepted_by",
- models.ForeignKey(
- blank=True,
- null=True,
- on_delete=django.db.models.deletion.SET_NULL,
- related_name="accepted_invitations",
- to=settings.AUTH_USER_MODEL,
- verbose_name="Accepted by",
- ),
- ),
- (
- "created_by",
- models.ForeignKey(
- blank=True,
- null=True,
- on_delete=django.db.models.deletion.SET_NULL,
- related_name="created_invitations",
- to=settings.AUTH_USER_MODEL,
- verbose_name="Created by",
- ),
- ),
- (
- "organization",
- models.ForeignKey(
- on_delete=django.db.models.deletion.CASCADE,
- related_name="invitations",
- to="core.organization",
- verbose_name="Organization",
- ),
- ),
- ],
- options={
- "verbose_name": "Organization invitation",
- "verbose_name_plural": "Organization invitations",
- "unique_together": {("organization", "email")},
- },
- bases=(rules.contrib.models.RulesModelMixin, models.Model),
- ),
- ]
diff --git a/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py b/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py
deleted file mode 100644
index 78c2c45..0000000
--- a/src/servala/core/migrations/0010_remove_invitation_unique_constraint.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-22 13:19
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0009_controlplane_wildcard_dns_and_more"),
- ]
-
- operations = [
- migrations.AlterUniqueTogether(
- name="organizationinvitation",
- unique_together=set(),
- ),
- migrations.AlterField(
- model_name="servicedefinition",
- name="advanced_fields",
- field=models.JSONField(
- blank=True,
- default=list,
- help_text=(
- "Array of field names that should be hidden behind an 'Advanced' toggle. "
- "Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])"
- ),
- null=True,
- verbose_name="Advanced fields",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py b/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py
deleted file mode 100644
index b122d68..0000000
--- a/src/servala/core/migrations/0011_alter_organizationorigin_billing_entity.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-22 13:40
-
-import django.db.models.deletion
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0010_remove_invitation_unique_constraint"),
- ]
-
- operations = [
- migrations.AlterField(
- model_name="organizationorigin",
- name="billing_entity",
- field=models.ForeignKey(
- blank=True,
- help_text="If set, this billing entity will be used on new organizations with this origin.",
- null=True,
- on_delete=django.db.models.deletion.PROTECT,
- related_name="origins",
- to="core.billingentity",
- verbose_name="Billing entity",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0012_convert_user_info_to_array.py b/src/servala/core/migrations/0012_convert_user_info_to_array.py
deleted file mode 100644
index 892949e..0000000
--- a/src/servala/core/migrations/0012_convert_user_info_to_array.py
+++ /dev/null
@@ -1,65 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-24 10:04
-
-from django.db import migrations
-
-
-def convert_user_info_to_array(apps, schema_editor):
- """
- Convert user_info from object format {"key": "value"} to array format
- [{"title": "key", "content": "value"}].
- """
- ControlPlane = apps.get_model("core", "ControlPlane")
-
- for control_plane in ControlPlane.objects.all():
- if not control_plane.user_info:
- continue
-
- # If it's already an array (migration already run or new format), skip
- if isinstance(control_plane.user_info, list):
- continue
-
- # Convert from dict to array
- if isinstance(control_plane.user_info, dict):
- new_user_info = []
- for key, value in control_plane.user_info.items():
- new_user_info.append({"title": key, "content": value})
-
- control_plane.user_info = new_user_info
- control_plane.save(update_fields=["user_info"])
-
-
-def reverse_user_info_to_object(apps, schema_editor):
- """
- Reverse the migration by converting array format back to object format.
- Note: help_text will be lost during reversal.
- """
- ControlPlane = apps.get_model("core", "ControlPlane")
-
- for control_plane in ControlPlane.objects.all():
- if not control_plane.user_info:
- continue
-
- # If it's already an object, skip
- if isinstance(control_plane.user_info, dict):
- continue
-
- # Convert from array to dict
- if isinstance(control_plane.user_info, list):
- new_user_info = {}
- for item in control_plane.user_info:
- if isinstance(item, dict) and "title" in item and "content" in item:
- new_user_info[item["title"]] = item["content"]
-
- control_plane.user_info = new_user_info
- control_plane.save(update_fields=["user_info"])
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0011_alter_organizationorigin_billing_entity"),
- ]
-
- operations = [
- migrations.RunPython(convert_user_info_to_array, reverse_user_info_to_object),
- ]
diff --git a/src/servala/core/migrations/0012_remove_advanced_fields.py b/src/servala/core/migrations/0012_remove_advanced_fields.py
deleted file mode 100644
index 7d0fecd..0000000
--- a/src/servala/core/migrations/0012_remove_advanced_fields.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-31 10:40
-
-import django.core.validators
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0012_convert_user_info_to_array"),
- ]
-
- operations = [
- migrations.RemoveField(
- model_name="servicedefinition",
- name="advanced_fields",
- ),
- migrations.AlterField(
- model_name="organization",
- name="name",
- field=models.CharField(
- max_length=32,
- validators=[
- django.core.validators.RegexValidator(
- message="Organization name can only contain letters, numbers, and spaces.",
- regex="^[A-Za-z0-9\\s]+$",
- )
- ],
- verbose_name="Name",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0013_add_form_config.py b/src/servala/core/migrations/0013_add_form_config.py
deleted file mode 100644
index 2819a6c..0000000
--- a/src/servala/core/migrations/0013_add_form_config.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# Generated by Django 5.2.7 on 2025-10-31 10:47
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0012_remove_advanced_fields"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="servicedefinition",
- name="form_config",
- field=models.JSONField(
- blank=True,
- help_text=(
- "Optional custom form configuration. When provided, this configuration will "
- "be used to render the service form instead of auto-generating it from the OpenAPI spec. "
- 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
- ),
- null=True,
- verbose_name="Form Configuration",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0014_hide_billing_address.py b/src/servala/core/migrations/0014_hide_billing_address.py
deleted file mode 100644
index 1e7f3bf..0000000
--- a/src/servala/core/migrations/0014_hide_billing_address.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# Generated by Django 5.2.8 on 2025-11-12 09:31
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0013_add_form_config"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="organizationorigin",
- name="billing_message",
- field=models.TextField(
- blank=True,
- help_text="Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale').",
- verbose_name="Billing Message",
- ),
- ),
- migrations.AddField(
- model_name="organizationorigin",
- name="hide_billing_address",
- field=models.BooleanField(
- default=False,
- help_text="If enabled, the billing address will not be shown in the organization details view.",
- verbose_name="Hide Billing Address",
- ),
- ),
- migrations.AlterField(
- model_name="controlplane",
- name="user_info",
- field=models.JSONField(
- blank=True,
- help_text=(
- 'Array of info objects: [{"title": "…", "content": "…", "help_text": "…"}]. '
- "The help_text field is optional and will be shown as a hover popover on an info icon."
- ),
- null=True,
- verbose_name="User Information",
- ),
- ),
- ]
diff --git a/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py b/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py
deleted file mode 100644
index cc88c9d..0000000
--- a/src/servala/core/migrations/0015_add_hide_expert_mode_to_service_definition.py
+++ /dev/null
@@ -1,25 +0,0 @@
-# Generated by Django 5.2.8 on 2025-11-14 15:55
-
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ("core", "0014_hide_billing_address"),
- ]
-
- operations = [
- migrations.AddField(
- model_name="servicedefinition",
- name="hide_expert_mode",
- field=models.BooleanField(
- default=False,
- help_text=(
- "When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form configuration will be available. "
- "Only applies when a custom form configuration is provided."
- ),
- verbose_name="Disable Expert Mode",
- ),
- ),
- ]
diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py
index 4c23f18..22e8e8a 100644
--- a/src/servala/core/models/__init__.py
+++ b/src/servala/core/models/__init__.py
@@ -1,7 +1,6 @@
from .organization import (
BillingEntity,
Organization,
- OrganizationInvitation,
OrganizationMembership,
OrganizationOrigin,
OrganizationRole,
@@ -24,7 +23,6 @@ __all__ = [
"ControlPlane",
"ControlPlaneCRD",
"Organization",
- "OrganizationInvitation",
"OrganizationMembership",
"OrganizationOrigin",
"OrganizationRole",
diff --git a/src/servala/core/models/organization.py b/src/servala/core/models/organization.py
index be4f587..083bc50 100644
--- a/src/servala/core/models/organization.py
+++ b/src/servala/core/models/organization.py
@@ -1,14 +1,7 @@
-import secrets
-
import rules
import urlman
-from auditlog.registry import auditlog
from django.conf import settings
-from django.contrib.sites.shortcuts import get_current_site
-from django.core.mail import send_mail
-from django.core.validators import RegexValidator
from django.db import models, transaction
-from django.http import HttpRequest
from django.utils.functional import cached_property
from django.utils.safestring import mark_safe
from django.utils.text import slugify
@@ -21,18 +14,7 @@ from servala.core.odoo import CLIENT
class Organization(ServalaModelMixin, models.Model):
- name = models.CharField(
- max_length=32,
- verbose_name=_("Name"),
- validators=[
- RegexValidator(
- regex=r"^[A-Za-z0-9\s]+$",
- message=_(
- "Organization name can only contain letters, numbers, and spaces."
- ),
- )
- ],
- )
+ name = models.CharField(max_length=100, verbose_name=_("Name"))
# The namespace is generated as "org-{id}" in accordance with RFC 1035 Label Names.
# It is nullable as we need to write to the database in order to read the ID, but should
# not be null in practical use.
@@ -64,12 +46,6 @@ class Organization(ServalaModelMixin, models.Model):
related_name="organizations",
verbose_name=_("Members"),
)
- limit_osb_services = models.ManyToManyField(
- to="Service",
- related_name="+",
- verbose_name=_("Services activated from OSB"),
- blank=True,
- )
odoo_sale_order_id = models.IntegerField(
null=True, blank=True, verbose_name=_("Odoo Sale Order ID")
@@ -101,18 +77,6 @@ class Organization(ServalaModelMixin, models.Model):
def get_absolute_url(self):
return self.urls.base
- @property
- def has_inherited_billing_entity(self):
- return self.origin and self.billing_entity == self.origin.billing_entity
-
- @property
- def limit_cloudproviders(self):
- if self.origin:
- return self.origin.limit_cloudproviders.all()
- from servala.core.models import CloudProvider
-
- return CloudProvider.objects.none()
-
def set_owner(self, user):
with scopes_disabled():
OrganizationMembership.objects.filter(user=user, organization=self).delete()
@@ -130,7 +94,7 @@ class Organization(ServalaModelMixin, models.Model):
@classmethod
@transaction.atomic
- def create_organization(cls, instance, owner=None):
+ def create_organization(cls, instance, owner):
try:
instance.origin
except Exception:
@@ -138,23 +102,9 @@ class Organization(ServalaModelMixin, models.Model):
pk=settings.SERVALA_DEFAULT_ORIGIN
)
instance.save()
- if owner:
- instance.set_owner(owner)
+ instance.set_owner(owner)
- if instance.origin and instance.origin.default_odoo_sale_order_id:
- sale_order_id = instance.origin.default_odoo_sale_order_id
- sale_order_data = CLIENT.search_read(
- model="sale.order",
- domain=[["id", "=", sale_order_id]],
- fields=["name"],
- limit=1,
- )
-
- instance.odoo_sale_order_id = sale_order_id
- if sale_order_data:
- instance.odoo_sale_order_name = sale_order_data[0]["name"]
- instance.save(update_fields=["odoo_sale_order_id", "odoo_sale_order_name"])
- elif (
+ if (
instance.billing_entity.odoo_company_id
and instance.billing_entity.odoo_invoice_id
):
@@ -181,34 +131,6 @@ class Organization(ServalaModelMixin, models.Model):
return instance
- def get_visible_services(self):
- from servala.core.models import Service
-
- queryset = Service.objects.all()
- if self.limit_osb_services.exists():
- queryset = self.limit_osb_services.all()
- if self.limit_cloudproviders.exists():
- queryset = queryset.filter(
- offerings__provider__in=self.limit_cloudproviders
- ).distinct()
- return queryset.prefetch_related(
- "offerings", "offerings__provider"
- ).select_related("category")
-
- def get_deactivated_services(self):
- from servala.core.models import Service
-
- if not self.limit_osb_services.exists():
- return Service.objects.none()
-
- queryset = Service.objects.select_related("category")
- if self.limit_cloudproviders.exists():
- queryset = queryset.filter(
- offerings__provider__in=self.limit_cloudproviders
- ).distinct()
- queryset = queryset.exclude(id__in=self.limit_osb_services.all())
- return queryset.prefetch_related("offerings", "offerings__provider")
-
class Meta:
verbose_name = _("Organization")
verbose_name_plural = _("Organizations")
@@ -391,48 +313,6 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, verbose_name=_("Description"))
- billing_entity = models.ForeignKey(
- to="BillingEntity",
- on_delete=models.PROTECT,
- related_name="origins",
- verbose_name=_("Billing entity"),
- help_text=_(
- "If set, this billing entity will be used on new organizations with this origin."
- ),
- null=True,
- blank=True,
- )
- limit_cloudproviders = models.ManyToManyField(
- to="CloudProvider",
- related_name="+",
- verbose_name=_("Limit to these Cloud providers"),
- blank=True,
- help_text=_(
- "If set, all organizations with this origin will be limited to these cloud providers."
- ),
- )
- default_odoo_sale_order_id = models.IntegerField(
- null=True,
- blank=True,
- verbose_name=_("Default Odoo Sale Order ID"),
- help_text=_(
- "If set, this sale order will be used for new organizations with this origin."
- ),
- )
- hide_billing_address = models.BooleanField(
- default=False,
- verbose_name=_("Hide Billing Address"),
- help_text=_(
- "If enabled, the billing address will not be shown in the organization details view."
- ),
- )
- billing_message = models.TextField(
- blank=True,
- verbose_name=_("Billing Message"),
- help_text=_(
- "Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale')."
- ),
- )
class Meta:
verbose_name = _("Organization origin")
@@ -481,120 +361,3 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
def __str__(self):
return f"{self.user} in {self.organization} as {self.role}"
-
-
-class OrganizationInvitation(ServalaModelMixin, models.Model):
- organization = models.ForeignKey(
- to=Organization,
- on_delete=models.CASCADE,
- related_name="invitations",
- verbose_name=_("Organization"),
- )
- email = models.EmailField(verbose_name=_("Email address"))
- role = models.CharField(
- max_length=20,
- choices=OrganizationRole.choices,
- default=OrganizationRole.MEMBER,
- verbose_name=_("Role"),
- )
- secret = models.CharField(
- max_length=64,
- unique=True,
- editable=False,
- verbose_name=_("Secret token"),
- )
- created_by = models.ForeignKey(
- to="core.User",
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="created_invitations",
- verbose_name=_("Created by"),
- )
- accepted_by = models.ForeignKey(
- to="core.User",
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name="accepted_invitations",
- verbose_name=_("Accepted by"),
- )
- accepted_at = models.DateTimeField(
- null=True, blank=True, verbose_name=_("Accepted at")
- )
-
- class urls(urlman.Urls):
- accept = "/invitations/{self.secret}/accept/"
- delete = "{self.organization.urls.details}invitations/{self.pk}/delete/"
-
- class Meta:
- verbose_name = _("Organization invitation")
- verbose_name_plural = _("Organization invitations")
-
- def __str__(self):
- return f"Invitation for {self.email} to {self.organization}"
-
- def save(self, *args, **kwargs):
- if not self.secret:
- self.secret = secrets.token_urlsafe(48)
- super().save(*args, **kwargs)
-
- @property
- def is_accepted(self):
- # We check both accepted_by and accepted_at to avoid a deleted user
- # freeing up an invitation
- return bool(self.accepted_by or self.accepted_at)
-
- @property
- def can_be_accepted(self):
- return not self.is_accepted
-
- def send_invitation_email(self, request=None):
- subject = _("You're invited to join {organization} on Servala").format(
- organization=self.organization.name
- )
-
- if request:
- invitation_url = request.build_absolute_uri(self.urls.accept)
- organization_url = request.build_absolute_uri(self.organization.urls.base)
- else:
- fake_request = HttpRequest()
- fake_request.META["SERVER_NAME"] = get_current_site(None).domain
- fake_request.META["SERVER_PORT"] = "443"
- fake_request.META["wsgi.url_scheme"] = "https"
- invitation_url = fake_request.build_absolute_uri(self.urls.accept)
- organization_url = fake_request.build_absolute_uri(
- self.organization.urls.base
- )
-
- message = _(
- """Hello,
-
-You have been invited to join the organization "{organization}" on Servala Portal as a {role}.
-
-To accept this invitation, please click the link below:
-{invitation_url}
-
-Once you accept, you'll be able to access the organization at:
-{organization_url}
-
-Best regards,
-The Servala Team"""
- ).format(
- organization=self.organization.name,
- role=self.get_role_display(),
- invitation_url=invitation_url,
- organization_url=organization_url,
- )
-
- send_mail(
- subject=subject,
- message=message,
- from_email=settings.EMAIL_DEFAULT_FROM,
- recipient_list=[self.email],
- fail_silently=False,
- )
-
-
-auditlog.register(OrganizationInvitation, serialize_data=True)
-auditlog.register(OrganizationMembership, serialize_data=True)
diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py
index ab7b76f..43c9023 100644
--- a/src/servala/core/models/service.py
+++ b/src/servala/core/models/service.py
@@ -156,17 +156,7 @@ class ControlPlane(ServalaModelMixin, models.Model):
blank=True,
verbose_name=_("User Information"),
help_text=_(
- 'Array of info objects: [{"title": "…", "content": "…", "help_text": "…"}]. '
- "The help_text field is optional and will be shown as a hover popover on an info icon."
- ),
- )
- wildcard_dns = models.CharField(
- max_length=255,
- blank=True,
- null=True,
- verbose_name=_("Wildcard DNS"),
- help_text=_(
- "Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)"
+ "Key-value information displayed to users when selecting this control plane"
),
)
@@ -360,24 +350,6 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
null=True,
blank=True,
)
- form_config = models.JSONField(
- verbose_name=_("Form Configuration"),
- help_text=_(
- "Optional custom form configuration. When provided, this configuration will be used "
- "to render the service form instead of auto-generating it from the OpenAPI spec. "
- 'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
- ),
- null=True,
- blank=True,
- )
- hide_expert_mode = models.BooleanField(
- default=False,
- verbose_name=_("Disable Expert Mode"),
- help_text=_(
- "When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form "
- "configuration will be available. Only applies when a custom form configuration is provided."
- ),
- )
service = models.ForeignKey(
to="Service",
on_delete=models.CASCADE,
@@ -520,22 +492,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
return
return generate_model_form_class(self.django_model)
- @cached_property
- def custom_model_form_class(self):
- from servala.core.crd import generate_custom_form_class
-
- if not self.django_model:
- return
- if not (
- self.service_definition
- and self.service_definition.form_config
- and self.service_definition.form_config.get("fieldsets")
- ):
- return
- return generate_custom_form_class(
- self.service_definition.form_config, self.django_model
- )
-
class ServiceOffering(ServalaModelMixin, models.Model):
"""
@@ -555,12 +511,6 @@ class ServiceOffering(ServalaModelMixin, models.Model):
verbose_name=_("Provider"),
)
description = models.TextField(blank=True, verbose_name=_("Description"))
- external_links = models.JSONField(
- null=True,
- blank=True,
- verbose_name=_("External links"),
- help_text=('JSON array of link objects: {"url": "…", "title": "…"}. '),
- )
osb_plan_id = models.CharField(
max_length=100,
null=True,
@@ -628,7 +578,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
}
class urls(urlman.Urls):
- base = "{self.organization.urls.instances}{self.name}-{self.pk}/"
+ base = "{self.organization.urls.instances}{self.name}/"
update = "{base}update/"
delete = "{base}delete/"
@@ -707,7 +657,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
return mark_safe(f"
- {% translate "Sign in to your account or create a new one to access your managed service instances and the Servala service catalog" %} + {% translate "Sign in to access your managed service instances and the Servala service catalog" %}
Crafted with in Zurich - {% load version_tags %} - - {% get_version_or_env %}