Compare commits

..

No commits in common. "main" and "2025.10.03-0" have entirely different histories.

80 changed files with 1333 additions and 6365 deletions

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -1 +1 @@
3.14
3.13

View file

@ -1,4 +1,4 @@
FROM python:3.14-slim
FROM python:3.13-slim
EXPOSE 8000
WORKDIR /app

View file

@ -4,7 +4,7 @@
The Servala Self-Service Portal
Latest release: 2025.11.17-0
Latest release: 2025.10.03-0
## Documentation

View file

@ -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[]

View file

@ -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])

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -3,41 +3,41 @@ name = "servala"
version = "0.0.0"
description = "Servala portal server and frontend"
readme = "README.md"
requires-python = ">=3.14.0"
requires-python = ">=3.13"
dependencies = [
"argon2-cffi>=25.1.0",
"cryptography>=46.0.3",
"django==5.2.8",
"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.46.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-bugbear>=24.12.12",
"flake8-pyproject>=1.2.3",
"isort>=7.0.0",
"pytest>=9.0.1",
"pytest-cov>=7.0.0",
"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]

View file

@ -1 +1 @@
__version__ = "2025.11.17-0"
__version__ = "2025.10.03-0"

View file

@ -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("<br/>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 = "<br/>".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}"
)

View file

@ -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

419
src/servala/core/crd.py Normal file
View file

@ -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"(?<!^)(?=[A-Z])", " ", title).capitalize()
def get_django_field(
field_schema, is_required, field_name, full_name, verbose_name_prefix=None
):
field_type = field_schema.get("type") or "string"
format = field_schema.get("format")
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
# Pass down the requirement status from parent to child fields
kwargs = {
"blank": not is_required, # All fields are optional by default
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
"verbose_name": verbose_name,
"default": field_schema.get("default"),
}
if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(minimum))
if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string":
if format == "date-time":
return models.DateTimeField(**kwargs)
elif format == "date":
return models.DateField(**kwargs)
else:
max_length = field_schema.get("max_length") or 255
if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=pattern))
if choices := field_schema.get("enum"):
kwargs["choices"] = ((choice, choice) for choice in choices)
return models.CharField(max_length=max_length, **kwargs)
elif field_type == "integer":
return models.IntegerField(**kwargs)
elif field_type == "number":
return models.FloatField(**kwargs)
elif field_type == "boolean":
return models.BooleanField(**kwargs)
elif field_type == "object":
# Here we pass down the requirement status to nested objects
return build_object_fields(
field_schema,
full_name,
verbose_name_prefix=f"{verbose_name}:",
parent_required=is_required,
)
elif field_type == "array":
kwargs["help_text"] = field_schema.get("description") or _("List of values")
from servala.frontend.forms.widgets import DynamicArrayField
field = models.JSONField(**kwargs)
formfield_kwargs = {
"label": field.verbose_name,
"required": not field.blank,
}
array_validation = {}
if min_items := field_schema.get("min_items"):
array_validation["min_items"] = min_items
if max_items := field_schema.get("max_items"):
array_validation["max_items"] = max_items
if unique_items := field_schema.get("unique_items"):
array_validation["unique_items"] = unique_items
if items_schema := field_schema.get("items"):
array_validation["items_schema"] = items_schema
if array_validation:
formfield_kwargs["array_validation"] = array_validation
field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
return field
return models.CharField(max_length=255, **kwargs)
def unnest_data(data):
result = {}
def _flatten_dict(d, parent_key=""):
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key else key
if isinstance(value, dict):
_flatten_dict(value, new_key)
else:
result[new_key] = value
_flatten_dict(data)
return result
class CrdModelFormMixin:
HIDDEN_FIELDS = [
"spec.compositeDeletePolicy",
"spec.compositionRef",
"spec.compositionRevisionRef",
"spec.compositionRevisionSelector",
"spec.compositionSelector",
"spec.compositionUpdatePolicy",
"spec.parameters.monitoring.alertmanagerConfigRef",
"spec.parameters.monitoring.alertmanagerConfigSecretRef",
"spec.parameters.network.serviceType",
"spec.parameters.scheduling",
"spec.parameters.security",
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = self._meta.model.SCHEMA
for field in ("organization", "context"):
self.fields[field].widget = forms.HiddenInput()
for name, field in self.fields.items():
if name in self.HIDDEN_FIELDS or any(
name.startswith(f) for f in self.HIDDEN_FIELDS
):
field.widget = forms.HiddenInput()
field.required = False
if self.instance and self.instance.pk:
self.fields["name"].disabled = True
self.fields["name"].help_text = _("Name cannot be changed after creation.")
self.fields["name"].widget = forms.HiddenInput()
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
def get_fieldsets(self):
fieldsets = []
# General fieldset for non-spec fields
general_fields = [
field_name
for field_name in self.fields.keys()
if not field_name.startswith("spec.")
]
if general_fields:
fieldset = {
"title": "General",
"fields": general_fields,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(general_fields),
}
if all(
[
isinstance(self.fields[field].widget, forms.HiddenInput)
for field in general_fields
]
):
fieldset["hidden"] = True
fieldsets.append(fieldset)
# Process spec fields
others = []
top_level_fieldsets = {}
hidden_spec_fields = []
for field_name in self.fields:
if field_name.startswith("spec."):
if isinstance(self.fields[field_name].widget, forms.HiddenInput):
hidden_spec_fields.append(field_name)
continue
parts = field_name.split(".")
if len(parts) == 2:
# Top-level spec field
others.append(field_name)
elif len(parts) == 3:
# Second-level field - promote to top-level fieldset
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
top_level_fieldsets[fieldset_key]["fields"].append(field_name)
else:
# Third-level and deeper - create nested fieldsets
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
sub_key = parts[3]
if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key):
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][
"fields"
].append(field_name)
for fieldset in top_level_fieldsets.values():
nested_fieldsets_list = []
for sub_fieldset in fieldset["fieldsets"].values():
if len(sub_fieldset["fields"]) == 1:
# If nested fieldset has only one field, move it to parent
fieldset["fields"].append(sub_fieldset["fields"][0])
else:
# Keep as nested fieldset with proper title stripping
title = f"{fieldset['title']}: {sub_fieldset['title']}: "
for field in sub_fieldset["fields"]:
self.strip_title(field, title)
nested_fieldsets_list.append(sub_fieldset)
fieldset["fieldsets"] = nested_fieldsets_list
total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list)
if total_fields == 1 and len(fieldset["fields"]) == 1:
others.append(fieldset["fields"][0])
else:
title = f"{fieldset['title']}: "
for field in fieldset["fields"]:
self.strip_title(field, title)
all_fields = fieldset["fields"][:]
for sub_fieldset in nested_fieldsets_list:
all_fields.extend(sub_fieldset["fields"])
fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields)
fieldsets.append(fieldset)
# Add 'others' tab if there are any fields
if others:
fieldsets.append(
{
"title": "Others",
"fields": others,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others),
}
)
if hidden_spec_fields:
fieldsets.append(
{
"title": "Advanced",
"fields": hidden_spec_fields,
"fieldsets": [],
"hidden": True,
"has_mandatory": self.has_mandatory_fields(hidden_spec_fields),
}
)
fieldsets.sort(key=lambda f: f.get("hidden", False))
return fieldsets
def get_nested_data(self):
"""
Builds the original nested JSON structure from flat form data.
Form fields are named with dot notation (e.g., 'spec.replicas')
"""
result = {}
for field_name, value in self.cleaned_data.items():
if value is None or value == "":
continue
parts = field_name.split(".")
current = result
# Navigate through the nested structure
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Last part, set the value
current[part] = value
else:
# Create nested dict if it doesn't exist
if part not in current:
current[part] = {}
current = current[part]
return result
def clean(self):
cleaned_data = super().clean()
self.validate_nested_data(
self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"]
)
return cleaned_data
def validate_nested_data(self, data, schema):
"""Validate data against the provided OpenAPI v3 schema"""
# TODO: actually validate the nested data.
# TODO: get jsonschema to give us a path to the failing field rather than just an error message,
# then add the validation error to that field (self.add_error())
# try:
# validate(instance=data, schema=schema)
# except Exception as e:
# raise forms.ValidationError(f"Validation error: {e.message}")
pass
def generate_model_form_class(model):
meta_attrs = {
"model": model,
"fields": "__all__",
}
fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
}
class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)

View file

@ -1,31 +0,0 @@
from servala.core.crd.forms import (
CrdModelFormMixin,
CustomFormMixin,
FormGeneratorMixin,
generate_custom_form_class,
generate_model_form_class,
)
from servala.core.crd.models import (
CRDModel,
build_object_fields,
duplicate_field,
generate_django_model,
get_django_field,
unnest_data,
)
from servala.core.crd.utils import deslugify
__all__ = [
"CrdModelFormMixin",
"CustomFormMixin",
"FormGeneratorMixin",
"generate_django_model",
"generate_model_form_class",
"generate_custom_form_class",
"CRDModel",
"build_object_fields",
"duplicate_field",
"get_django_field",
"unnest_data",
"deslugify",
]

View file

@ -1,479 +0,0 @@
from contextlib import suppress
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.forms.models import ModelForm, ModelFormMetaclass
from servala.core.crd.utils import deslugify
from servala.core.models import ControlPlaneCRD
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
# Fields that must be present in every form
MANDATORY_FIELDS = ["name"]
# Default field configurations - fields that can be included with just a mapping
# to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = {
"name": {
"type": "text",
"label": "Instance Name",
"help_text": "Unique name for the new instance",
"required": True,
"max_length": 63,
},
"spec.parameters.service.fqdn": {
"type": "array",
"label": "FQDNs",
"help_text": "Domain names for accessing this service",
"required": False,
},
"spec.parameters.size.disk": {
"type": "number",
"label": "Disk size",
"addon_text": "Gi",
},
}
class FormGeneratorMixin:
"""Shared base class for ModelForm classes based on our generated CRD models.
There are two relevant child classes:
- CrdModelFormMixin: For fully auto-generated forms from the spec
- CustomFormMixin: For forms built from form_config settings.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "context" in self.fields:
self.fields["context"].widget = forms.HiddenInput()
if crd := self.initial.get("context"):
crd = getattr(crd, "pk", crd) # can be int or object
self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd)
if self.instance and hasattr(self.instance, "name") and self.instance.name:
if "name" in self.fields:
self.fields["name"].disabled = True
self.fields["name"].widget = forms.HiddenInput()
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
class CrdModelFormMixin(FormGeneratorMixin):
HIDDEN_FIELDS = [
"spec.compositeDeletePolicy",
"spec.compositionRef",
"spec.compositionRevisionRef",
"spec.compositionRevisionSelector",
"spec.compositionSelector",
"spec.compositionUpdatePolicy",
"spec.parameters.monitoring.alertmanagerConfigRef",
"spec.parameters.monitoring.alertmanagerConfigSecretRef",
"spec.parameters.network.serviceType",
"spec.parameters.scheduling",
"spec.parameters.security",
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = self._meta.model.SCHEMA
for name, field in self.fields.items():
if name in self.HIDDEN_FIELDS or any(
name.startswith(f) for f in self.HIDDEN_FIELDS
):
field.widget = forms.HiddenInput()
field.required = False
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def get_fieldsets(self):
fieldsets = []
# General fieldset for non-spec fields
general_fields = [
field_name
for field_name in self.fields.keys()
if not field_name.startswith("spec.")
]
if general_fields:
fieldset = {
"title": "General",
"fields": general_fields,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(general_fields),
}
if all(
[
isinstance(self.fields[field].widget, forms.HiddenInput)
for field in general_fields
]
):
fieldset["hidden"] = True
fieldsets.append(fieldset)
# Process spec fields
others = []
top_level_fieldsets = {}
hidden_spec_fields = []
for field_name in self.fields:
if field_name.startswith("spec."):
if isinstance(self.fields[field_name].widget, forms.HiddenInput):
hidden_spec_fields.append(field_name)
continue
parts = field_name.split(".")
if len(parts) == 2:
# Top-level spec field
others.append(field_name)
elif len(parts) == 3:
# Second-level field - promote to top-level fieldset
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
top_level_fieldsets[fieldset_key]["fields"].append(field_name)
else:
# Third-level and deeper - create nested fieldsets
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
sub_key = parts[3]
if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key):
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][
"fields"
].append(field_name)
for fieldset in top_level_fieldsets.values():
nested_fieldsets_list = []
for sub_fieldset in fieldset["fieldsets"].values():
if len(sub_fieldset["fields"]) == 1:
# If nested fieldset has only one field, move it to parent
fieldset["fields"].append(sub_fieldset["fields"][0])
else:
# Keep as nested fieldset with proper title stripping
title = f"{fieldset['title']}: {sub_fieldset['title']}: "
for field in sub_fieldset["fields"]:
self.strip_title(field, title)
nested_fieldsets_list.append(sub_fieldset)
fieldset["fieldsets"] = nested_fieldsets_list
total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list)
if total_fields == 1 and len(fieldset["fields"]) == 1:
others.append(fieldset["fields"][0])
else:
title = f"{fieldset['title']}: "
for field in fieldset["fields"]:
self.strip_title(field, title)
all_fields = fieldset["fields"][:]
for sub_fieldset in nested_fieldsets_list:
all_fields.extend(sub_fieldset["fields"])
fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields)
fieldsets.append(fieldset)
# Add 'others' tab if there are any fields
if others:
fieldsets.append(
{
"title": "Others",
"fields": others,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others),
}
)
if hidden_spec_fields:
fieldsets.append(
{
"title": "Advanced",
"fields": hidden_spec_fields,
"fieldsets": [],
"hidden": True,
"has_mandatory": self.has_mandatory_fields(hidden_spec_fields),
}
)
fieldsets.sort(key=lambda f: f.get("hidden", False))
return fieldsets
def get_nested_data(self):
"""
Builds the original nested JSON structure from flat form data.
Form fields are named with dot notation (e.g., 'spec.replicas')
"""
result = {}
for field_name, value in self.cleaned_data.items():
if value is None or value == "":
continue
parts = field_name.split(".")
current = result
# Navigate through the nested structure
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Last part, set the value
current[part] = value
else:
# Create nested dict if it doesn't exist
if part not in current:
current[part] = {}
current = current[part]
return result
def clean(self):
cleaned_data = super().clean()
self.validate_nested_data(
self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"]
)
return cleaned_data
def validate_nested_data(self, data, schema):
"""Validate data against the provided OpenAPI v3 schema"""
# TODO: actually validate the nested data.
# TODO: get jsonschema to give us a path to the failing field rather than just an error message,
# then add the validation error to that field (self.add_error())
# try:
# validate(instance=data, schema=schema)
# except Exception as e:
# raise forms.ValidationError(f"Validation error: {e.message}")
pass
def generate_model_form_class(model):
meta_attrs = {
"model": model,
"fields": "__all__",
}
fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
}
class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)
class CustomFormMixin(FormGeneratorMixin):
"""
Base for custom (user-friendly) forms generated from ServiceDefinition.form_config.
"""
IS_CUSTOM_FORM = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._apply_field_config()
if (
self.instance
and hasattr(self.instance, "name")
and self.instance.name
and "name" in self.fields
):
self.fields["name"].widget = forms.HiddenInput()
self.fields["name"].disabled = True
self.fields.pop("context", None)
def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []):
for fc in fieldset.get("fields", []):
field_name = fc.get("controlplane_field_mapping")
if field_name not in self.fields:
continue
field_config = fc.copy()
# Merge with defaults if field has default config
if field_name in DEFAULT_FIELD_CONFIGS:
field_config = DEFAULT_FIELD_CONFIGS[field_name].copy()
for key, value in fc.items():
if value or (value is False):
field_config[key] = value
field = self.fields[field_name]
field_type = field_config.get("type")
field.label = field_config.get("label", field_name)
field.help_text = field_config.get("help_text", "")
field.required = field_config.get("required", False)
if field_type == "textarea":
field.widget = forms.Textarea(
attrs={"rows": field_config.get("rows", 4)}
)
elif field_type == "array":
field.widget = DynamicArrayWidget()
elif field_type == "choice":
if hasattr(field, "choices") and field.choices:
field._controlplane_choices = list(field.choices)
if custom_choices := field_config.get("choices"):
field.choices = [tuple(choice) for choice in custom_choices]
if field_type == "number":
min_val = field_config.get("min_value")
max_val = field_config.get("max_value")
unit = field_config.get("addon_text")
if unit:
field.widget = NumberInputWithAddon(addon_text=unit)
field.addon_text = unit
value = self.initial.get(field_name)
if value and isinstance(value, str) and value.endswith(unit):
numeric_value = value[: -len(unit)]
with suppress(ValueError):
if "." in numeric_value:
self.initial[field_name] = float(numeric_value)
else:
self.initial[field_name] = int(numeric_value)
validators = []
if min_val is not None:
validators.append(MinValueValidator(min_val))
field.widget.attrs["min"] = min_val
if max_val is not None:
validators.append(MaxValueValidator(max_val))
field.widget.attrs["max"] = max_val
if validators:
field.validators.extend(validators)
if "default_value" in field_config and field.initial is None:
field.initial = field_config["default_value"]
if field_type in ("text", "textarea") and field_config.get(
"max_length"
):
field.max_length = field_config.get("max_length")
if hasattr(field.widget, "attrs"):
field.widget.attrs["maxlength"] = field_config.get("max_length")
field.controlplane_field_mapping = field_name
def get_fieldsets(self):
fieldsets = []
for fieldset_config in self.form_config.get("fieldsets", []):
field_names = [
f["controlplane_field_mapping"]
for f in fieldset_config.get("fields", [])
]
fieldset = {
"title": fieldset_config.get("title", "General"),
"fields": field_names,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(field_names),
}
fieldsets.append(fieldset)
return fieldsets
def clean(self):
cleaned_data = super().clean()
for field_name, field in self.fields.items():
if hasattr(field, "_controlplane_choices"):
value = cleaned_data.get(field_name)
if value:
valid_values = [choice[0] for choice in field._controlplane_choices]
if value not in valid_values:
self.add_error(
field_name,
forms.ValidationError(
f"'{value}' is not a valid choice. "
f"Must be one of: {valid_values.join(', ')}"
),
)
return cleaned_data
def get_nested_data(self):
nested = {}
for field_name in self.fields.keys():
if field_name == "context":
value = self.cleaned_data.get(field_name)
if value is not None:
nested[field_name] = value
continue
mapping = field_name
value = self.cleaned_data.get(field_name)
field = self.fields[field_name]
if addon_text := getattr(field, "addon_text", None):
value = f"{value}{addon_text}"
parts = mapping.split(".")
current = nested
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return nested
def generate_custom_form_class(form_config, model):
"""
Generate a custom (user-friendly) form class from form_config JSON.
"""
field_list = ["context", "name"]
for fieldset in form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []):
field_name = field_config.get("controlplane_field_mapping")
if field_name:
field_list.append(field_name)
fields = {
"context": forms.ModelChoiceField(
queryset=ControlPlaneCRD.objects.none(),
required=True,
widget=forms.HiddenInput(),
),
}
meta_attrs = {
"model": model,
"fields": field_list,
}
form_fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
"form_config": form_config,
**fields,
}
class_name = f"{model.__name__}CustomForm"
return ModelFormMetaclass(class_name, (CustomFormMixin, ModelForm), form_fields)

View file

@ -1,167 +0,0 @@
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from servala.core.crd.utils import deslugify
from servala.core.models import ServiceInstance
from servala.frontend.forms.widgets import DynamicArrayField
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 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", "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 duplicate_field(field_name, model):
field = model._meta.get_field(field_name)
new_field = type(field).__new__(type(field))
new_field.__dict__.update(field.__dict__)
new_field.model = None
new_field.auto_created = False
return new_field
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 get_django_field(
field_schema, is_required, field_name, full_name, verbose_name_prefix=None
):
field_type = field_schema.get("type") or "string"
format = field_schema.get("format")
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
# Pass down the requirement status from parent to child fields
kwargs = {
"blank": not is_required, # All fields are optional by default
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
"verbose_name": verbose_name,
"default": field_schema.get("default"),
}
if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(minimum))
if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string":
if format == "date-time":
return models.DateTimeField(**kwargs)
elif format == "date":
return models.DateField(**kwargs)
else:
max_length = field_schema.get("max_length") or 255
if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=pattern))
if choices := field_schema.get("enum"):
kwargs["choices"] = ((choice, choice) for choice in choices)
return models.CharField(max_length=max_length, **kwargs)
elif field_type == "integer":
return models.IntegerField(**kwargs)
elif field_type == "number":
return models.FloatField(**kwargs)
elif field_type == "boolean":
return models.BooleanField(**kwargs)
elif field_type == "object":
# Here we pass down the requirement status to nested objects
return build_object_fields(
field_schema,
full_name,
verbose_name_prefix=f"{verbose_name}:",
parent_required=is_required,
)
elif field_type == "array":
kwargs["help_text"] = field_schema.get("description") or _("List of values")
field = models.JSONField(**kwargs)
formfield_kwargs = {
"label": field.verbose_name,
"required": not field.blank,
}
array_validation = {}
if min_items := field_schema.get("min_items"):
array_validation["min_items"] = min_items
if max_items := field_schema.get("max_items"):
array_validation["max_items"] = max_items
if unique_items := field_schema.get("unique_items"):
array_validation["unique_items"] = unique_items
if items_schema := field_schema.get("items"):
array_validation["items_schema"] = items_schema
if array_validation:
formfield_kwargs["array_validation"] = array_validation
field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
return field
return models.CharField(max_length=255, **kwargs)
def unnest_data(data):
result = {}
def _flatten_dict(d, parent_key=""):
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key else key
if isinstance(value, dict):
_flatten_dict(value, new_key)
else:
result[new_key] = value
_flatten_dict(data)
return result

View file

@ -1,115 +0,0 @@
import re
def deslugify(title):
"""
Convert camelCase, PascalCase, or snake_case to human-readable title.
Handles known acronyms (e.g., postgreSQLParameters -> 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)

View file

@ -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)

View file

@ -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),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",
),
),
]

View file

@ -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",

View file

@ -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)

View file

@ -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"<ul>{error_items}</ul>")
@classmethod
@transaction.atomic
def create_instance(cls, name, organization, context, created_by, spec_data):
# Ensure the namespace exists
context.control_plane.get_or_create_namespace(organization)
@ -755,7 +704,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
body=create_data,
)
except Exception as e:
# Transaction will automatically roll back the instance creation
instance.delete()
if isinstance(e, ApiException):
try:
error_body = json.loads(e.body)
@ -899,6 +848,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
return
return self.context.django_model(
name=self.name,
organization=self.organization,
context=self.context,
spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
@ -955,9 +905,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
import base64
for key, value in secret.data.items():
# Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters
if key.endswith("_HOST"):
continue
try:
credentials[key] = base64.b64decode(value).decode("utf-8")
except Exception:
@ -969,21 +916,5 @@ class ServiceInstance(ServalaModelMixin, models.Model):
except Exception as e:
return {"error": str(e)}
@property
def fqdn_url(self):
try:
fqdn = self.spec.get("parameters", {}).get("service", {}).get("fqdn")
if not fqdn:
return None
if isinstance(fqdn, list):
return fqdn[0]
elif isinstance(fqdn, str):
return fqdn
else:
return None
except (AttributeError, KeyError, IndexError):
return None
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)

View file

@ -207,19 +207,3 @@ def get_invoice_addresses(user):
return invoice_addresses or []
except Exception:
return []
def create_helpdesk_ticket(title, description, partner_id=None, sale_order_id=None):
ticket_data = {
"name": title,
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
"description": description,
}
if partner_id:
ticket_data["partner_id"] = partner_id
if sale_order_id:
ticket_data["sale_order_id"] = sale_order_id
return CLIENT.execute("helpdesk.ticket", "create", [ticket_data])

View file

@ -14,26 +14,20 @@ def has_organization_role(user, org, roles):
@rules.predicate
def is_organization_owner(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
return has_organization_role(user, org, [OrganizationRole.OWNER])
return has_organization_role(user, org, ["owner"])
@rules.predicate
def is_organization_admin(user, obj):
from servala.core.models.organization import OrganizationRole
if hasattr(obj, "organization"):
org = obj.organization
else:
org = obj
return has_organization_role(
user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
)
return has_organization_role(user, org, ["owner", "admin"])
@rules.predicate

View file

@ -1,107 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Service Definition Form Configuration Schema",
"description": "Schema for custom form configuration in ServiceDefinition",
"type": "object",
"required": ["fieldsets"],
"properties": {
"fieldsets": {
"type": "array",
"description": "Array of fieldset objects defining form sections",
"minItems": 1,
"items": {
"type": "object",
"required": ["fields"],
"properties": {
"title": {
"type": "string",
"description": "Optional title for the fieldset/tab"
},
"fields": {
"type": "array",
"description": "Array of field definitions in this fieldset",
"minItems": 1,
"items": {
"type": "object",
"required": ["controlplane_field_mapping"],
"properties": {
"type": {
"type": "string",
"description": "Field type",
"enum": ["", "text", "email", "textarea", "number", "choice", "checkbox", "array"]
},
"label": {
"type": "string",
"description": "Human-readable field label"
},
"help_text": {
"type": "string",
"description": "Optional help text displayed below the field"
},
"required": {
"type": "boolean",
"description": "Whether the field is required",
"default": false
},
"controlplane_field_mapping": {
"type": "string",
"description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')"
},
"max_length": {
"type": ["integer", "null"],
"description": "Maximum length for text/textarea fields",
"minimum": 1
},
"rows": {
"type": ["integer", "null"],
"description": "Number of rows for textarea fields",
"minimum": 1
},
"min_value": {
"type": ["number", "null"],
"description": "Minimum value for number fields"
},
"max_value": {
"type": ["number", "null"],
"description": "Maximum value for number fields"
},
"choices": {
"type": "array",
"description": "Array of [value, label] pairs for choice fields",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"min_values": {
"type": ["integer", "null"],
"description": "Minimum number of values for array fields",
"minimum": 0
},
"max_values": {
"type": ["integer", "null"],
"description": "Maximum number of values for array fields",
"minimum": 1
},
"validators": {
"type": "array",
"description": "Array of validator names (for future use)",
"items": {
"type": "string",
"enum": ["email", "fqdn", "url", "ipv4", "ipv6"]
}
},
"default_value": {
"type": "string",
"description": "Default value for the field when creating new instances"
}
}
}
}
}
}
}
}
}

View file

@ -1,12 +1,5 @@
from django.conf import settings
def add_organizations(request):
if not request.user.is_authenticated:
return {"user_organizations": []}
return {"user_organizations": request.user.organizations.all().order_by("name")}
def add_beta_banner(request):
return {"show_beta_banner": settings.SERVALA_SHOW_BETA_BANNER}

View file

@ -1,73 +1,19 @@
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from servala.core.models import Organization, OrganizationInvitation, OrganizationRole
from servala.core.models import Organization
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
class OrganizationForm(HtmxMixin, ModelForm):
name_validator = RegexValidator(
regex=f"^{ORG_NAME_PATTERN}$",
message=_(
"Organization name can only contain letters, numbers, spaces, and common punctuation (-.,&'()+)."
),
)
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
# if self.instance and self.instance.has_inherited_billing_entity:
# TODO disable billing entity editing
class Meta:
model = Organization
fields = ("name",)
widgets = {
"name": forms.TextInput(
attrs={
"maxlength": "100",
"pattern": ORG_NAME_PATTERN,
"title": _(
"Organization name can contain letters, numbers, spaces, and common punctuation (-.,&'()+). Emoji not allowed."
),
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["name"].validators.append(self.name_validator)
self.fields["name"].max_length = 100
class OrganizationCreateForm(OrganizationForm):
address_validator = RegexValidator(
regex=r"^[\w\s\.,\-/()\']+$",
message=_(
"This field can only contain letters, numbers, spaces, and basic punctuation (.,-/()')."
),
)
city_validator = RegexValidator(
regex=r"^[\w\s\-\']+$",
message=_("City name contains invalid characters."),
)
postal_code_validator = RegexValidator(
regex=r"^[\w\s\-]+$",
message=_(
"Postal code can only contain letters, numbers, spaces, and hyphens."
),
)
phone_validator = RegexValidator(
regex=r"^[0-9\s\+\-()]+$",
message=_(
"Phone number can only contain numbers, spaces, and basic punctuation (+,-,())."
),
)
billing_processing_choice = forms.ChoiceField(
choices=[
("existing", _("Use an existing billing address")),
@ -83,93 +29,24 @@ class OrganizationCreateForm(OrganizationForm):
)
# Fields for creating a new billing address in Odoo, prefixed with 'invoice_'
invoice_street = forms.CharField(
label=_("Line 1"),
required=False,
max_length=128,
validators=[address_validator],
widget=forms.TextInput(
attrs={
"maxlength": "128",
"title": _(
"Letters, numbers, spaces, and basic punctuation allowed. Emoji not allowed."
),
}
),
)
invoice_street2 = forms.CharField(
label=_("Line 2"),
required=False,
max_length=128,
validators=[address_validator],
widget=forms.TextInput(
attrs={
"maxlength": "128",
"title": _(
"Letters, numbers, spaces, and basic punctuation allowed. Emoji not allowed."
),
}
),
)
invoice_city = forms.CharField(
label=_("City"),
required=False,
max_length=64,
validators=[city_validator],
widget=forms.TextInput(
attrs={
"maxlength": "64",
"title": _(
"Letters, spaces, hyphens, and apostrophes allowed. Emoji not allowed."
),
}
),
)
invoice_zip = forms.CharField(
label=_("Postal Code"),
required=False,
max_length=20,
validators=[postal_code_validator],
widget=forms.TextInput(
attrs={
"maxlength": "20",
"title": _(
"Letters, numbers, spaces, and hyphens allowed. Emoji not allowed."
),
}
),
)
invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100)
invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100)
invoice_city = forms.CharField(label=_("City"), required=False, max_length=100)
invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20)
invoice_country = forms.ChoiceField(
label=_("Country"),
required=False,
choices=get_odoo_countries(),
)
invoice_email = forms.EmailField(
label=_("Billing Email"),
required=False,
max_length=254,
widget=forms.EmailInput(attrs={"maxlength": "254"}),
)
invoice_phone = forms.CharField(
label=_("Phone"),
required=False,
max_length=30,
validators=[phone_validator],
widget=forms.TextInput(
attrs={
"maxlength": "30",
"pattern": r"[0-9\s\+\-()]+",
"title": _("Only numbers, spaces, and basic punctuation allowed"),
}
),
)
invoice_email = forms.EmailField(label=_("Billing Email"), required=False)
invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30)
class Meta(OrganizationForm.Meta):
pass
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
if not self.initial.get("invoice_country"):
default_country_name = "Switzerland"
country_choices = self.fields["invoice_country"].choices
@ -178,6 +55,7 @@ class OrganizationCreateForm(OrganizationForm):
self.initial["invoice_country"] = country_id
break
self.user = user
self.odoo_addresses = get_invoice_addresses(self.user)
if self.odoo_addresses:
@ -230,68 +108,3 @@ class OrganizationCreateForm(OrganizationForm):
"existing_odoo_address_id", _("Please select an invoice address.")
)
return cleaned_data
class OrganizationInvitationForm(forms.ModelForm):
def __init__(self, *args, organization=None, user_role=None, **kwargs):
super().__init__(*args, **kwargs)
self.organization = organization
self.user_role = user_role
if user_role:
allowed_roles = self._get_allowed_roles(user_role)
self.fields["role"].choices = [
(value, label)
for value, label in OrganizationRole.choices
if value in allowed_roles
]
def _get_allowed_roles(self, user_role):
role_hierarchy = {
OrganizationRole.OWNER: [
OrganizationRole.OWNER,
OrganizationRole.ADMIN,
OrganizationRole.MEMBER,
],
OrganizationRole.ADMIN: [
OrganizationRole.ADMIN,
OrganizationRole.MEMBER,
],
OrganizationRole.MEMBER: [],
}
return role_hierarchy.get(user_role, [])
def clean_email(self):
email = self.cleaned_data["email"].lower()
if self.organization.members.filter(email__iexact=email).exists():
raise ValidationError(
_("A user with this email is already a member of this organization.")
)
if OrganizationInvitation.objects.filter(
organization=self.organization,
email__iexact=email,
accepted_by__isnull=True,
).exists():
raise ValidationError(
_("An invitation has already been sent to this email address.")
)
return email
def save(self, commit=True):
invitation = super().save(commit=False)
invitation.organization = self.organization
if commit:
invitation.save()
return invitation
class Meta:
model = OrganizationInvitation
fields = ("email", "role")
widgets = {
"email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}),
"role": forms.RadioSelect(),
}

View file

@ -21,15 +21,6 @@ class ServiceFilterForm(forms.Form):
)
q = forms.CharField(label=_("Search"), required=False)
def __init__(self, *args, organization=None, **kwargs):
super().__init__(*args, **kwargs)
if organization and organization.limit_cloudproviders.exists():
allowed_providers = organization.limit_cloudproviders
if allowed_providers.count() <= 1:
self.fields.pop("cloud_provider", None)
else:
self.fields["cloud_provider"].queryset = allowed_providers
def filter_queryset(self, queryset):
if category := self.cleaned_data.get("category"):
queryset = queryset.filter(category=category)

View file

@ -2,7 +2,6 @@ import json
from django import forms
from django.core.exceptions import ValidationError
from django.forms.widgets import NumberInput
class DynamicArrayWidget(forms.Widget):
@ -217,21 +216,3 @@ class DynamicArrayField(forms.JSONField):
raise ValidationError(
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
)
class NumberInputWithAddon(NumberInput):
"""
Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
Renders as a Bootstrap input-group with the suffix displayed as an add-on.
"""
template_name = "frontend/forms/number_input_with_addon.html"
def __init__(self, addon_text="", attrs=None):
super().__init__(attrs)
self.addon_text = addon_text
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["widget"]["addon_text"] = self.addon_text
return context

View file

@ -5,7 +5,7 @@
{% translate "Sign in" %}
{% endblock html_title %}
{% block page_title %}
{% translate "Welcome to Servala - Sovereign App Store" %}
{% translate "Welcome to Servala" %}
{% endblock page_title %}
{% block card_header %}
<div class="card-header text-center py-4"
@ -26,23 +26,21 @@
<div class="text-center mb-4">
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
<p class="text-muted mb-0">
{% 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" %}
</p>
</div>
{% for provider in socialaccount_providers %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form method="post"
action="{{ href }}"
class="d-flex justify-content-center">
<form method="post" action="{{ href }}">
{% csrf_token %}
{{ redirect_field }}
<button type="submit"
class="btn btn-primary btn-lg py-2 px-4 mb-4 fw-semibold"
class="btn btn-primary btn-lg w-100 py-3 mb-4 fw-semibold"
title="{{ provider.name }}"
style="border-radius: 12px;
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
<span>{% translate "Sign in or Register" %}</span>
<span>{% translate "Sign in with VSHN Account" %}</span>
</button>
</form>
{% endfor %}

View file

@ -22,7 +22,6 @@
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>
<div id="app">
<div id="main" class="layout-horizontal">
{% include 'includes/beta_banner.html' %}
{% include 'includes/header.html' %}
<div class="content-wrapper container">
<div class="page-heading">
@ -66,8 +65,6 @@
<div class="float-end">
<p>
Crafted with <span class="text-danger"><i class="bi bi-heart-fill icon-mid"></i></span> in Zurich
{% load version_tags %}
- {% get_version_or_env %}
</p>
</div>
</div>
@ -81,20 +78,13 @@
<script src="{% static 'js/dynamic-array.js' %}"></script>
<!-- Ybug code start (https://ybug.io) -->
<script type='text/javascript'>
(function() {
window.ybug_settings = {
"id": "q1tgbdjp26ydh8gygggv"
};
var ybug = document.createElement('script');
ybug.type = 'text/javascript';
ybug.async = true;
ybug.src = 'https://widget.ybug.io/button/' + window.ybug_settings.id + '.js';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(ybug, s);
})();
(function() {
window.ybug_settings = {"id":"q1tgbdjp26ydh8gygggv"};
var ybug = document.createElement('script'); ybug.type = 'text/javascript'; ybug.async = true;
ybug.src = 'https://widget.ybug.io/button/'+window.ybug_settings.id+'.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ybug, s);
})();
</script>
<!-- Ybug code end -->
{% block extra_js %}
{% endblock extra_js %}
</body>
</html>

View file

@ -1,9 +1,6 @@
<div class="dynamic-array-widget"
id="{{ widget.name }}_container"
data-name="{{ widget.name }}"
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
{% endif %}
{% endfor %}>
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
data-name="{{ widget.name }}">
<div class="array-items">
{% for item in value_list %}
<div class="array-item d-flex mb-2">

View file

@ -14,7 +14,7 @@
{% endif %}
{% if field.use_fieldset %}</fieldset>{% endif %}
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
{% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %}
{% if field.help_text %}
<small class="form-text text-muted"
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
{% endif %}

View file

@ -1,11 +0,0 @@
<div class="input-group">
<input type="{{ widget.type }}"
name="{{ widget.name }}"
{% if widget.value != None %}value="{{ widget.value }}"{% endif %}
{% if widget.attrs.id %}id="{{ widget.attrs.id }}"{% endif %}
{% for name, value in widget.attrs.items %} {% if value is not False and name != "id" %} {{ name }}{% if value is not True %}="{{ value }}"{% endif %}
{% endif %}
{% endfor %}
class="form-control{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}" />
<span class="input-group-text">{{ widget.addon_text }}</span>
</div>

View file

@ -1,7 +1,7 @@
{% extends "frontend/base.html" %}
{% load i18n static %}
{% block html_title %}
{% translate "Dashboard" %} for {{ object.name }}
{{ object.name }} {% translate "Dashboard" %}
{% endblock html_title %}
{% block page_title %}{% endblock %}
{% block content %}
@ -87,6 +87,7 @@
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Service" %}</th>
<th>{% translate "Status" %}</th>
<th>{% translate "Created" %}</th>
<th>{% translate "Actions" %}</th>
</tr>
@ -95,7 +96,7 @@
{% for instance in service_instances %}
<tr>
<td>
<a href="{{ instance.urls.base }}"
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
class="fw-semibold text-decoration-none">{{ instance.name }}</a>
</td>
<td>
@ -116,13 +117,13 @@
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ instance.urls.base }}"
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
class="btn btn-outline-primary btn-sm"
title="{% translate 'View Details' %}">
<i class="bi bi-eye"></i>
</a>
{% if instance.has_change_permission %}
<a href="{{ instance.urls.update }}"
<a href="{% url 'frontend:organization.instance.update' organization=object.slug slug=instance.name %}"
class="btn btn-outline-secondary btn-sm"
title="{% translate 'Edit' %}">
<i class="bi bi-pencil"></i>

View file

@ -1,42 +0,0 @@
{% extends "frontend/base.html" %}
{% load i18n %}
{% block html_title %}
{% block page_title %}
{% translate "Accept Organization Invitation" %}
{% endblock page_title %}
{% endblock html_title %}
{% block content %}
<section class="section">
<div class="card">
<div class="card-content">
<div class="card-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
{% blocktranslate with org_name=invitation.organization.name role=invitation.get_role_display %}
You have been invited to join <strong>{{ org_name }}</strong> as a <strong>{{ role }}</strong>.
{% endblocktranslate %}
</div>
{% if user.email|lower != invitation.email|lower %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
{% blocktranslate with invitation_email=invitation.email user_email=user.email %}
<strong>Note:</strong> This invitation was sent to <strong>{{ invitation_email }}</strong>,
but you are currently logged in as <strong>{{ user_email }}</strong>.
{% endblocktranslate %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-end gap-2">
<a href="{% url 'frontend:organization.selection' %}"
class="btn btn-secondary">{% translate "Cancel" %}</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> {% translate "Accept Invitation" %}
</button>
</div>
</form>
</div>
</div>
</div>
</section>
{% endblock content %}

View file

@ -32,7 +32,13 @@
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %}
{% include "includes/external_link.html" %}
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %}
</div>
</div>
@ -41,7 +47,7 @@
</div>
</div>
<div class="row match-height card-grid">
{% for offering in visible_offerings %}
{% for offering in service.offerings.all %}
<div class="col-6 col-lg-3 col-md-4">
<div class="card">
<div class="card-header card-header-with-logo">

View file

@ -7,15 +7,6 @@
{% endblock html_title %}
{% block page_title_extra %}
<div>
{% if instance.fqdn_url %}
<a href="https://{{ instance.fqdn_url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-success me-1 mb-1">
<i class="bi bi-box-arrow-up-right me-1"></i>
{% translate "Open" %}
</a>
{% endif %}
{% if has_change_permission %}
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
{% endif %}
@ -111,16 +102,7 @@
</div>
</div>
{% endif %}
{% if control_plane.user_info %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
</div>
<div class="card-content">
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
</div>
</div>
{% endif %}
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
</div>
{% if instance.spec and spec_fieldsets %}
<div class="col-12">
@ -191,36 +173,34 @@
</div>
{% endif %}
{% if instance.connection_credentials %}
<div class="col-12">
<div class="card">
<div class="card-header">
<h4>{% translate "Connection Credentials" %}</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<div class="card">
<div class="card-header">
<h4>{% translate "Connection Credentials" %}</h4>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
</tr>
</thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Value" %}</th>
<td>{{ key }}</td>
<td>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code>{{ value }}</code>
{% endif %}
</td>
</tr>
</thead>
<tbody>
{% for key, value in instance.connection_credentials.items %}
<tr>
<td>{{ key }}</td>
<td>
{% if key == "error" %}
<span class="text-danger">{{ value }}</span>
{% else %}
<code>{{ value }}</code>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
@ -257,12 +237,3 @@
</div>
</div>
{% endblock content %}
{% block extra_js %}
<script>
// Initialize Bootstrap popovers for help text
document.addEventListener('DOMContentLoaded', function() {
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
});
</script>
{% endblock extra_js %}

View file

@ -13,7 +13,7 @@
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
{% endblock page_title_extra %}
{% partialdef service-form %}
{% if form or custom_form %}
{% if form %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
@ -22,7 +22,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
{% include "includes/tabbed_fieldset_form.html" with form=form %}
{% endif %}
</div>
</div>
@ -31,7 +31,7 @@
{% block content %}
<section class="section">
<div class="card">
{% if not form and not custom_form %}
{% if not form %}
<div class="alert alert-warning" role="alert">
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
</div>

View file

@ -7,17 +7,13 @@
{{ offering }}
{% endblock page_title %}
{% endblock html_title %}
{% partialdef control-plane-info inline=True %}
{% if selected_plane and selected_plane.user_info %}
<div class="mt-3">
<div class="border-top pt-3">
{% include "includes/control_plane_user_info.html" with control_plane=selected_plane %}
</div>
</div>
{% partialdef control-plane-info %}
{% if selected_plane %}
{% include "includes/control_plane_user_info.html" with control_plane=selected_plane %}
{% endif %}
{% endpartialdef %}
{% partialdef service-form %}
{% if service_form or custom_service_form %}
{% if service_form %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
@ -26,7 +22,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
{% endif %}
</div>
</div>
@ -34,127 +30,69 @@
{% endpartialdef %}
{% block content %}
<section class="section">
{% if not has_control_planes %}
<!-- No Service Available Message -->
<div class="row">
<div class="col-12">
<div class="alert alert-warning d-flex align-items-center" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>{% translate "Service Unavailable" %}</strong>
<p class="mb-0">
{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
</p>
<div class="row">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-header d-flex align-items-center">
{% if service.logo %}
<img src="{{ service.logo.url }}"
alt="{{ service.name }}"
class="me-3"
style="max-width: 48px;
max-height: 48px">
{% endif %}
<div class="d-flex flex-column">
<h4 class="mb-0">{{ offering }}</h4>
<small class="text-muted">{{ offering.service.category }}</small>
</div>
</div>
</div>
</div>
{% else %}
<!-- Two Column Layout -->
<div class="row g-3">
<!-- Left Column: Service Provider Zone -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">{% translate "Service Provider Zone" %}</h5>
</div>
<div class="card-body">
<div class="card-body">
{% if offering.description %}
<div class="row mb-3">
<div class="col-12">
<p>{{ offering.description|urlize }}</p>
</div>
</div>
{% endif %}
{% if not has_control_planes %}
<p>{% translate "We currently cannot offer this service, sorry!" %}</p>
{% else %}
<form hx-trigger="change"
hx-get="{{ request.path }}?fragment=service-form"
hx-target="#service-form"
hx-swap="outerHTML"
class="control-plane-select-form">
hx-swap="outerHTML">
{{ select_form }}
</form>
<style>
.control-plane-select-form .form-label {
display: none;
}
</style>
<div id="control-plane-info"
hx-trigger="load, change from:form"
hx-get="{{ request.path }}?fragment=control-plane-info">
{% partial control-plane-info %}
{% endif %}
{% if service.external_links %}
<div class="row mt-3">
<div class="col-12">
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in service.external_links %}
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Right Column: Service Information -->
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">{% translate "Service Information" %}</h5>
</div>
<div class="card-body">
{% if offering.service.logo or offering.description %}
<div class="d-flex gap-3 mb-3">
{% if offering.service.logo %}
<div class="flex-shrink-0">
<img src="{{ offering.service.logo.url }}"
alt="{{ offering.service.name }}"
style="max-width: 64px;
max-height: 64px">
</div>
{% endif %}
{% if offering.description %}
<div class="flex-grow-1">
<p class="mb-0">{{ offering.description|urlize }}</p>
</div>
{% endif %}
</div>
{% endif %}
{% if offering.service.external_links or offering.external_links %}
{% if offering.service.logo or offering.description %}<hr class="my-3">{% endif %}
<h6 class="mb-3">{% translate "External Links" %}</h6>
<div class="d-flex flex-wrap gap-2">
{% for link in offering.service.external_links %}
{% include "includes/external_link.html" %}
{% endfor %}
{% for link in offering.external_links %}
{% include "includes/external_link.html" %}
{% endfor %}
</div>
{% else %}
{% if not offering.service.logo and not offering.description %}
<p class="text-muted mb-0">{% translate "No additional information available." %}</p>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
<!-- Service Form (unchanged) -->
<div class="row mt-3">
<div class="col-12">
<div id="service-form">{% partial service-form %}</div>
</div>
<div class="col-12 col-lg-4">
{% if has_control_planes %}
<div id="control-plane-info"
hx-trigger="load, change from:form"
hx-get="{{ request.path }}?fragment=control-plane-info">{% partial control-plane-info %}</div>
{% endif %}
</div>
</div>
</section>
{% endblock content %}
{% block extra_js %}
{% if wildcard_dns and organization_namespace %}
<script>
const fqdnConfig = {
wildcardDns: '{{ wildcard_dns }}',
namespace: '{{ organization_namespace }}'
};
</script>
<script defer src="{% static "js/fqdn.js" %}"></script>
{% endif %}
<script>
// Initialize Bootstrap popovers for help text
document.addEventListener('DOMContentLoaded', function() {
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
});
// Re-initialize popovers after HTMX swaps
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'control-plane-info') {
const popoverTriggerList = event.detail.target.querySelectorAll('[data-bs-toggle="popover"]');
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
}
});
</script>
{% endblock extra_js %}

View file

@ -16,37 +16,50 @@
</div>
</div>
</div>
<div class="row match-height card-grid service-cards-container {% if not deactivated_services %}mb-5{% endif %}">
<div class="row match-height card-grid service-cards-container mb-5">
{% for service in services %}
<div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
{% empty %}
<div class="col-12">
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="card-content">
<p>{% translate "No services found." %}</p>
<div class="card-header card-header-with-logo">
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<div class="card-header-content">
<h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-content">
<div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
{% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
</div>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
<div class="card-content">
<p>{% translate "No services found." %}</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% if deactivated_services %}
<div class="card">
<div class="card-body">
<h5 class="card-title">{% translate "You may also be interested in one of these …" %}</h5>
<p class="text-muted">
<i class="bi bi-info-circle mt-1"></i>
{% translate "These services need to be enabled first before they become available in the Servala portal." %}
</p>
</div>
</div>
<div class="row match-height card-grid service-cards-container mb-5">
{% for service in deactivated_services %}
<div class="col-12 col-md-6 col-lg-3 service-deactivated">{% include "includes/service_card.html" %}</div>
{% endfor %}
</div>
{% endif %}
</section>
<script src="{% static "js/autosubmit.js" %}" defer></script>
{% endblock content %}

View file

@ -36,97 +36,6 @@
</form>
</td>
{% endpartialdef org-name-edit %}
{% partialdef members-list %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Name" %}</th>
<th>{% translate "Email" %}</th>
<th>{% translate "Role" %}</th>
<th>{% translate "Joined" %}</th>
</tr>
</thead>
<tbody>
{% for membership in memberships %}
<tr>
<td>{{ membership.user }}</td>
<td>{{ membership.user.email }}</td>
<td>
<span class="badge bg-{% if membership.role == 'owner' %}primary{% elif membership.role == 'admin' %}info{% else %}secondary{% endif %}">
{{ membership.get_role_display }}
</span>
</td>
<td>{{ membership.date_joined|date:"Y-m-d" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-muted text-center">{% translate "No members yet" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endpartialdef members-list %}
{% partialdef pending-invitations-card %}
{% if pending_invitations %}
<div class="card">
<div class="card-header">
<h4 class="card-title">
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
</h4>
</div>
<div class="card-content">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Email" %}</th>
<th>{% translate "Role" %}</th>
<th>{% translate "Sent" %}</th>
<th>{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for invitation in pending_invitations %}
<tr>
<td>{{ invitation.email }}</td>
<td>
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
{{ invitation.get_role_display }}
</span>
</td>
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
</button>
<form method="post"
action="{{ invitation.urls.delete }}"
style="display: inline"
hx-post="{{ invitation.urls.delete }}"
hx-target="#pending-invitations-card"
hx-swap="outerHTML"
hx-confirm="{% translate 'Are you sure you want to delete this invitation?' %}">
{% csrf_token %}
<input type="hidden" name="fragment" value="pending-invitations-card">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> {% translate "Delete" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endpartialdef pending-invitations-card %}
{% block content %}
<section class="section">
<div class="card">
@ -156,136 +65,67 @@
</div>
</div>
</div>
{% if not form.instance.origin.hide_billing_address %}
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Billing Address" %}</h4>
{% if form.instance.has_inherited_billing_entity %}
<p class="text-muted">
<small>{% translate "This billing address cannot be modified." %}</small>
</p>
{% endif %}
</div>
<div class="card-content">
<div class="card-body">
{% with odoo_data=form.instance.billing_entity.odoo_data %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
{% if odoo_data.invoice_address %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
</th>
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
</tr>
<tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
</tr>
{% if odoo_data.invoice_address.street2 %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street2 }}</td>
</tr>
{% endif %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "City" %}</span>
</th>
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
</th>
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Country" %}</span>
</th>
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
</tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
</th>
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endwith %}
</div>
</div>
</div>
{% endif %}
{% elif form.instance.origin.billing_message %}
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Billing Information" %}</h4>
<h4 class="card-title">{% translate "Billing Address" %}</h4>
</div>
<div class="card-content">
<div class="card-body">
<p>{{ form.instance.origin.billing_message }}</p>
</div>
</div>
</div>
{% endif %}
{% if can_manage_members %}
<div class="card">
<div class="card-header">
<h4 class="card-title">
<i class="bi bi-people"></i> {% translate "Members" %}
</h4>
</div>
<div class="card-content">
<div class="card-body">{% partial members-list %}</div>
</div>
</div>
<div id="pending-invitations-card">{% partial pending-invitations-card %}</div>
<div class="card">
<div class="card-header">
<h4 class="card-title">
<i class="bi bi-person-plus"></i> {% translate "Invite New Member" %}
</h4>
</div>
<div class="card-content">
<div class="card-body">
<div class="alert alert-light mb-3">
<h6>
<i class="bi bi-info-circle"></i> {% translate "Role Permissions" %}
</h6>
<ul class="mb-0">
<li>
<strong>{% translate "Owner" %}:</strong> {% translate "Can manage all organization settings, members, services, and can appoint administrators." %}
</li>
<li>
<strong>{% translate "Administrator" %}:</strong> {% translate "Can manage members, invite users, and manage all services and instances." %}
</li>
<li>
<strong>{% translate "Member" %}:</strong> {% translate "Can view organization details, create and manage their own service instances." %}
</li>
</ul>
</div>
<form method="post" class="form">
{% csrf_token %}
<div class="row">{{ invitation_form }}</div>
<div class="row mt-3">
<div class="col-12">
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
</button>
</div>
{% with odoo_data=form.instance.billing_entity.odoo_data %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
{% if odoo_data.invoice_address %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
</th>
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
</tr>
<tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
</tr>
{% if odoo_data.invoice_address.street2 %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street2 }}</td>
</tr>
{% endif %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "City" %}</span>
</th>
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
</th>
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Country" %}</span>
</th>
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
</tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
</th>
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</form>
{% endwith %}
</div>
</div>
</div>

View file

@ -1,13 +0,0 @@
{% if show_beta_banner %}
<div class="beta-banner">
<div class="container">
<div class="beta-banner-content">
<span class="beta-banner-badge">BETA</span>
<span class="beta-banner-text">The Servala Portal is currently in beta testing. Your feedback helps us improve!</span>
<button type="button"
class="btn btn-sm beta-banner-button"
onclick="if(window.Ybug) { Ybug.open(); }">Share Feedback</button>
</div>
</div>
</div>
{% endif %}

View file

@ -1,24 +1,26 @@
{% load i18n %}
{% comment %}
Reusable snippet for displaying ControlPlane user_info
Usage: {% include "includes/control_plane_user_info.html" with control_plane=control_plane_object %}
{% endcomment %}
{% if control_plane.user_info %}
<div class="control-plane-info-list">
{% for info in control_plane.user_info %}
<div class="info-item mb-3">
<div class="d-flex align-items-center mb-1">
<small class="text-muted fw-semibold">{{ info.title }}</small>
{% if info.help_text %}
<i class="bi bi-info-circle ms-1 text-muted"
data-bs-toggle="popover"
data-bs-trigger="hover focus"
data-bs-placement="top"
data-bs-content="{{ info.help_text }}"
style="cursor: help;
font-size: 0.875rem"></i>
{% endif %}
</div>
<div class="bg-light-subtle p-2 rounded">
<code class="text-dark">{{ info.content }}</code>
</div>
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
</div>
<div class="card-content">
<div class="table-responsive">
<table class="table mb-0 table-lg">
<tbody>
{% for key, value in control_plane.user_info.items %}
<tr>
<th>{{ key }}</th>
<td>{{ value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
{% endif %}

View file

@ -1,7 +0,0 @@
<a href="{{ link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary btn-sm">
{{ link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>

View file

@ -130,7 +130,7 @@
{% else %}
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Sign in' %}</span>
<span>{% translate 'Login' %}</span>
</a>
{% endif %}
<a href="#" class="burger-btn d-block d-xl-none">

View file

@ -1,31 +0,0 @@
{% load i18n %}
<div class="card">
<div class="card-header card-header-with-logo">
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
<div class="card-header-content">
<h4>{{ service.name }}</h4>
<small class="text-muted">{{ service.category }}</small>
</div>
</div>
<div class="card-content">
<div class="card-body flex-grow-1">
{% if service.description %}<p class="card-text">{{ service.description|urlize }}</p>{% endif %}
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center gap-2">
{% if service.featured_links %}
{% with featured_link=service.featured_links.0 %}
<a href="{{ featured_link.url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-outline-primary">
{{ featured_link.title }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% endwith %}
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Get It" %}</a>
</div>
</div>

View file

@ -1,149 +1,56 @@
{% load i18n %}
{% load get_field %}
{% load static %}
<form class="form form-vertical crd-form"
method="post"
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %}
{% include "frontend/forms/errors.html" %}
{% if form and expert_form and not hide_expert_mode %}
<div class="mb-3 text-end">
<a href="#"
class="text-muted small"
id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div>
{% endif %}
<div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
{% if form and form.context %}{{ form.context }}{% endif %}
{% if form and form.get_fieldsets|length == 1 %}
{# Single fieldset - render without tabs #}
{% for fieldset in form.get_fieldsets %}
<div class="my-2">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% elif form %}
{# Multiple fieldsets or auto-generated form - render with tabs #}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#custom-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="custom-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="custom-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
<ul class="nav nav-tabs" id="expertTab" role="tablist">
{% for fieldset in expert_form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="expert-{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#expert-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="expert-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="expertTabContent">
{% for fieldset in expert_form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="expert-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if form %}
<input type="hidden"
name="active_form"
id="active-form-input"
value="custom">
{% endif %}
{% endfor %}
</div>
<div class="col-sm-12 d-flex justify-content-end">
{# browser form validation fails when there are fields missing/invalid that are hidden #}
<input class="btn btn-primary me-1 mb-1"
type="submit"
{% if form and expert_form %}formnovalidate{% endif %}
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
<button class="btn btn-primary me-1 mb-1" type="submit">
{% if form_submit_label %}
{{ form_submit_label }}
{% else %}
{% translate "Save" %}
{% endif %}
</button>
</div>
</form>
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
{% if form and not hide_expert_mode %}
<script defer src="{% static 'js/expert-mode.js' %}"></script>
{% endif %}

View file

@ -1,5 +1,3 @@
from contextlib import suppress
from django import template
register = template.Library()
@ -7,5 +5,4 @@ register = template.Library()
@register.filter
def get_field(form, field_name):
with suppress(KeyError):
return form[field_name]
return form[field_name]

View file

@ -1,16 +0,0 @@
import os
from django import template
from servala.__about__ import __version__
register = template.Library()
@register.simple_tag
def get_version_or_env():
"""Return version number in production, environment name otherwise."""
env = os.environ.get("SERVALA_ENVIRONMENT", "development")
if env == "production":
return __version__
return env

View file

@ -6,11 +6,6 @@ from servala.frontend import views
urlpatterns = [
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
path(
"invitations/<str:secret>/accept/",
views.InvitationAcceptView.as_view(),
name="invitation.accept",
),
path(
"organizations/",
views.OrganizationSelectionView.as_view(),
@ -30,11 +25,6 @@ urlpatterns = [
views.OrganizationUpdateView.as_view(),
name="organization.details",
),
path(
"details/invitations/<int:pk>/delete/",
views.InvitationDeleteView.as_view(),
name="invitation.delete",
),
path(
"services/",
views.ServiceListView.as_view(),

View file

@ -8,8 +8,6 @@ from .generic import (
custom_500,
)
from .organization import (
InvitationAcceptView,
InvitationDeleteView,
OrganizationCreateView,
OrganizationDashboardView,
OrganizationUpdateView,
@ -27,8 +25,6 @@ from .support import SupportView
__all__ = [
"IndexView",
"InvitationAcceptView",
"InvitationDeleteView",
"LogoutView",
"OrganizationCreateView",
"OrganizationDashboardView",

View file

@ -1,31 +1,16 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, DetailView, TemplateView
from django_scopes import scopes_disabled
from django.views.generic import CreateView, DetailView
from rules.contrib.views import AutoPermissionRequiredMixin
from servala.core.models import (
BillingEntity,
Organization,
OrganizationInvitation,
OrganizationMembership,
ServiceInstance,
)
from servala.frontend.forms.organization import (
OrganizationCreateForm,
OrganizationForm,
OrganizationInvitationForm,
)
from servala.frontend.views.mixins import (
HtmxUpdateView,
HtmxViewMixin,
OrganizationViewMixin,
)
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
@ -111,225 +96,10 @@ class OrganizationDashboardView(
return context
class OrganizationMembershipMixin:
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
template_name = "frontend/organizations/update.html"
@cached_property
def user_role(self):
membership = (
OrganizationMembership.objects.filter(
user=self.request.user, organization=self.get_object()
)
.order_by("role")
.first()
)
return membership.role if membership else None
@cached_property
def can_manage_members(self):
return self.request.user.has_perm(
"core.change_organization", self.request.organization
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
organization = self.get_object()
if self.can_manage_members:
memberships = (
OrganizationMembership.objects.filter(organization=organization)
.select_related("user")
.order_by("role", "user__email")
)
pending_invitations = OrganizationInvitation.objects.filter(
organization=organization, accepted_by__isnull=True
).order_by("-created_at")
invitation_form = OrganizationInvitationForm(
organization=organization, user_role=self.user_role
)
context.update(
{
"memberships": memberships,
"pending_invitations": pending_invitations,
"invitation_form": invitation_form,
"can_manage_members": self.can_manage_members,
"user_role": self.user_role,
}
)
return context
class OrganizationUpdateView(
OrganizationViewMixin, OrganizationMembershipMixin, HtmxUpdateView
):
form_class = OrganizationForm
fragments = (
"org-name",
"org-name-edit",
"members-list",
"pending-invitations-card",
)
def post(self, request, *args, **kwargs):
if "invite_email" in request.POST:
return self.handle_invitation(request)
return super().post(request, *args, **kwargs)
def handle_invitation(self, request):
organization = self.get_object()
if not self.can_manage_members:
messages.error(request, _("You do not have permission to invite members."))
return redirect(self.get_success_url())
form = OrganizationInvitationForm(
request.POST, organization=organization, user_role=self.user_role
)
if form.is_valid():
invitation = form.save(commit=False)
invitation.created_by = request.user
invitation.save()
try:
invitation.send_invitation_email(request)
messages.success(
request,
_(
"Invitation sent to {email}. They will receive an email with the invitation link."
).format(email=invitation.email),
)
except Exception:
messages.warning(
request,
_(
"Invitation created for {email}, but email failed to send. Share this link manually: {url}"
).format(
email=invitation.email,
url=request.build_absolute_uri(invitation.urls.accept),
),
)
else:
for error in form.errors.values():
for error_msg in error:
messages.error(request, error_msg)
if self.is_htmx and self._get_fragment():
return self.get(request, *self.args, **self.kwargs)
return redirect(self.get_success_url())
fragments = ("org-name", "org-name-edit")
def get_success_url(self):
return self.request.path
@method_decorator(scopes_disabled(), name="dispatch")
class InvitationAcceptView(TemplateView):
template_name = "frontend/organizations/invitation_accept.html"
def get_invitation(self):
secret = self.kwargs.get("secret")
return get_object_or_404(OrganizationInvitation, secret=secret)
def dispatch(self, request, *args, **kwargs):
invitation = self.get_invitation()
if invitation.is_accepted:
messages.warning(
request,
_("This invitation has already been accepted."),
)
return redirect("frontend:organization.selection")
if not request.user.is_authenticated:
request.session["invitation_next"] = request.path
messages.info(
request,
_("Please log in or sign up to accept this invitation."),
)
return redirect(f"{reverse('account_login')}?next={request.path}")
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["invitation"] = self.get_invitation()
return context
def post(self, request, *args, **kwargs):
invitation = self.get_invitation()
invitation.accepted_by = request.user
invitation.accepted_at = timezone.now()
invitation.save()
OrganizationMembership.objects.get_or_create(
user=request.user,
organization=invitation.organization,
defaults={"role": invitation.role},
)
messages.success(
request,
_("You have successfully joined {organization}!").format(
organization=invitation.organization.name
),
)
request.session.pop("invitation_next", None)
return redirect(invitation.organization.urls.base)
class InvitationDeleteView(HtmxViewMixin, OrganizationMembershipMixin, DeleteView):
model = OrganizationInvitation
http_method_names = ["get", "post"]
fragments = ("pending-invitations-card",)
def get_queryset(self):
return OrganizationInvitation.objects.filter(accepted_by__isnull=True)
def get_success_url(self):
return self.object.organization.urls.details
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
organization = self.request.organization
context["pending_invitations"] = OrganizationInvitation.objects.filter(
organization=organization, accepted_by__isnull=True
).order_by("-created_at")
return context
def _check_permission(self):
return self.request.user.has_perm(
"core.change_organization", self.request.organization
)
def get_object(self):
if self.request.method == "POST" and self.is_htmx:
try:
return super().get_object()
except Exception:
return
return super().get_object()
def post(self, request, *args, **kwargs):
self.object = self.get_object()
organization = self.object.organization
if not self._check_permission():
if not self.is_htmx:
messages.error(
request,
_("You do not have permission to delete this invitation."),
)
return redirect(organization.urls.details)
email = self.object.email
self.object.delete()
if not self.is_htmx:
messages.success(
request,
_("Invitation for {email} has been deleted.").format(email=email),
)
if self.is_htmx and self._get_fragment():
return self.get(request, *args, **kwargs)
return redirect(self.get_success_url())

View file

@ -1,6 +1,6 @@
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import Http404, HttpResponse
from django.http import HttpResponse
from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@ -36,24 +36,22 @@ class ServiceListView(OrganizationViewMixin, ListView):
def get_queryset(self):
"""Return all services."""
services = self.request.organization.get_visible_services()
services = (
Service.objects.all()
.select_related("category")
.prefetch_related("offerings__provider")
)
if self.filter_form.is_valid():
services = self.filter_form.filter_queryset(services)
return services.distinct()
@cached_property
def filter_form(self):
return ServiceFilterForm(
data=self.request.GET or None, organization=self.request.organization
)
return ServiceFilterForm(data=self.request.GET or None)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filter_form"] = self.filter_form
context["deactivated_services"] = (
self.request.organization.get_deactivated_services()
)
return context
@ -64,36 +62,10 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
permission_type = "view"
def get_queryset(self):
return self.request.organization.get_visible_services()
@cached_property
def visible_offerings(self):
offerings = self.object.offerings.all()
if self.request.organization.limit_cloudproviders.exists():
offerings = offerings.filter(
provider__in=self.request.organization.limit_cloudproviders.all()
)
return offerings
def get(self, request, *args, **kwargs):
self.object = self.get_object()
# If there's exactly one offering, skip provider selection and go directly to it
if self.visible_offerings.count() == 1:
offering = self.visible_offerings.first()
return redirect(
"frontend:organization.offering",
organization=self.request.organization.slug,
slug=self.object.slug,
pk=offering.pk,
)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["visible_offerings"] = self.visible_offerings.select_related("provider")
return context
return Service.objects.select_related("category").prefetch_related(
"offerings",
"offerings__provider",
)
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
@ -104,14 +76,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
fragments = ("service-form", "control-plane-info")
def has_permission(self):
if not self.has_organization_permission():
return False
if self.request.organization.limit_cloudproviders.exists():
return (
self.get_object().provider
in self.request.organization.limit_cloudproviders.all()
)
return True
return self.has_organization_permission()
def get_queryset(self):
return ServiceOffering.objects.all().select_related(
@ -142,9 +107,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
def context_object(self):
if self.request.method == "POST":
return ControlPlaneCRD.objects.filter(
pk=self.request.POST.get(
"expert-context", self.request.POST.get("custom-context")
),
pk=self.request.POST.get("context"),
# Make sure we dont use a malicious ID
control_plane__in=self.planes,
).first()
@ -152,52 +115,15 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
control_plane=self.selected_plane, service_offering=self.object
).first()
def get_instance_form_kwargs(self, ignore_data=False):
return {
"initial": {
def get_instance_form(self):
if not self.context_object or not self.context_object.model_form_class:
return None
return self.context_object.model_form_class(
data=self.request.POST if self.request.method == "POST" else None,
initial={
"organization": self.request.organization,
"context": self.context_object,
},
"prefix": "expert",
"data": (
self.request.POST
if (self.request.method == "POST" and not ignore_data)
else None
),
}
def get_instance_form(self, ignore_data=False):
if (
not self.context_object
or not self.context_object.model_form_class
or self.hide_expert_mode
):
return
return self.context_object.model_form_class(
**self.get_instance_form_kwargs(ignore_data=ignore_data)
)
def get_custom_instance_form(self, ignore_data=False):
if not self.context_object or not self.context_object.custom_model_form_class:
return
kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data)
kwargs["prefix"] = "custom"
return self.context_object.custom_model_form_class(**kwargs)
@property
def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields
# vs "expert form" = auto-generated (all technical fields)
return self.request.POST.get("active_form", "expert") == "custom"
@cached_property
def hide_expert_mode(self):
return (
self.context_object
and self.context_object.service_definition
and self.context_object.service_definition.form_config
and self.context_object.service_definition.hide_expert_mode
)
def get_context_data(self, **kwargs):
@ -205,23 +131,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists()
context["selected_plane"] = self.selected_plane
context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST":
if self.is_custom_form:
context["service_form"] = self.get_instance_form(ignore_data=True)
context["custom_service_form"] = self.get_custom_instance_form()
else:
context["service_form"] = self.get_instance_form()
context["custom_service_form"] = self.get_custom_instance_form(
ignore_data=True
)
else:
context["service_form"] = self.get_instance_form()
context["custom_service_form"] = self.get_custom_instance_form()
if self.selected_plane and self.selected_plane.wildcard_dns:
context["wildcard_dns"] = self.selected_plane.wildcard_dns
context["organization_namespace"] = self.request.organization.namespace
context["service_form"] = self.get_instance_form()
return context
def post(self, request, *args, **kwargs):
@ -232,10 +142,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["form_error"] = True
return self.render_to_response(context)
if self.is_custom_form:
form = self.get_custom_instance_form()
else:
form = self.get_instance_form()
form = self.get_instance_form()
if not form: # Should not happen if context_object is valid, but as a safeguard
messages.error(
self.request,
@ -263,13 +170,15 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
)
form.add_error(None, error_message)
# If the form is not valid or if the service creation failed, we render it again
context["service_form"] = form
return self.render_to_response(context)
class ServiceInstanceMixin:
model = ServiceInstance
context_object_name = "instance"
pk_url_kwarg = "slug"
slug_field = "name"
def dispatch(self, *args, **kwargs):
self._has_warned = False
@ -286,25 +195,7 @@ class ServiceInstanceMixin:
)
def get_object(self, **kwargs):
queryset = kwargs.get("queryset") or self.get_queryset()
# Get the slug from URL (format: "my-instance-123")
slug = self.kwargs.get(self.pk_url_kwarg)
if slug is None:
raise Http404("No slug provided in URL")
# Extract pk from the slug (everything after the last dash)
try:
pk_str = slug.rsplit("-", 1)[-1]
pk = int(pk_str)
except (ValueError, IndexError):
raise Http404(f"Invalid slug format: {slug}")
try:
instance = queryset.get(pk=pk)
except ServiceInstance.DoesNotExist:
raise Http404("Service instance not found")
instance = super().get_object(**kwargs)
if not instance.kubernetes_object and not self._has_warned:
messages.warning(
self.request,
@ -451,88 +342,11 @@ class ServiceInstanceUpdateView(
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["instance"] = self.object.spec_object
kwargs["prefix"] = "expert"
return kwargs
def get_form(self, *args, ignore_data=False, **kwargs):
if self.hide_expert_mode:
return
if not ignore_data:
return super().get_form(*args, **kwargs)
cls = self.get_form_class()
kwargs = self.get_form_kwargs()
if ignore_data:
kwargs.pop("data", None)
return cls(**kwargs)
def get_custom_form(self, ignore_data=False):
cls = self.object.context.custom_model_form_class
if not cls:
return
kwargs = self.get_form_kwargs()
kwargs["prefix"] = "custom"
if ignore_data:
kwargs.pop("data", None)
return cls(**kwargs)
@property
def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields
# vs "expert form" = auto-generated (all technical fields)
return self.request.POST.get("active_form", "expert") == "custom"
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.is_custom_form:
form = self.get_custom_form()
else:
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
return self.form_invalid(form)
@cached_property
def hide_expert_mode(self):
return (
self.object
and self.object.context
and self.object.context.service_definition
and self.object.context.service_definition.form_config
and self.object.context.service_definition.hide_expert_mode
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST":
if self.is_custom_form:
context["custom_form"] = self.get_custom_form()
context["form"] = self.get_form(ignore_data=True)
else:
context["custom_form"] = self.get_custom_form(ignore_data=True)
else:
context["custom_form"] = self.get_custom_form()
return context
def _deep_merge(self, base, update):
for key, value in update.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._deep_merge(base[key], value)
else:
base[key] = value
return base
def form_valid(self, form):
try:
form_data = form.get_nested_data()
spec_data = form_data.get("spec")
if self.is_custom_form:
current_spec = dict(self.object.spec) if self.object.spec else {}
spec_data = self._deep_merge(current_spec, spec_data)
spec_data = form.get_nested_data().get("spec")
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
messages.success(
self.request,

View file

@ -1,10 +1,11 @@
from django.conf import settings
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from servala.core.odoo import create_helpdesk_ticket
from servala.core.odoo import CLIENT
from servala.frontend.forms.support import SupportForm
from servala.frontend.views.mixins import OrganizationViewMixin
@ -23,16 +24,21 @@ class SupportView(OrganizationViewMixin, FormView):
if not partner_id:
raise Exception("Could not get or create Odoo contact for user")
ticket_data = {
"name": f"Servala Support - Organization {organization.name}",
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
"partner_id": partner_id,
"description": message,
}
# All orgs should have a sale order ID, but legacy ones might not have it.
# Also, we want to be very sure that support requests work, especially for
# organizations where something in the creation process may have gone wrong,
# so if the ID does not exist, we omit it entirely.
create_helpdesk_ticket(
title=f"Servala Support - Organization {organization.name}",
description=message,
partner_id=partner_id,
sale_order_id=organization.odoo_sale_order_id or None,
)
if organization.odoo_sale_order_id:
ticket_data["sale_order_id"] = organization.odoo_sale_order_id
CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
messages.success(
self.request,
_(

View file

@ -20,7 +20,6 @@ from servala.__about__ import __version__ as version
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
DEBUG = SERVALA_ENVIRONMENT == "development"
SERVALA_SHOW_BETA_BANNER = os.environ.get("SERVALA_SHOW_BETA_BANNER", "True") == "True"
SECRET_KEY = os.environ.get("SERVALA_SECRET_KEY")
if previous_secret_key := os.environ.get("SERVALA_PREVIOUS_SECRET_KEY"):
@ -220,7 +219,6 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.static",
"servala.frontend.context_processors.add_organizations",
"servala.frontend.context_processors.add_beta_banner",
],
"loaders": template_loaders,
},

View file

@ -8,7 +8,6 @@ overrides/adds settings specific to testing.
from servala.settings import * # noqa: F403, F401
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
SALT_KEY = SECRET_KEY
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]

View file

@ -237,127 +237,45 @@ a.btn-keycloak {
flex-grow: 1;
}
/* Expert CRD Form mandatory field styling */
.expert-crd-form .form-group.mandatory .form-label {
/* CRD Form mandatory field styling */
.crd-form .form-group.mandatory .form-label {
font-weight: bold;
position: relative;
}
.expert-crd-form .form-group.mandatory .form-label::after {
.crd-form .form-group.mandatory .form-label::after {
content: " *";
color: #dc3545;
font-weight: bold;
}
.expert-crd-form .form-group.mandatory {
.crd-form .form-group.mandatory {
border-left: 3px solid #dc3545;
padding-left: 10px;
background-color: rgba(220, 53, 69, 0.05);
border-radius: 3px;
}
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
.crd-form .nav-tabs .nav-link .mandatory-indicator {
color: #dc3545;
font-weight: bold;
font-size: 1.1em;
margin-left: 4px;
}
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory {
html[data-bs-theme="dark"] .crd-form .form-group.mandatory {
background-color: rgba(220, 53, 69, 0.1);
border-left-color: #ff6b6b;
}
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after {
html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after {
color: #ff6b6b;
}
html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
color: #ff6b6b;
}
.crd-form .nav-tabs .nav-link.has-mandatory {
position: relative;
}
.service-deactivated .card {
opacity: 50%;
cursor: not-allowed;
img {
opacity: 75%
}
h4, small, p {
color: var(--bs-secondary-color) !important;
}
a.btn-outline-secondary {
color: var(--bs-btn-disabled-color) !important;
background-color: var(--bs-btn-disabled-bg) !important;
border-color: var(--bs-btn-disabled-border-color) !important;
opacity: var(--bs-btn-disabled-opacity);
}
a.btn-secondary {
color: white !important;
}
a.btn {
pointer-events: none;
}
}
.ml-auto {
margin-left: auto !important
}
.beta-banner {
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%);
color: white;
padding: 0.75rem 0;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.beta-banner-content {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.beta-banner-badge {
background-color: white;
color: var(--bs-primary);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-weight: bold;
font-size: 0.875rem;
letter-spacing: 0.5px;
}
.beta-banner-text {
font-size: 0.95rem;
}
.beta-banner-button {
background-color: white;
color: var(--bs-primary);
border: none;
font-weight: 600;
padding: 0.375rem 1rem;
transition: all 0.2s ease;
}
.beta-banner-button:hover {
background-color: var(--brand-light);
color: var(--bs-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
html[data-bs-theme="dark"] .beta-banner {
background: linear-gradient(135deg, var(--bs-primary) 0%, #7a4fc4 100%);
}
html[data-bs-theme="dark"] .beta-banner-badge {
background-color: rgba(255, 255, 255, 0.95);
}
html[data-bs-theme="dark"] .beta-banner-button {
background-color: rgba(255, 255, 255, 0.95);
color: var(--bs-primary);
}
html[data-bs-theme="dark"] .beta-banner-button:hover {
background-color: white;
color: var(--bs-primary);
}

View file

@ -1,30 +0,0 @@
// Bootstrap 5 automatically initializes tabs with data-bs-toggle="tab"
// but we need to ensure they work after HTMX swaps
(function() {
'use strict';
const initBootstrapTabs = () => {
const customTabList = document.querySelectorAll('#myTab button[data-bs-toggle="tab"]');
customTabList.forEach(function(tabButton) {
new bootstrap.Tab(tabButton);
});
const expertTabList = document.querySelectorAll('#expertTab button[data-bs-toggle="tab"]');
expertTabList.forEach(function(tabButton) {
new bootstrap.Tab(tabButton);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBootstrapTabs);
} else {
initBootstrapTabs();
}
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form' ||
event.detail.target.classList.contains('crd-form')) {
initBootstrapTabs();
}
});
})();

View file

@ -7,10 +7,6 @@ const initDynamicArrayWidget = () => {
const containers = document.querySelectorAll('.dynamic-array-widget')
containers.forEach(container => {
if (container.dataset.initialized === 'true') {
return
}
const itemsContainer = container.querySelector('.array-items')
const addButton = container.querySelector('.add-array-item')
const hiddenInput = container.querySelector('input[type="hidden"]')
@ -26,7 +22,6 @@ const initDynamicArrayWidget = () => {
// Ensure hidden input is synced with visible inputs on initialization
updateHiddenInput(container)
container.dataset.initialized = 'true'
})
}
@ -129,8 +124,6 @@ const updateRemoveButtonVisibility = (container) => {
})
}
window.updateHiddenInput = updateHiddenInput
document.addEventListener('DOMContentLoaded', initDynamicArrayWidget)
document.addEventListener('htmx:afterSwap', initDynamicArrayWidget)
document.addEventListener('htmx:afterSettle', initDynamicArrayWidget)

View file

@ -1,49 +0,0 @@
(function() {
'use strict';
let isExpertMode = false;
function initExpertMode() {
const toggleButton = document.getElementById('expert-mode-toggle');
if (!toggleButton) return;
const customFormContainer = document.getElementById('custom-form-container');
const expertFormContainer = document.getElementById('expert-form-container');
if (!customFormContainer || !expertFormContainer) {
console.warn('Expert mode containers not found');
return;
}
toggleButton.addEventListener('click', function(e) {
e.preventDefault();
isExpertMode = !isExpertMode;
const activeFormInput = document.getElementById('active-form-input');
if (isExpertMode) {
customFormContainer.style.display = 'none';
expertFormContainer.style.display = 'block';
toggleButton.textContent = 'Show Simplified Form';
if (activeFormInput) activeFormInput.value = 'expert';
} else {
customFormContainer.style.display = 'block';
expertFormContainer.style.display = 'none';
toggleButton.textContent = 'Show Expert Mode';
if (activeFormInput) activeFormInput.value = 'custom';
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExpertMode);
} else {
initExpertMode();
}
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form' || event.detail.target.classList.contains('crd-form')) {
initExpertMode();
}
});
})();

View file

@ -1,62 +0,0 @@
const initializeFqdnGeneration = (prefix) => {
const nameField = document.querySelector(`input#id_${prefix}-name`);
if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input
const fqdnFieldContainer = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`)
let fqdnField = null;
let isArrayField = true;
if (fqdnFieldContainer) {
let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input');
} else {
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
isArrayField = false;
}
if (!fqdnField) return
if (nameField && fqdnField) {
const generateFqdn = (instanceName) => {
if (!instanceName) return '';
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
}
nameField.addEventListener('input', function() {
if (!fqdnField.dataset.manuallyEdited) {
fqdnField.value = generateFqdn(this.value);
if (isArrayField) {
// Update hidden input for array fields
const container = fqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
}
}
}
});
fqdnField.addEventListener('input', function() {
this.dataset.manuallyEdited = 'true';
});
if (nameField.value && !fqdnField.value) {
fqdnField.value = generateFqdn(nameField.value);
if (isArrayField) {
// Update hidden input for array fields
const container = fqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
}
}
}
}
}
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') {
initializeFqdnGeneration("custom");
initializeFqdnGeneration("expert");
}
});

View file

@ -3,7 +3,6 @@ import base64
import pytest
from servala.core.models import (
BillingEntity,
Organization,
OrganizationMembership,
OrganizationOrigin,
@ -22,11 +21,6 @@ def origin():
return OrganizationOrigin.objects.create(name="TESTORIGIN")
@pytest.fixture
def billing_entity():
return BillingEntity.objects.create(name="Test Entity")
@pytest.fixture
def organization(origin):
return Organization.objects.create(name="Test Org", origin=origin)

View file

@ -5,12 +5,6 @@ from django.core import mail
from django_scopes import scopes_disabled
from servala.core.models import Organization, OrganizationOrigin, User
from servala.core.models.service import (
ControlPlane,
ControlPlaneCRD,
ServiceDefinition,
ServiceInstance,
)
@pytest.fixture
@ -79,8 +73,12 @@ def test_successful_onboarding_new_organization(
assert org.origin == exoscale_origin
assert org.namespace.startswith("org-")
user = User.objects.get(email="test@example.com")
assert user.first_name == "Test"
assert user.last_name == "User"
with scopes_disabled():
assert org.invitations.all().filter(email="test@example.com").exists()
membership = org.memberships.get(user=user)
assert membership.role == "owner"
billing_entity = org.billing_entity
assert billing_entity.name == "Test Organization Display (Exoscale)"
@ -89,14 +87,10 @@ def test_successful_onboarding_new_organization(
assert org.odoo_sale_order_id == 789
assert org.odoo_sale_order_name == "SO001"
assert org.limit_osb_services.all().count() == 1
assert len(mail.outbox) == 2
invitation_email = mail.outbox[0]
assert (
invitation_email.subject
== "You're invited to join Test Organization Display on Servala"
)
assert invitation_email.subject == "Welcome to Servala - Test Organization Display"
assert "test@example.com" in invitation_email.to
welcome_email = mail.outbox[1]
@ -104,36 +98,6 @@ def test_successful_onboarding_new_organization(
assert "redis/offering/" in welcome_email.body
@pytest.mark.django_db
def test_new_organization_inherits_origin(
osb_client,
test_service,
test_service_offering,
valid_osb_payload,
exoscale_origin,
instance_id,
billing_entity,
):
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
exoscale_origin.billing_entity = billing_entity
exoscale_origin.save()
response = osb_client.put(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(valid_osb_payload),
content_type="application/json",
)
assert response.status_code == 201
response_data = json.loads(response.content)
assert response_data["message"] == "Successfully enabled service"
org = Organization.objects.get(osb_guid="test-org-guid-123")
assert org.name == "Test Organization Display"
assert org.billing_entity == exoscale_origin.billing_entity
@pytest.mark.django_db
def test_duplicate_organization_returns_existing(
osb_client,
@ -143,12 +107,11 @@ def test_duplicate_organization_returns_existing(
exoscale_origin,
instance_id,
):
org = Organization.objects.create(
Organization.objects.create(
name="Existing Org",
osb_guid="test-org-guid-123",
origin=exoscale_origin,
)
org.limit_osb_services.add(test_service)
valid_osb_payload["service_id"] = test_service.osb_service_id
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
@ -163,7 +126,7 @@ def test_duplicate_organization_returns_existing(
response_data = json.loads(response.content)
assert response_data["message"] == "Service already enabled"
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
assert len(mail.outbox) == 0 # No email necessary
assert len(mail.outbox) == 1 # Only one email was sent
@pytest.mark.django_db
@ -457,290 +420,3 @@ def test_organization_creation_with_context_only(
assert response.status_code == 201
org = Organization.objects.get(osb_guid="fallback-org-guid")
assert org is not None
@pytest.mark.django_db
def test_delete_offboarding_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_missing_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_service_id(osb_client, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id=invalid&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unknown service_id: invalid" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_patch_suspension_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": "user@example.com",
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
payload = {
"plan_id": test_service_offering.osb_plan_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_missing_plan_id(osb_client, test_service, instance_id):
payload = {
"service_id": test_service.osb_service_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_invalid_json(osb_client, instance_id):
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data="invalid json{",
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Invalid JSON in request body" in response_data["error"]
@pytest.mark.django_db
def test_delete_creates_ticket_with_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
# Verify the ticket was created with admin URL
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check that the description contains an admin URL
assert "admin/core/serviceoffering" in call_kwargs["description"]
assert f"/{test_service_offering.pk}/" in call_kwargs["description"]
assert (
call_kwargs["title"]
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_patch_creates_ticket_with_user_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
org_owner,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": org_owner.email,
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
# Verify the ticket was created with admin URLs
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check that the description contains admin URLs
assert "admin/core/serviceoffering" in call_kwargs["description"]
assert "admin/core/user" in call_kwargs["description"]
assert f"/{org_owner.pk}/" in call_kwargs["description"]
assert (
call_kwargs["title"]
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_ticket_includes_organization_and_instance_when_found(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
organization,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
service_definition = ServiceDefinition.objects.create(
name="Test Definition",
service=test_service,
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
)
control_plane = ControlPlane.objects.create(
name="Test Control Plane",
cloud_provider=test_service_offering.provider,
api_credentials={
"certificate-authority-data": "test",
"server": "https://test",
"token": "test",
},
)
crd = ControlPlaneCRD.objects.create(
service_offering=test_service_offering,
control_plane=control_plane,
service_definition=service_definition,
)
instance_name = "test-instance-123"
service_instance = ServiceInstance.objects.create(
name=instance_name,
organization=organization,
context=crd,
)
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_name}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
# Verify the ticket was created with all admin URLs
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check organization is included
assert f"Organization: {organization.name}" in call_kwargs["description"]
assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included
assert f"Instance: {service_instance.name}" in call_kwargs["description"]
assert "admin/core/serviceinstance" in call_kwargs["description"]
assert f"/{service_instance.pk}/" in call_kwargs["description"]

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,5 @@
import pytest
from servala.core.models.service import CloudProvider, ServiceOffering
@pytest.mark.parametrize(
"url,redirect",
@ -47,103 +45,3 @@ def test_organization_linked_in_sidebar(
assert response.status_code == 200
assert organization.name in response.content.decode()
assert other_organization.name not in response.content.decode()
@pytest.mark.django_db
def test_service_detail_redirects_with_single_offering(
client, org_owner, organization, test_service, test_service_offering
):
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
assert response.url == expected_url
@pytest.mark.django_db
def test_service_detail_shows_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
second_offering = ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert test_service_offering.provider.name in content
assert second_offering.provider.name in content
assert "Create Instance" in content
@pytest.mark.django_db
def test_service_detail_respects_cloud_provider_restrictions(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
organization.origin.limit_cloudproviders.add(test_service_offering.provider)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
assert response.url == expected_url
@pytest.mark.django_db
def test_service_detail_no_redirect_with_restricted_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
second_offering = ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
third_provider = CloudProvider.objects.create(
name="Azure", description="Microsoft Azure"
)
third_offering = ServiceOffering.objects.create(
service=test_service,
provider=third_provider,
description="Redis on Azure",
osb_plan_id="test-plan-789",
)
organization.origin.limit_cloudproviders.add(
test_service_offering.provider, second_provider
)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert test_service_offering.provider.name in content
assert second_offering.provider.name in content
assert third_offering.provider.name not in content

792
uv.lock generated

File diff suppressed because it is too large Load diff