Compare commits
No commits in common. "main" and "2025.10.03-0" have entirely different histories.
main
...
2025.10.03
80 changed files with 1333 additions and 6365 deletions
|
|
@ -4,10 +4,6 @@
|
||||||
# When the environment is "development", DEBUG is set to True.
|
# When the environment is "development", DEBUG is set to True.
|
||||||
SERVALA_ENVIRONMENT='development'
|
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!
|
# 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
|
# 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).
|
# the rotation, all sessions that still rely on that key will be invalidated (i.e., users will have to log in again).
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,6 @@ on:
|
||||||
- "pyproject.toml"
|
- "pyproject.toml"
|
||||||
- "uv.lock"
|
- "uv.lock"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -26,7 +23,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -72,7 +69,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Determine image tag
|
- name: Determine image tag
|
||||||
id: determine-tag
|
id: determine-tag
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -53,7 +53,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.19
|
uses: docker://quay.io/appuio/oc:v4.19
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
@ -49,7 +49,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Deploy to OpenShift
|
- name: Deploy to OpenShift
|
||||||
uses: docker://quay.io/appuio/oc:v4.19
|
uses: docker://quay.io/appuio/oc:v4.19
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ jobs:
|
||||||
container: catthehacker/ubuntu:act-latest
|
container: catthehacker/ubuntu:act-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Renovate
|
- name: Renovate
|
||||||
uses: https://github.com/renovatebot/github-action@v44.0.5
|
uses: https://github.com/renovatebot/github-action@v43.0.14
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,15 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: https://github.com/astral-sh/setup-uv@v7
|
uses: https://github.com/astral-sh/setup-uv@v6
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run --env-file=.env.example pytest
|
run: uv run --env-file=.env.example pytest
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.14
|
3.13
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM python:3.14-slim
|
FROM python:3.13-slim
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
The Servala Self-Service Portal
|
The Servala Self-Service Portal
|
||||||
|
|
||||||
Latest release: 2025.11.17-0
|
Latest release: 2025.10.03-0
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
** xref:web-portal-admin.adoc[Admin]
|
** xref:web-portal-admin.adoc[Admin]
|
||||||
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
||||||
** xref:web-portal-billingentity.adoc[Billing Entities]
|
** xref:web-portal-billingentity.adoc[Billing Entities]
|
||||||
** xref:web-portal-changelog.adoc[Changelog]
|
|
||||||
|
|
||||||
* xref:web-portal-planning.adoc[]
|
* xref:web-portal-planning.adoc[]
|
||||||
** xref:user-stories.adoc[]
|
** xref:user-stories.adoc[]
|
||||||
|
|
@ -17,4 +16,4 @@
|
||||||
** xref:api.adoc[]
|
** xref:api.adoc[]
|
||||||
|
|
||||||
* Cloud Providers
|
* Cloud Providers
|
||||||
** xref:exoscale-osb.adoc[]
|
** xref:exoscale-osb.adoc[]
|
||||||
|
|
@ -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])
|
|
||||||
|
|
||||||
116
hack/README.md
116
hack/README.md
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -3,41 +3,41 @@ name = "servala"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "Servala portal server and frontend"
|
description = "Servala portal server and frontend"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14.0"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"cryptography>=46.0.3",
|
"cryptography>=46.0.1",
|
||||||
"django==5.2.8",
|
"django==5.2.7",
|
||||||
"django-allauth>=65.13.1",
|
"django-allauth>=65.11.2",
|
||||||
"django-auditlog>=3.3.0",
|
"django-auditlog>=3.2.1",
|
||||||
"django-fernet-encrypted-fields>=0.3.1",
|
"django-fernet-encrypted-fields>=0.3.0",
|
||||||
"django-jsonform>=2.23.2",
|
"django-jsonform>=2.23.2",
|
||||||
"django-scopes>=2.0.0",
|
"django-scopes>=2.0.0",
|
||||||
"django-storages[s3]>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
"django-template-partials>=25.3",
|
"django-template-partials>=25.2",
|
||||||
"jsonschema>=4.25.1",
|
"jsonschema>=4.25.1",
|
||||||
"kubernetes>=34.1.0",
|
"kubernetes>=33.1.0",
|
||||||
"pillow>=12.0.0",
|
"pillow>=11.3.0",
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.10",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"rules>=3.5",
|
"rules>=3.5",
|
||||||
"sentry-sdk[django]>=2.46.0",
|
"sentry-sdk[django]>=2.39.0",
|
||||||
"urlman>=2.0.2",
|
"urlman>=2.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"black>=25.11.0",
|
"black>=25.9.0",
|
||||||
"bumpver>=2025.1131",
|
"bumpver>=2025.1131",
|
||||||
"coverage>=7.12.0",
|
"coverage>=7.10.7",
|
||||||
"djlint>=1.36.4",
|
"djlint>=1.36.4",
|
||||||
"flake8>=7.3.0",
|
"flake8>=7.3.0",
|
||||||
"flake8-bugbear>=25.11.29",
|
"flake8-bugbear>=24.12.12",
|
||||||
"flake8-pyproject>=1.2.3",
|
"flake8-pyproject>=1.2.3",
|
||||||
"isort>=7.0.0",
|
"isort>=6.0.1",
|
||||||
"pytest>=9.0.1",
|
"pytest>=8.4.2",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=6.3.0",
|
||||||
"pytest-django>=4.11.1",
|
"pytest-django>=4.11.1",
|
||||||
"pytest-mock>=3.15.1",
|
"pytest-mock>=3.15.1",
|
||||||
]
|
]
|
||||||
|
|
@ -61,15 +61,15 @@ testpaths = "src/tests"
|
||||||
pythonpath = "src"
|
pythonpath = "src"
|
||||||
|
|
||||||
[tool.bumpver]
|
[tool.bumpver]
|
||||||
current_version = "2025.11.17-0"
|
current_version = "2025.10.03-0"
|
||||||
version_pattern = "YYYY.0M.0D-INC0"
|
version_pattern = "YYYY.0M.0D-INC0"
|
||||||
commit_message = "bump version {old_version} -> {new_version}"
|
commit_message = "bump version {old_version} -> {new_version}"
|
||||||
tag_message = "{new_version}"
|
tag_message = "{new_version}"
|
||||||
tag_scope = "default"
|
tag_scope = "default"
|
||||||
pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
|
pre_commit_hook = ""
|
||||||
post_commit_hook = "hack/bumpver-post-commit-hook.sh"
|
post_commit_hook = ""
|
||||||
commit = true
|
commit = true
|
||||||
tag = false
|
tag = true
|
||||||
push = true
|
push = true
|
||||||
|
|
||||||
[tool.bumpver.file_patterns]
|
[tool.bumpver.file_patterns]
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
__version__ = "2025.11.17-0"
|
__version__ = "2025.10.03-0"
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,20 @@
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_not_required
|
from django.contrib.auth.decorators import login_not_required
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from servala.api.permissions import OSBBasicAuthPermission
|
from servala.api.permissions import OSBBasicAuthPermission
|
||||||
from servala.core.exoscale import get_exoscale_origin
|
from servala.core.exoscale import get_exoscale_origin
|
||||||
from servala.core.models import (
|
from servala.core.models import BillingEntity, Organization, User
|
||||||
BillingEntity,
|
from servala.core.models.service import Service, ServiceOffering
|
||||||
Organization,
|
|
||||||
OrganizationInvitation,
|
|
||||||
OrganizationRole,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from servala.core.models.service import Service, ServiceInstance, ServiceOffering
|
|
||||||
from servala.core.odoo import create_helpdesk_ticket
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -30,7 +23,9 @@ logger = logging.getLogger(__name__)
|
||||||
@method_decorator(login_not_required, name="dispatch")
|
@method_decorator(login_not_required, name="dispatch")
|
||||||
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
|
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):
|
def _error(self, error):
|
||||||
|
|
@ -107,47 +102,66 @@ class OSBServiceInstanceView(OSBBasicAuthPermission, View):
|
||||||
)
|
)
|
||||||
|
|
||||||
exoscale_origin = get_exoscale_origin()
|
exoscale_origin = get_exoscale_origin()
|
||||||
try:
|
with suppress(Organization.DoesNotExist):
|
||||||
organization = Organization.objects.get(
|
organization = Organization.objects.get(
|
||||||
osb_guid=organization_guid, origin=exoscale_origin
|
osb_guid=organization_guid, origin=exoscale_origin
|
||||||
)
|
)
|
||||||
if service in organization.limit_osb_services.all():
|
self._send_service_welcome_email(
|
||||||
return JsonResponse({"message": "Service already enabled"}, status=200)
|
request, organization, user, service, service_offering
|
||||||
except Organization.DoesNotExist:
|
)
|
||||||
try:
|
return JsonResponse({"message": "Service already enabled"}, status=200)
|
||||||
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)
|
|
||||||
|
|
||||||
organization.limit_osb_services.add(service)
|
odoo_data = {
|
||||||
self._send_service_welcome_email(
|
"company_name": organization_display_name,
|
||||||
request, organization, user, service, service_offering
|
"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(
|
def _send_service_welcome_email(
|
||||||
self, request, organization, user, service, service_offering
|
self, request, organization, user, service, service_offering
|
||||||
|
|
@ -177,169 +191,3 @@ The Servala Team"""
|
||||||
recipient_list=[user.email],
|
recipient_list=[user.email],
|
||||||
fail_silently=False,
|
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}"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from django.contrib import admin, messages
|
from django.contrib import admin, messages
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
from django_jsonform.widgets import JSONFormWidget
|
||||||
|
|
@ -12,7 +9,6 @@ from servala.core.models import (
|
||||||
ControlPlane,
|
ControlPlane,
|
||||||
ControlPlaneCRD,
|
ControlPlaneCRD,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
Service,
|
Service,
|
||||||
|
|
@ -66,15 +62,10 @@ class OrganizationAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("name", "namespace")
|
search_fields = ("name", "namespace")
|
||||||
autocomplete_fields = ("billing_entity", "origin")
|
autocomplete_fields = ("billing_entity", "origin")
|
||||||
inlines = (OrganizationMembershipInline,)
|
inlines = (OrganizationMembershipInline,)
|
||||||
filter_horizontal = ("limit_osb_services",)
|
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
|
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
|
||||||
readonly_fields.append("namespace") # Always read-only
|
readonly_fields.append("namespace") # Always read-only
|
||||||
|
|
||||||
if obj and obj.has_inherited_billing_entity:
|
|
||||||
readonly_fields.append("billing_entity")
|
|
||||||
|
|
||||||
return readonly_fields
|
return readonly_fields
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -86,16 +77,8 @@ class BillingEntityAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(OrganizationOrigin)
|
@admin.register(OrganizationOrigin)
|
||||||
class OrganizationOriginAdmin(admin.ModelAdmin):
|
class OrganizationOriginAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = ("name",)
|
||||||
"name",
|
|
||||||
"billing_entity",
|
|
||||||
"default_odoo_sale_order_id",
|
|
||||||
"hide_billing_address",
|
|
||||||
)
|
|
||||||
list_filter = ("hide_billing_address",)
|
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
autocomplete_fields = ("billing_entity",)
|
|
||||||
filter_horizontal = ("limit_cloudproviders",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(OrganizationMembership)
|
@admin.register(OrganizationMembership)
|
||||||
|
|
@ -107,58 +90,6 @@ class OrganizationMembershipAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "date_joined"
|
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)
|
@admin.register(ServiceCategory)
|
||||||
class ServiceCategoryAdmin(admin.ModelAdmin):
|
class ServiceCategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "parent")
|
list_display = ("name", "parent")
|
||||||
|
|
@ -177,6 +108,7 @@ class ServiceAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
def get_form(self, request, obj=None, **kwargs):
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
form = super().get_form(request, obj, **kwargs)
|
form = super().get_form(request, obj, **kwargs)
|
||||||
|
# JSON schema for external_links field
|
||||||
external_links_schema = {
|
external_links_schema = {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "External Links",
|
"title": "External Links",
|
||||||
|
|
@ -209,6 +141,7 @@ class CloudProviderAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
def get_form(self, request, obj=None, **kwargs):
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
form = super().get_form(request, obj, **kwargs)
|
form = super().get_form(request, obj, **kwargs)
|
||||||
|
# JSON schema for external_links field
|
||||||
external_links_schema = {
|
external_links_schema = {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"title": "External Links",
|
"title": "External Links",
|
||||||
|
|
@ -241,15 +174,7 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{
|
{"fields": ("name", "description", "cloud_provider", "required_label")},
|
||||||
"fields": (
|
|
||||||
"name",
|
|
||||||
"description",
|
|
||||||
"cloud_provider",
|
|
||||||
"required_label",
|
|
||||||
"wildcard_dns",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
_("API Credentials"),
|
_("API Credentials"),
|
||||||
|
|
@ -319,29 +244,8 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||||
"description": _("API definition for the Kubernetes Custom Resource"),
|
"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):
|
def get_exclude(self, request, obj=None):
|
||||||
# Exclude the original api_definition field as we're using our custom fields
|
# Exclude the original api_definition field as we're using our custom fields
|
||||||
return ["api_definition"]
|
return ["api_definition"]
|
||||||
|
|
@ -400,23 +304,3 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("description",)
|
search_fields = ("description",)
|
||||||
autocomplete_fields = ("service", "provider")
|
autocomplete_fields = ("service", "provider")
|
||||||
inlines = (ControlPlaneCRDInline,)
|
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
419
src/servala/core/crd.py
Normal 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)
|
||||||
|
|
@ -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",
|
|
||||||
]
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,34 +1,18 @@
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import jsonschema
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_jsonform.widgets import JSONFormWidget
|
from django_jsonform.widgets import JSONFormWidget
|
||||||
|
|
||||||
from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
|
|
||||||
from servala.core.models import ControlPlane, ServiceDefinition
|
from servala.core.models import ControlPlane, ServiceDefinition
|
||||||
|
|
||||||
CONTROL_PLANE_USER_INFO_SCHEMA = {
|
CONTROL_PLANE_USER_INFO_SCHEMA = {
|
||||||
"type": "array",
|
"type": "object",
|
||||||
"items": {
|
"properties": {
|
||||||
"type": "object",
|
"CNAME Record": {
|
||||||
"properties": {
|
"title": "CNAME Record",
|
||||||
"title": {
|
"type": "string",
|
||||||
"type": "string",
|
|
||||||
"title": "Title",
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Content",
|
|
||||||
},
|
|
||||||
"help_text": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Help Text (optional)",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"required": ["title", "content"],
|
|
||||||
},
|
},
|
||||||
|
"additionalProperties": {"type": "string"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,12 +85,6 @@ class ControlPlaneAdminForm(forms.ModelForm):
|
||||||
return super().save(*args, **kwargs)
|
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):
|
class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
api_group = forms.CharField(
|
api_group = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
|
|
@ -135,10 +113,6 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
self.fields["api_version"].initial = api_def.get("version", "")
|
self.fields["api_version"].initial = api_def.get("version", "")
|
||||||
self.fields["api_kind"].initial = api_def.get("kind", "")
|
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):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
|
|
@ -166,250 +140,8 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||||
api_def["kind"] = api_kind
|
api_def["kind"] = api_kind
|
||||||
cleaned_data["api_definition"] = api_def
|
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
|
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):
|
def save(self, *args, **kwargs):
|
||||||
self.instance.api_definition = self.cleaned_data["api_definition"]
|
self.instance.api_definition = self.cleaned_data["api_definition"]
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from .organization import (
|
from .organization import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
OrganizationRole,
|
OrganizationRole,
|
||||||
|
|
@ -24,7 +23,6 @@ __all__ = [
|
||||||
"ControlPlane",
|
"ControlPlane",
|
||||||
"ControlPlaneCRD",
|
"ControlPlaneCRD",
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationInvitation",
|
|
||||||
"OrganizationMembership",
|
"OrganizationMembership",
|
||||||
"OrganizationOrigin",
|
"OrganizationOrigin",
|
||||||
"OrganizationRole",
|
"OrganizationRole",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,7 @@
|
||||||
import secrets
|
|
||||||
|
|
||||||
import rules
|
import rules
|
||||||
import urlman
|
import urlman
|
||||||
from auditlog.registry import auditlog
|
|
||||||
from django.conf import settings
|
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.db import models, transaction
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
@ -21,18 +14,7 @@ from servala.core.odoo import CLIENT
|
||||||
|
|
||||||
|
|
||||||
class Organization(ServalaModelMixin, models.Model):
|
class Organization(ServalaModelMixin, models.Model):
|
||||||
name = models.CharField(
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
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."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
# The namespace is generated as "org-{id}" in accordance with RFC 1035 Label Names.
|
# 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
|
# 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.
|
# not be null in practical use.
|
||||||
|
|
@ -64,12 +46,6 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
related_name="organizations",
|
related_name="organizations",
|
||||||
verbose_name=_("Members"),
|
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(
|
odoo_sale_order_id = models.IntegerField(
|
||||||
null=True, blank=True, verbose_name=_("Odoo Sale Order ID")
|
null=True, blank=True, verbose_name=_("Odoo Sale Order ID")
|
||||||
|
|
@ -101,18 +77,6 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.urls.base
|
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):
|
def set_owner(self, user):
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
OrganizationMembership.objects.filter(user=user, organization=self).delete()
|
OrganizationMembership.objects.filter(user=user, organization=self).delete()
|
||||||
|
|
@ -130,7 +94,7 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create_organization(cls, instance, owner=None):
|
def create_organization(cls, instance, owner):
|
||||||
try:
|
try:
|
||||||
instance.origin
|
instance.origin
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -138,23 +102,9 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
pk=settings.SERVALA_DEFAULT_ORIGIN
|
pk=settings.SERVALA_DEFAULT_ORIGIN
|
||||||
)
|
)
|
||||||
instance.save()
|
instance.save()
|
||||||
if owner:
|
instance.set_owner(owner)
|
||||||
instance.set_owner(owner)
|
|
||||||
|
|
||||||
if instance.origin and instance.origin.default_odoo_sale_order_id:
|
if (
|
||||||
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 (
|
|
||||||
instance.billing_entity.odoo_company_id
|
instance.billing_entity.odoo_company_id
|
||||||
and instance.billing_entity.odoo_invoice_id
|
and instance.billing_entity.odoo_invoice_id
|
||||||
):
|
):
|
||||||
|
|
@ -181,34 +131,6 @@ class Organization(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
return instance
|
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:
|
class Meta:
|
||||||
verbose_name = _("Organization")
|
verbose_name = _("Organization")
|
||||||
verbose_name_plural = _("Organizations")
|
verbose_name_plural = _("Organizations")
|
||||||
|
|
@ -391,48 +313,6 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
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:
|
class Meta:
|
||||||
verbose_name = _("Organization origin")
|
verbose_name = _("Organization origin")
|
||||||
|
|
@ -481,120 +361,3 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} in {self.organization} as {self.role}"
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -156,17 +156,7 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
verbose_name=_("User Information"),
|
verbose_name=_("User Information"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
'Array of info objects: [{"title": "…", "content": "…", "help_text": "…"}]. '
|
"Key-value information displayed to users when selecting this control plane"
|
||||||
"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)"
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -360,24 +350,6 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
|
||||||
null=True,
|
null=True,
|
||||||
blank=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(
|
service = models.ForeignKey(
|
||||||
to="Service",
|
to="Service",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
|
@ -520,22 +492,6 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
||||||
return
|
return
|
||||||
return generate_model_form_class(self.django_model)
|
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):
|
class ServiceOffering(ServalaModelMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
@ -555,12 +511,6 @@ class ServiceOffering(ServalaModelMixin, models.Model):
|
||||||
verbose_name=_("Provider"),
|
verbose_name=_("Provider"),
|
||||||
)
|
)
|
||||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
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(
|
osb_plan_id = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
null=True,
|
null=True,
|
||||||
|
|
@ -628,7 +578,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
}
|
}
|
||||||
|
|
||||||
class urls(urlman.Urls):
|
class urls(urlman.Urls):
|
||||||
base = "{self.organization.urls.instances}{self.name}-{self.pk}/"
|
base = "{self.organization.urls.instances}{self.name}/"
|
||||||
update = "{base}update/"
|
update = "{base}update/"
|
||||||
delete = "{base}delete/"
|
delete = "{base}delete/"
|
||||||
|
|
||||||
|
|
@ -707,7 +657,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
return mark_safe(f"<ul>{error_items}</ul>")
|
return mark_safe(f"<ul>{error_items}</ul>")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
|
||||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||||
# Ensure the namespace exists
|
# Ensure the namespace exists
|
||||||
context.control_plane.get_or_create_namespace(organization)
|
context.control_plane.get_or_create_namespace(organization)
|
||||||
|
|
@ -755,7 +704,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
body=create_data,
|
body=create_data,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Transaction will automatically roll back the instance creation
|
instance.delete()
|
||||||
if isinstance(e, ApiException):
|
if isinstance(e, ApiException):
|
||||||
try:
|
try:
|
||||||
error_body = json.loads(e.body)
|
error_body = json.loads(e.body)
|
||||||
|
|
@ -899,6 +848,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
return
|
return
|
||||||
return self.context.django_model(
|
return self.context.django_model(
|
||||||
name=self.name,
|
name=self.name,
|
||||||
|
organization=self.organization,
|
||||||
context=self.context,
|
context=self.context,
|
||||||
spec=self.spec,
|
spec=self.spec,
|
||||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
# 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
|
import base64
|
||||||
|
|
||||||
for key, value in secret.data.items():
|
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:
|
try:
|
||||||
credentials[key] = base64.b64decode(value).decode("utf-8")
|
credentials[key] = base64.b64decode(value).decode("utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -969,21 +916,5 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(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)
|
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)
|
||||||
|
|
|
||||||
|
|
@ -207,19 +207,3 @@ def get_invoice_addresses(user):
|
||||||
return invoice_addresses or []
|
return invoice_addresses or []
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
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])
|
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,20 @@ def has_organization_role(user, org, roles):
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
def is_organization_owner(user, obj):
|
def is_organization_owner(user, obj):
|
||||||
from servala.core.models.organization import OrganizationRole
|
|
||||||
|
|
||||||
if hasattr(obj, "organization"):
|
if hasattr(obj, "organization"):
|
||||||
org = obj.organization
|
org = obj.organization
|
||||||
else:
|
else:
|
||||||
org = obj
|
org = obj
|
||||||
return has_organization_role(user, org, [OrganizationRole.OWNER])
|
return has_organization_role(user, org, ["owner"])
|
||||||
|
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
def is_organization_admin(user, obj):
|
def is_organization_admin(user, obj):
|
||||||
from servala.core.models.organization import OrganizationRole
|
|
||||||
|
|
||||||
if hasattr(obj, "organization"):
|
if hasattr(obj, "organization"):
|
||||||
org = obj.organization
|
org = obj.organization
|
||||||
else:
|
else:
|
||||||
org = obj
|
org = obj
|
||||||
return has_organization_role(
|
return has_organization_role(user, org, ["owner", "admin"])
|
||||||
user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@rules.predicate
|
@rules.predicate
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
|
|
||||||
def add_organizations(request):
|
def add_organizations(request):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return {"user_organizations": []}
|
return {"user_organizations": []}
|
||||||
|
|
||||||
return {"user_organizations": request.user.organizations.all().order_by("name")}
|
return {"user_organizations": request.user.organizations.all().order_by("name")}
|
||||||
|
|
||||||
|
|
||||||
def add_beta_banner(request):
|
|
||||||
return {"show_beta_banner": settings.SERVALA_SHOW_BETA_BANNER}
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,19 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.core.validators import RegexValidator
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from django.utils.translation import gettext_lazy as _
|
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.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||||
from servala.frontend.forms.mixins import HtmxMixin
|
from servala.frontend.forms.mixins import HtmxMixin
|
||||||
|
|
||||||
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationForm(HtmxMixin, ModelForm):
|
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:
|
class Meta:
|
||||||
model = Organization
|
model = Organization
|
||||||
fields = ("name",)
|
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):
|
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(
|
billing_processing_choice = forms.ChoiceField(
|
||||||
choices=[
|
choices=[
|
||||||
("existing", _("Use an existing billing address")),
|
("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_'
|
# Fields for creating a new billing address in Odoo, prefixed with 'invoice_'
|
||||||
invoice_street = forms.CharField(
|
invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100)
|
||||||
label=_("Line 1"),
|
invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100)
|
||||||
required=False,
|
invoice_city = forms.CharField(label=_("City"), required=False, max_length=100)
|
||||||
max_length=128,
|
invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20)
|
||||||
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_country = forms.ChoiceField(
|
invoice_country = forms.ChoiceField(
|
||||||
label=_("Country"),
|
label=_("Country"),
|
||||||
required=False,
|
required=False,
|
||||||
choices=get_odoo_countries(),
|
choices=get_odoo_countries(),
|
||||||
)
|
)
|
||||||
invoice_email = forms.EmailField(
|
invoice_email = forms.EmailField(label=_("Billing Email"), required=False)
|
||||||
label=_("Billing Email"),
|
invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30)
|
||||||
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"),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta(OrganizationForm.Meta):
|
class Meta(OrganizationForm.Meta):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def __init__(self, *args, user=None, **kwargs):
|
def __init__(self, *args, user=None, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.user = user
|
|
||||||
if not self.initial.get("invoice_country"):
|
if not self.initial.get("invoice_country"):
|
||||||
default_country_name = "Switzerland"
|
default_country_name = "Switzerland"
|
||||||
country_choices = self.fields["invoice_country"].choices
|
country_choices = self.fields["invoice_country"].choices
|
||||||
|
|
@ -178,6 +55,7 @@ class OrganizationCreateForm(OrganizationForm):
|
||||||
self.initial["invoice_country"] = country_id
|
self.initial["invoice_country"] = country_id
|
||||||
break
|
break
|
||||||
|
|
||||||
|
self.user = user
|
||||||
self.odoo_addresses = get_invoice_addresses(self.user)
|
self.odoo_addresses = get_invoice_addresses(self.user)
|
||||||
|
|
||||||
if self.odoo_addresses:
|
if self.odoo_addresses:
|
||||||
|
|
@ -230,68 +108,3 @@ class OrganizationCreateForm(OrganizationForm):
|
||||||
"existing_odoo_address_id", _("Please select an invoice address.")
|
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||||
)
|
)
|
||||||
return cleaned_data
|
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(),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,6 @@ class ServiceFilterForm(forms.Form):
|
||||||
)
|
)
|
||||||
q = forms.CharField(label=_("Search"), required=False)
|
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):
|
def filter_queryset(self, queryset):
|
||||||
if category := self.cleaned_data.get("category"):
|
if category := self.cleaned_data.get("category"):
|
||||||
queryset = queryset.filter(category=category)
|
queryset = queryset.filter(category=category)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import json
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms.widgets import NumberInput
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicArrayWidget(forms.Widget):
|
class DynamicArrayWidget(forms.Widget):
|
||||||
|
|
@ -217,21 +216,3 @@ class DynamicArrayField(forms.JSONField):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
{% translate "Sign in" %}
|
{% translate "Sign in" %}
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
{% translate "Welcome to Servala - Sovereign App Store" %}
|
{% translate "Welcome to Servala" %}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
{% block card_header %}
|
{% block card_header %}
|
||||||
<div class="card-header text-center py-4"
|
<div class="card-header text-center py-4"
|
||||||
|
|
@ -26,23 +26,21 @@
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
|
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
|
||||||
<p class="text-muted mb-0">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% for provider in socialaccount_providers %}
|
{% for provider in socialaccount_providers %}
|
||||||
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
||||||
<form method="post"
|
<form method="post" action="{{ href }}">
|
||||||
action="{{ href }}"
|
|
||||||
class="d-flex justify-content-center">
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ redirect_field }}
|
{{ redirect_field }}
|
||||||
<button type="submit"
|
<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 }}"
|
title="{{ provider.name }}"
|
||||||
style="border-radius: 12px;
|
style="border-radius: 12px;
|
||||||
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
|
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
|
||||||
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
|
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
|
||||||
<span>{% translate "Sign in or Register" %}</span>
|
<span>{% translate "Sign in with VSHN Account" %}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>
|
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="main" class="layout-horizontal">
|
<div id="main" class="layout-horizontal">
|
||||||
{% include 'includes/beta_banner.html' %}
|
|
||||||
{% include 'includes/header.html' %}
|
{% include 'includes/header.html' %}
|
||||||
<div class="content-wrapper container">
|
<div class="content-wrapper container">
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
|
|
@ -66,8 +65,6 @@
|
||||||
<div class="float-end">
|
<div class="float-end">
|
||||||
<p>
|
<p>
|
||||||
Crafted with <span class="text-danger"><i class="bi bi-heart-fill icon-mid"></i></span> in Zurich
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,20 +78,13 @@
|
||||||
<script src="{% static 'js/dynamic-array.js' %}"></script>
|
<script src="{% static 'js/dynamic-array.js' %}"></script>
|
||||||
<!-- Ybug code start (https://ybug.io) -->
|
<!-- Ybug code start (https://ybug.io) -->
|
||||||
<script type='text/javascript'>
|
<script type='text/javascript'>
|
||||||
(function() {
|
(function() {
|
||||||
window.ybug_settings = {
|
window.ybug_settings = {"id":"q1tgbdjp26ydh8gygggv"};
|
||||||
"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 ybug = document.createElement('script');
|
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ybug, s);
|
||||||
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>
|
</script>
|
||||||
<!-- Ybug code end -->
|
<!-- Ybug code end -->
|
||||||
{% block extra_js %}
|
|
||||||
{% endblock extra_js %}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
<div class="dynamic-array-widget"
|
<div class="dynamic-array-widget"
|
||||||
id="{{ widget.name }}_container"
|
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
|
||||||
data-name="{{ widget.name }}"
|
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 %}>
|
|
||||||
<div class="array-items">
|
<div class="array-items">
|
||||||
{% for item in value_list %}
|
{% for item in value_list %}
|
||||||
<div class="array-item d-flex mb-2">
|
<div class="array-item d-flex mb-2">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if field.use_fieldset %}</fieldset>{% endif %}
|
{% if field.use_fieldset %}</fieldset>{% endif %}
|
||||||
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
{% 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"
|
<small class="form-text text-muted"
|
||||||
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "frontend/base.html" %}
|
{% extends "frontend/base.html" %}
|
||||||
{% load i18n static %}
|
{% load i18n static %}
|
||||||
{% block html_title %}
|
{% block html_title %}
|
||||||
{% translate "Dashboard" %} for {{ object.name }}
|
{{ object.name }} {% translate "Dashboard" %}
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
{% block page_title %}{% endblock %}
|
{% block page_title %}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% translate "Name" %}</th>
|
<th>{% translate "Name" %}</th>
|
||||||
<th>{% translate "Service" %}</th>
|
<th>{% translate "Service" %}</th>
|
||||||
|
<th>{% translate "Status" %}</th>
|
||||||
<th>{% translate "Created" %}</th>
|
<th>{% translate "Created" %}</th>
|
||||||
<th>{% translate "Actions" %}</th>
|
<th>{% translate "Actions" %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -95,7 +96,7 @@
|
||||||
{% for instance in service_instances %}
|
{% for instance in service_instances %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<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>
|
class="fw-semibold text-decoration-none">{{ instance.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -116,13 +117,13 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<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"
|
class="btn btn-outline-primary btn-sm"
|
||||||
title="{% translate 'View Details' %}">
|
title="{% translate 'View Details' %}">
|
||||||
<i class="bi bi-eye"></i>
|
<i class="bi bi-eye"></i>
|
||||||
</a>
|
</a>
|
||||||
{% if instance.has_change_permission %}
|
{% 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"
|
class="btn btn-outline-secondary btn-sm"
|
||||||
title="{% translate 'Edit' %}">
|
title="{% translate 'Edit' %}">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -32,7 +32,13 @@
|
||||||
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
{% for link in service.external_links %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -41,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row match-height card-grid">
|
<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="col-6 col-lg-3 col-md-4">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header card-header-with-logo">
|
<div class="card-header card-header-with-logo">
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,6 @@
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
{% block page_title_extra %}
|
{% block page_title_extra %}
|
||||||
<div>
|
<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 %}
|
{% if has_change_permission %}
|
||||||
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
|
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -111,16 +102,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if control_plane.user_info %}
|
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% if instance.spec and spec_fieldsets %}
|
{% if instance.spec and spec_fieldsets %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
|
|
@ -191,36 +173,34 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if instance.connection_credentials %}
|
{% if instance.connection_credentials %}
|
||||||
<div class="col-12">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-header">
|
||||||
<div class="card-header">
|
<h4>{% translate "Connection Credentials" %}</h4>
|
||||||
<h4>{% translate "Connection Credentials" %}</h4>
|
</div>
|
||||||
</div>
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<div class="table-responsive">
|
||||||
<div class="table-responsive">
|
<table class="table table-bordered">
|
||||||
<table class="table table-bordered">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
|
<th>{% translate "Name" %}</th>
|
||||||
|
<th>{% translate "Value" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key, value in instance.connection_credentials.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% translate "Name" %}</th>
|
<td>{{ key }}</td>
|
||||||
<th>{% translate "Value" %}</th>
|
<td>
|
||||||
|
{% if key == "error" %}
|
||||||
|
<span class="text-danger">{{ value }}</span>
|
||||||
|
{% else %}
|
||||||
|
<code>{{ value }}</code>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
{% endfor %}
|
||||||
<tbody>
|
</tbody>
|
||||||
{% for key, value in instance.connection_credentials.items %}
|
</table>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -257,12 +237,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% 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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
|
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
|
||||||
{% endblock page_title_extra %}
|
{% endblock page_title_extra %}
|
||||||
{% partialdef service-form %}
|
{% partialdef service-form %}
|
||||||
{% if form or custom_form %}
|
{% if form %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center"></div>
|
<div class="card-header d-flex align-items-center"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
|
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
{% if not form and not custom_form %}
|
{% if not form %}
|
||||||
<div class="alert alert-warning" role="alert">
|
<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." %}
|
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,13 @@
|
||||||
{{ offering }}
|
{{ offering }}
|
||||||
{% endblock page_title %}
|
{% endblock page_title %}
|
||||||
{% endblock html_title %}
|
{% endblock html_title %}
|
||||||
{% partialdef control-plane-info inline=True %}
|
{% partialdef control-plane-info %}
|
||||||
{% if selected_plane and selected_plane.user_info %}
|
{% if selected_plane %}
|
||||||
<div class="mt-3">
|
{% include "includes/control_plane_user_info.html" with control_plane=selected_plane %}
|
||||||
<div class="border-top pt-3">
|
|
||||||
{% include "includes/control_plane_user_info.html" with control_plane=selected_plane %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
{% partialdef service-form %}
|
{% partialdef service-form %}
|
||||||
{% if service_form or custom_service_form %}
|
{% if service_form %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex align-items-center"></div>
|
<div class="card-header d-flex align-items-center"></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -26,7 +22,7 @@
|
||||||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,127 +30,69 @@
|
||||||
{% endpartialdef %}
|
{% endpartialdef %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
{% if not has_control_planes %}
|
<div class="row">
|
||||||
<!-- No Service Available Message -->
|
<div class="col-12 col-lg-8">
|
||||||
<div class="row">
|
<div class="card">
|
||||||
<div class="col-12">
|
<div class="card-header d-flex align-items-center">
|
||||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
{% if service.logo %}
|
||||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
<img src="{{ service.logo.url }}"
|
||||||
<div>
|
alt="{{ service.name }}"
|
||||||
<strong>{% translate "Service Unavailable" %}</strong>
|
class="me-3"
|
||||||
<p class="mb-0">
|
style="max-width: 48px;
|
||||||
{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
|
max-height: 48px">
|
||||||
</p>
|
{% 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>
|
||||||
</div>
|
<div class="card-body">
|
||||||
</div>
|
{% if offering.description %}
|
||||||
{% else %}
|
<div class="row mb-3">
|
||||||
<!-- Two Column Layout -->
|
<div class="col-12">
|
||||||
<div class="row g-3">
|
<p>{{ offering.description|urlize }}</p>
|
||||||
<!-- Left Column: Service Provider Zone -->
|
</div>
|
||||||
<div class="col-12 col-lg-6">
|
</div>
|
||||||
<div class="card h-100">
|
{% endif %}
|
||||||
<div class="card-header">
|
{% if not has_control_planes %}
|
||||||
<h5 class="mb-0">{% translate "Service Provider Zone" %}</h5>
|
<p>{% translate "We currently cannot offer this service, sorry!" %}</p>
|
||||||
</div>
|
{% else %}
|
||||||
<div class="card-body">
|
|
||||||
<form hx-trigger="change"
|
<form hx-trigger="change"
|
||||||
hx-get="{{ request.path }}?fragment=service-form"
|
hx-get="{{ request.path }}?fragment=service-form"
|
||||||
hx-target="#service-form"
|
hx-target="#service-form"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML">
|
||||||
class="control-plane-select-form">
|
|
||||||
{{ select_form }}
|
{{ select_form }}
|
||||||
</form>
|
</form>
|
||||||
<style>
|
{% endif %}
|
||||||
.control-plane-select-form .form-label {
|
{% if service.external_links %}
|
||||||
display: none;
|
<div class="row mt-3">
|
||||||
}
|
<div class="col-12">
|
||||||
</style>
|
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||||
<div id="control-plane-info"
|
<div class="d-flex flex-wrap gap-2">
|
||||||
hx-trigger="load, change from:form"
|
{% for link in service.external_links %}
|
||||||
hx-get="{{ request.path }}?fragment=control-plane-info">
|
<a href="{{ link.url }}"
|
||||||
{% partial control-plane-info %}
|
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>
|
||||||
</div>
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</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 id="service-form">{% partial service-form %}</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock content %}
|
{% 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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -16,37 +16,50 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% for service in services %}
|
||||||
<div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
|
<div class="col-12 col-md-6 col-lg-3">
|
||||||
{% empty %}
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-header card-header-with-logo">
|
||||||
<div class="card-content">
|
{% if service.logo %}<img src="{{ service.logo.url }}" alt="{{ service.name }}">{% endif %}
|
||||||
<p>{% translate "No services found." %}</p>
|
<div class="card-header-content">
|
||||||
|
<h4>{{ service.name }}</h4>
|
||||||
|
<small class="text-muted">{{ service.category }}</small>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
|
||||||
|
|
@ -36,97 +36,6 @@
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
{% endpartialdef org-name-edit %}
|
{% 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 %}
|
{% block content %}
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -156,136 +65,67 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
||||||
{% 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 %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h4 class="card-title">{% translate "Billing Information" %}</h4>
|
<h4 class="card-title">{% translate "Billing Address" %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>{{ form.instance.origin.billing_message }}</p>
|
{% with odoo_data=form.instance.billing_entity.odoo_data %}
|
||||||
</div>
|
<div class="table-responsive">
|
||||||
</div>
|
<table class="table table-lg">
|
||||||
</div>
|
<tbody>
|
||||||
{% endif %}
|
{% if odoo_data.invoice_address %}
|
||||||
{% if can_manage_members %}
|
<tr>
|
||||||
<div class="card">
|
<th class="w-25">
|
||||||
<div class="card-header">
|
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
|
||||||
<h4 class="card-title">
|
</th>
|
||||||
<i class="bi bi-people"></i> {% translate "Members" %}
|
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
|
||||||
</h4>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
<div class="card-content">
|
<tr>
|
||||||
<div class="card-body">{% partial members-list %}</div>
|
<th class="w-25">
|
||||||
</div>
|
<span class="d-flex mt-2">{% translate "Street" %}</span>
|
||||||
</div>
|
</th>
|
||||||
<div id="pending-invitations-card">{% partial pending-invitations-card %}</div>
|
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
|
||||||
<div class="card">
|
</tr>
|
||||||
<div class="card-header">
|
{% if odoo_data.invoice_address.street2 %}
|
||||||
<h4 class="card-title">
|
<tr>
|
||||||
<i class="bi bi-person-plus"></i> {% translate "Invite New Member" %}
|
<th class="w-25">
|
||||||
</h4>
|
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
|
||||||
</div>
|
</th>
|
||||||
<div class="card-content">
|
<td>{{ odoo_data.invoice_address.street2 }}</td>
|
||||||
<div class="card-body">
|
</tr>
|
||||||
<div class="alert alert-light mb-3">
|
{% endif %}
|
||||||
<h6>
|
<tr>
|
||||||
<i class="bi bi-info-circle"></i> {% translate "Role Permissions" %}
|
<th class="w-25">
|
||||||
</h6>
|
<span class="d-flex mt-2">{% translate "City" %}</span>
|
||||||
<ul class="mb-0">
|
</th>
|
||||||
<li>
|
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
|
||||||
<strong>{% translate "Owner" %}:</strong> {% translate "Can manage all organization settings, members, services, and can appoint administrators." %}
|
</tr>
|
||||||
</li>
|
<tr>
|
||||||
<li>
|
<th class="w-25">
|
||||||
<strong>{% translate "Administrator" %}:</strong> {% translate "Can manage members, invite users, and manage all services and instances." %}
|
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
|
||||||
</li>
|
</th>
|
||||||
<li>
|
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
|
||||||
<strong>{% translate "Member" %}:</strong> {% translate "Can view organization details, create and manage their own service instances." %}
|
</tr>
|
||||||
</li>
|
<tr>
|
||||||
</ul>
|
<th class="w-25">
|
||||||
</div>
|
<span class="d-flex mt-2">{% translate "Country" %}</span>
|
||||||
<form method="post" class="form">
|
</th>
|
||||||
{% csrf_token %}
|
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
|
||||||
<div class="row">{{ invitation_form }}</div>
|
</tr>
|
||||||
<div class="row mt-3">
|
<th class="w-25">
|
||||||
<div class="col-12">
|
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
|
||||||
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
|
</th>
|
||||||
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
|
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
|
||||||
</button>
|
</tr>
|
||||||
</div>
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -1,24 +1,26 @@
|
||||||
{% load i18n %}
|
{% 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 %}
|
{% if control_plane.user_info %}
|
||||||
<div class="control-plane-info-list">
|
<div class="card">
|
||||||
{% for info in control_plane.user_info %}
|
<div class="card-header">
|
||||||
<div class="info-item mb-3">
|
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
|
||||||
<div class="d-flex align-items-center mb-1">
|
</div>
|
||||||
<small class="text-muted fw-semibold">{{ info.title }}</small>
|
<div class="card-content">
|
||||||
{% if info.help_text %}
|
<div class="table-responsive">
|
||||||
<i class="bi bi-info-circle ms-1 text-muted"
|
<table class="table mb-0 table-lg">
|
||||||
data-bs-toggle="popover"
|
<tbody>
|
||||||
data-bs-trigger="hover focus"
|
{% for key, value in control_plane.user_info.items %}
|
||||||
data-bs-placement="top"
|
<tr>
|
||||||
data-bs-content="{{ info.help_text }}"
|
<th>{{ key }}</th>
|
||||||
style="cursor: help;
|
<td>{{ value }}</td>
|
||||||
font-size: 0.875rem"></i>
|
</tr>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
<div class="bg-light-subtle p-2 rounded">
|
</table>
|
||||||
<code class="text-dark">{{ info.content }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'account_login' %}" class="sidebar-link">
|
<a href="{% url 'account_login' %}" class="sidebar-link">
|
||||||
<i class="bi bi-person-badge-fill"></i>
|
<i class="bi bi-person-badge-fill"></i>
|
||||||
<span>{% translate 'Sign in' %}</span>
|
<span>{% translate 'Login' %}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="#" class="burger-btn d-block d-xl-none">
|
<a href="#" class="burger-btn d-block d-xl-none">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,149 +1,56 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load get_field %}
|
{% load get_field %}
|
||||||
{% load static %}
|
|
||||||
<form class="form form-vertical crd-form"
|
<form class="form form-vertical crd-form"
|
||||||
method="post"
|
method="post"
|
||||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{% include "frontend/forms/errors.html" %}
|
{% include "frontend/forms/errors.html" %}
|
||||||
{% if form and expert_form and not hide_expert_mode %}
|
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||||
<div class="mb-3 text-end">
|
{% for fieldset in form.get_fieldsets %}
|
||||||
<a href="#"
|
{% if not fieldset.hidden %}
|
||||||
class="text-muted small"
|
<li class="nav-item" role="presentation">
|
||||||
id="expert-mode-toggle"
|
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||||
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
id="{{ fieldset.title|slugify }}-tab"
|
||||||
</div>
|
data-bs-toggle="tab"
|
||||||
{% endif %}
|
data-bs-target="#{{ fieldset.title|slugify }}"
|
||||||
<div id="custom-form-container"
|
type="button"
|
||||||
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
|
role="tab"
|
||||||
{% if form and form.context %}{{ form.context }}{% endif %}
|
aria-controls="{{ fieldset.title|slugify }}"
|
||||||
{% if form and form.get_fieldsets|length == 1 %}
|
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||||
{# Single fieldset - render without tabs #}
|
{{ fieldset.title }}
|
||||||
{% for fieldset in form.get_fieldsets %}
|
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||||
<div class="my-2">
|
</button>
|
||||||
{% for field in fieldset.fields %}
|
</li>
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% for subfieldset in fieldset.fieldsets %}
|
</ul>
|
||||||
{% if subfieldset.fields %}
|
<div class="tab-content" id="myTabContent">
|
||||||
<div>
|
{% for fieldset in form.get_fieldsets %}
|
||||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||||
{% for field in subfieldset.fields %}
|
id="{{ fieldset.title|slugify }}"
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
role="tabpanel"
|
||||||
{% endfor %}
|
aria-labelledby="{{ fieldset.title|slugify }}-tab">
|
||||||
</div>
|
{% for field in fieldset.fields %}
|
||||||
{% endif %}
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
{% 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 %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
{% for subfieldset in fieldset.fieldsets %}
|
||||||
<div class="tab-content" id="myTabContent">
|
{% if subfieldset.fields %}
|
||||||
{% for fieldset in form.get_fieldsets %}
|
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
{% for field in subfieldset.fields %}
|
||||||
id="custom-{{ fieldset.title|slugify }}"
|
|
||||||
role="tabpanel"
|
|
||||||
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
|
|
||||||
{% for field in fieldset.fields %}
|
|
||||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||||
{% endfor %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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>
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endif %}
|
</div>
|
||||||
{% if form %}
|
|
||||||
<input type="hidden"
|
|
||||||
name="active_form"
|
|
||||||
id="active-form-input"
|
|
||||||
value="custom">
|
|
||||||
{% endif %}
|
|
||||||
<div class="col-sm-12 d-flex justify-content-end">
|
<div class="col-sm-12 d-flex justify-content-end">
|
||||||
{# browser form validation fails when there are fields missing/invalid that are hidden #}
|
<button class="btn btn-primary me-1 mb-1" type="submit">
|
||||||
<input class="btn btn-primary me-1 mb-1"
|
{% if form_submit_label %}
|
||||||
type="submit"
|
{{ form_submit_label }}
|
||||||
{% if form and expert_form %}formnovalidate{% endif %}
|
{% else %}
|
||||||
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
{% translate "Save" %}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
@ -7,5 +5,4 @@ register = template.Library()
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_field(form, field_name):
|
def get_field(form, field_name):
|
||||||
with suppress(KeyError):
|
return form[field_name]
|
||||||
return form[field_name]
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -6,11 +6,6 @@ from servala.frontend import views
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
|
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
|
||||||
path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
|
path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
|
||||||
path(
|
|
||||||
"invitations/<str:secret>/accept/",
|
|
||||||
views.InvitationAcceptView.as_view(),
|
|
||||||
name="invitation.accept",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"organizations/",
|
"organizations/",
|
||||||
views.OrganizationSelectionView.as_view(),
|
views.OrganizationSelectionView.as_view(),
|
||||||
|
|
@ -30,11 +25,6 @@ urlpatterns = [
|
||||||
views.OrganizationUpdateView.as_view(),
|
views.OrganizationUpdateView.as_view(),
|
||||||
name="organization.details",
|
name="organization.details",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"details/invitations/<int:pk>/delete/",
|
|
||||||
views.InvitationDeleteView.as_view(),
|
|
||||||
name="invitation.delete",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"services/",
|
"services/",
|
||||||
views.ServiceListView.as_view(),
|
views.ServiceListView.as_view(),
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ from .generic import (
|
||||||
custom_500,
|
custom_500,
|
||||||
)
|
)
|
||||||
from .organization import (
|
from .organization import (
|
||||||
InvitationAcceptView,
|
|
||||||
InvitationDeleteView,
|
|
||||||
OrganizationCreateView,
|
OrganizationCreateView,
|
||||||
OrganizationDashboardView,
|
OrganizationDashboardView,
|
||||||
OrganizationUpdateView,
|
OrganizationUpdateView,
|
||||||
|
|
@ -27,8 +25,6 @@ from .support import SupportView
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"IndexView",
|
"IndexView",
|
||||||
"InvitationAcceptView",
|
|
||||||
"InvitationDeleteView",
|
|
||||||
"LogoutView",
|
"LogoutView",
|
||||||
"OrganizationCreateView",
|
"OrganizationCreateView",
|
||||||
"OrganizationDashboardView",
|
"OrganizationDashboardView",
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,16 @@
|
||||||
from django.contrib import messages
|
from django.shortcuts import redirect
|
||||||
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.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import CreateView, DeleteView, DetailView, TemplateView
|
from django.views.generic import CreateView, DetailView
|
||||||
from django_scopes import scopes_disabled
|
|
||||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||||
|
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
BillingEntity,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationInvitation,
|
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
ServiceInstance,
|
ServiceInstance,
|
||||||
)
|
)
|
||||||
from servala.frontend.forms.organization import (
|
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||||
OrganizationCreateForm,
|
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||||
OrganizationForm,
|
|
||||||
OrganizationInvitationForm,
|
|
||||||
)
|
|
||||||
from servala.frontend.views.mixins import (
|
|
||||||
HtmxUpdateView,
|
|
||||||
HtmxViewMixin,
|
|
||||||
OrganizationViewMixin,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||||
|
|
@ -111,225 +96,10 @@ class OrganizationDashboardView(
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class OrganizationMembershipMixin:
|
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
||||||
template_name = "frontend/organizations/update.html"
|
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
|
form_class = OrganizationForm
|
||||||
fragments = (
|
fragments = ("org-name", "org-name-edit")
|
||||||
"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())
|
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return self.request.path
|
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())
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
@ -36,24 +36,22 @@ class ServiceListView(OrganizationViewMixin, ListView):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return all services."""
|
"""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():
|
if self.filter_form.is_valid():
|
||||||
services = self.filter_form.filter_queryset(services)
|
services = self.filter_form.filter_queryset(services)
|
||||||
return services.distinct()
|
return services.distinct()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def filter_form(self):
|
def filter_form(self):
|
||||||
return ServiceFilterForm(
|
return ServiceFilterForm(data=self.request.GET or None)
|
||||||
data=self.request.GET or None, organization=self.request.organization
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["filter_form"] = self.filter_form
|
context["filter_form"] = self.filter_form
|
||||||
context["deactivated_services"] = (
|
|
||||||
self.request.organization.get_deactivated_services()
|
|
||||||
)
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -64,36 +62,10 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
|
||||||
permission_type = "view"
|
permission_type = "view"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.organization.get_visible_services()
|
return Service.objects.select_related("category").prefetch_related(
|
||||||
|
"offerings",
|
||||||
@cached_property
|
"offerings__provider",
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
|
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
|
||||||
|
|
@ -104,14 +76,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
fragments = ("service-form", "control-plane-info")
|
fragments = ("service-form", "control-plane-info")
|
||||||
|
|
||||||
def has_permission(self):
|
def has_permission(self):
|
||||||
if not self.has_organization_permission():
|
return 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
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return ServiceOffering.objects.all().select_related(
|
return ServiceOffering.objects.all().select_related(
|
||||||
|
|
@ -142,9 +107,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
def context_object(self):
|
def context_object(self):
|
||||||
if self.request.method == "POST":
|
if self.request.method == "POST":
|
||||||
return ControlPlaneCRD.objects.filter(
|
return ControlPlaneCRD.objects.filter(
|
||||||
pk=self.request.POST.get(
|
pk=self.request.POST.get("context"),
|
||||||
"expert-context", self.request.POST.get("custom-context")
|
|
||||||
),
|
|
||||||
# Make sure we don’t use a malicious ID
|
# Make sure we don’t use a malicious ID
|
||||||
control_plane__in=self.planes,
|
control_plane__in=self.planes,
|
||||||
).first()
|
).first()
|
||||||
|
|
@ -152,52 +115,15 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
control_plane=self.selected_plane, service_offering=self.object
|
control_plane=self.selected_plane, service_offering=self.object
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
def get_instance_form_kwargs(self, ignore_data=False):
|
def get_instance_form(self):
|
||||||
return {
|
if not self.context_object or not self.context_object.model_form_class:
|
||||||
"initial": {
|
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,
|
"organization": self.request.organization,
|
||||||
"context": self.context_object,
|
"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):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
@ -205,23 +131,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["select_form"] = self.select_form
|
context["select_form"] = self.select_form
|
||||||
context["has_control_planes"] = self.planes.exists()
|
context["has_control_planes"] = self.planes.exists()
|
||||||
context["selected_plane"] = self.selected_plane
|
context["selected_plane"] = self.selected_plane
|
||||||
context["hide_expert_mode"] = self.hide_expert_mode
|
context["service_form"] = self.get_instance_form()
|
||||||
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
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
@ -232,10 +142,7 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
context["form_error"] = True
|
context["form_error"] = True
|
||||||
return self.render_to_response(context)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
if self.is_custom_form:
|
form = self.get_instance_form()
|
||||||
form = self.get_custom_instance_form()
|
|
||||||
else:
|
|
||||||
form = self.get_instance_form()
|
|
||||||
if not form: # Should not happen if context_object is valid, but as a safeguard
|
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
@ -263,13 +170,15 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
||||||
)
|
)
|
||||||
form.add_error(None, error_message)
|
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)
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
|
||||||
class ServiceInstanceMixin:
|
class ServiceInstanceMixin:
|
||||||
model = ServiceInstance
|
model = ServiceInstance
|
||||||
context_object_name = "instance"
|
context_object_name = "instance"
|
||||||
pk_url_kwarg = "slug"
|
slug_field = "name"
|
||||||
|
|
||||||
def dispatch(self, *args, **kwargs):
|
def dispatch(self, *args, **kwargs):
|
||||||
self._has_warned = False
|
self._has_warned = False
|
||||||
|
|
@ -286,25 +195,7 @@ class ServiceInstanceMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_object(self, **kwargs):
|
def get_object(self, **kwargs):
|
||||||
queryset = kwargs.get("queryset") or self.get_queryset()
|
instance = super().get_object(**kwargs)
|
||||||
|
|
||||||
# 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")
|
|
||||||
|
|
||||||
if not instance.kubernetes_object and not self._has_warned:
|
if not instance.kubernetes_object and not self._has_warned:
|
||||||
messages.warning(
|
messages.warning(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
@ -451,88 +342,11 @@ class ServiceInstanceUpdateView(
|
||||||
def get_form_kwargs(self):
|
def get_form_kwargs(self):
|
||||||
kwargs = super().get_form_kwargs()
|
kwargs = super().get_form_kwargs()
|
||||||
kwargs["instance"] = self.object.spec_object
|
kwargs["instance"] = self.object.spec_object
|
||||||
kwargs["prefix"] = "expert"
|
|
||||||
return kwargs
|
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):
|
def form_valid(self, form):
|
||||||
try:
|
try:
|
||||||
form_data = form.get_nested_data()
|
spec_data = form.get_nested_data().get("spec")
|
||||||
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)
|
|
||||||
|
|
||||||
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import FormView
|
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.forms.support import SupportForm
|
||||||
from servala.frontend.views.mixins import OrganizationViewMixin
|
from servala.frontend.views.mixins import OrganizationViewMixin
|
||||||
|
|
||||||
|
|
@ -23,16 +24,21 @@ class SupportView(OrganizationViewMixin, FormView):
|
||||||
if not partner_id:
|
if not partner_id:
|
||||||
raise Exception("Could not get or create Odoo contact for user")
|
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.
|
# 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
|
# Also, we want to be very sure that support requests work, especially for
|
||||||
# organizations where something in the creation process may have gone wrong,
|
# organizations where something in the creation process may have gone wrong,
|
||||||
# so if the ID does not exist, we omit it entirely.
|
# so if the ID does not exist, we omit it entirely.
|
||||||
create_helpdesk_ticket(
|
if organization.odoo_sale_order_id:
|
||||||
title=f"Servala Support - Organization {organization.name}",
|
ticket_data["sale_order_id"] = organization.odoo_sale_order_id
|
||||||
description=message,
|
|
||||||
partner_id=partner_id,
|
CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
|
||||||
sale_order_id=organization.odoo_sale_order_id or None,
|
|
||||||
)
|
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
_(
|
_(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ from servala.__about__ import __version__ as version
|
||||||
|
|
||||||
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||||
DEBUG = 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")
|
SECRET_KEY = os.environ.get("SERVALA_SECRET_KEY")
|
||||||
if previous_secret_key := os.environ.get("SERVALA_PREVIOUS_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.contrib.messages.context_processors.messages",
|
||||||
"django.template.context_processors.static",
|
"django.template.context_processors.static",
|
||||||
"servala.frontend.context_processors.add_organizations",
|
"servala.frontend.context_processors.add_organizations",
|
||||||
"servala.frontend.context_processors.add_beta_banner",
|
|
||||||
],
|
],
|
||||||
"loaders": template_loaders,
|
"loaders": template_loaders,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ overrides/adds settings specific to testing.
|
||||||
from servala.settings import * # noqa: F403, F401
|
from servala.settings import * # noqa: F403, F401
|
||||||
|
|
||||||
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
|
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||||
SALT_KEY = SECRET_KEY
|
|
||||||
PASSWORD_HASHERS = [
|
PASSWORD_HASHERS = [
|
||||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -237,127 +237,45 @@ a.btn-keycloak {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Expert CRD Form mandatory field styling */
|
/* CRD Form mandatory field styling */
|
||||||
.expert-crd-form .form-group.mandatory .form-label {
|
.crd-form .form-group.mandatory .form-label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .form-group.mandatory .form-label::after {
|
.crd-form .form-group.mandatory .form-label::after {
|
||||||
content: " *";
|
content: " *";
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .form-group.mandatory {
|
.crd-form .form-group.mandatory {
|
||||||
border-left: 3px solid #dc3545;
|
border-left: 3px solid #dc3545;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
background-color: rgba(220, 53, 69, 0.05);
|
background-color: rgba(220, 53, 69, 0.05);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
.crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||||
color: #dc3545;
|
color: #dc3545;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin-left: 4px;
|
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);
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
border-left-color: #ff6b6b;
|
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;
|
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;
|
color: #ff6b6b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crd-form .nav-tabs .nav-link.has-mandatory {
|
.crd-form .nav-tabs .nav-link.has-mandatory {
|
||||||
position: relative;
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
30
src/servala/static/js/bootstrap-tabs.js
vendored
30
src/servala/static/js/bootstrap-tabs.js
vendored
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -7,10 +7,6 @@ const initDynamicArrayWidget = () => {
|
||||||
const containers = document.querySelectorAll('.dynamic-array-widget')
|
const containers = document.querySelectorAll('.dynamic-array-widget')
|
||||||
|
|
||||||
containers.forEach(container => {
|
containers.forEach(container => {
|
||||||
if (container.dataset.initialized === 'true') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemsContainer = container.querySelector('.array-items')
|
const itemsContainer = container.querySelector('.array-items')
|
||||||
const addButton = container.querySelector('.add-array-item')
|
const addButton = container.querySelector('.add-array-item')
|
||||||
const hiddenInput = container.querySelector('input[type="hidden"]')
|
const hiddenInput = container.querySelector('input[type="hidden"]')
|
||||||
|
|
@ -26,7 +22,6 @@ const initDynamicArrayWidget = () => {
|
||||||
|
|
||||||
// Ensure hidden input is synced with visible inputs on initialization
|
// Ensure hidden input is synced with visible inputs on initialization
|
||||||
updateHiddenInput(container)
|
updateHiddenInput(container)
|
||||||
container.dataset.initialized = 'true'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,8 +124,6 @@ const updateRemoveButtonVisibility = (container) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
window.updateHiddenInput = updateHiddenInput
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initDynamicArrayWidget)
|
document.addEventListener('DOMContentLoaded', initDynamicArrayWidget)
|
||||||
document.addEventListener('htmx:afterSwap', initDynamicArrayWidget)
|
document.addEventListener('htmx:afterSwap', initDynamicArrayWidget)
|
||||||
document.addEventListener('htmx:afterSettle', initDynamicArrayWidget)
|
document.addEventListener('htmx:afterSettle', initDynamicArrayWidget)
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -3,7 +3,6 @@ import base64
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from servala.core.models import (
|
from servala.core.models import (
|
||||||
BillingEntity,
|
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationMembership,
|
OrganizationMembership,
|
||||||
OrganizationOrigin,
|
OrganizationOrigin,
|
||||||
|
|
@ -22,11 +21,6 @@ def origin():
|
||||||
return OrganizationOrigin.objects.create(name="TESTORIGIN")
|
return OrganizationOrigin.objects.create(name="TESTORIGIN")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def billing_entity():
|
|
||||||
return BillingEntity.objects.create(name="Test Entity")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def organization(origin):
|
def organization(origin):
|
||||||
return Organization.objects.create(name="Test Org", origin=origin)
|
return Organization.objects.create(name="Test Org", origin=origin)
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,6 @@ from django.core import mail
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from servala.core.models import Organization, OrganizationOrigin, User
|
from servala.core.models import Organization, OrganizationOrigin, User
|
||||||
from servala.core.models.service import (
|
|
||||||
ControlPlane,
|
|
||||||
ControlPlaneCRD,
|
|
||||||
ServiceDefinition,
|
|
||||||
ServiceInstance,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -79,8 +73,12 @@ def test_successful_onboarding_new_organization(
|
||||||
assert org.origin == exoscale_origin
|
assert org.origin == exoscale_origin
|
||||||
assert org.namespace.startswith("org-")
|
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():
|
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
|
billing_entity = org.billing_entity
|
||||||
assert billing_entity.name == "Test Organization Display (Exoscale)"
|
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_id == 789
|
||||||
assert org.odoo_sale_order_name == "SO001"
|
assert org.odoo_sale_order_name == "SO001"
|
||||||
assert org.limit_osb_services.all().count() == 1
|
|
||||||
|
|
||||||
assert len(mail.outbox) == 2
|
assert len(mail.outbox) == 2
|
||||||
invitation_email = mail.outbox[0]
|
invitation_email = mail.outbox[0]
|
||||||
assert (
|
assert invitation_email.subject == "Welcome to Servala - Test Organization Display"
|
||||||
invitation_email.subject
|
|
||||||
== "You're invited to join Test Organization Display on Servala"
|
|
||||||
)
|
|
||||||
assert "test@example.com" in invitation_email.to
|
assert "test@example.com" in invitation_email.to
|
||||||
|
|
||||||
welcome_email = mail.outbox[1]
|
welcome_email = mail.outbox[1]
|
||||||
|
|
@ -104,36 +98,6 @@ def test_successful_onboarding_new_organization(
|
||||||
assert "redis/offering/" in welcome_email.body
|
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
|
@pytest.mark.django_db
|
||||||
def test_duplicate_organization_returns_existing(
|
def test_duplicate_organization_returns_existing(
|
||||||
osb_client,
|
osb_client,
|
||||||
|
|
@ -143,12 +107,11 @@ def test_duplicate_organization_returns_existing(
|
||||||
exoscale_origin,
|
exoscale_origin,
|
||||||
instance_id,
|
instance_id,
|
||||||
):
|
):
|
||||||
org = Organization.objects.create(
|
Organization.objects.create(
|
||||||
name="Existing Org",
|
name="Existing Org",
|
||||||
osb_guid="test-org-guid-123",
|
osb_guid="test-org-guid-123",
|
||||||
origin=exoscale_origin,
|
origin=exoscale_origin,
|
||||||
)
|
)
|
||||||
org.limit_osb_services.add(test_service)
|
|
||||||
|
|
||||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_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)
|
response_data = json.loads(response.content)
|
||||||
assert response_data["message"] == "Service already enabled"
|
assert response_data["message"] == "Service already enabled"
|
||||||
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
|
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
|
@pytest.mark.django_db
|
||||||
|
|
@ -457,290 +420,3 @@ def test_organization_creation_with_context_only(
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
org = Organization.objects.get(osb_guid="fallback-org-guid")
|
org = Organization.objects.get(osb_guid="fallback-org-guid")
|
||||||
assert org is not None
|
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
|
|
@ -1,7 +1,5 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from servala.core.models.service import CloudProvider, ServiceOffering
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"url,redirect",
|
"url,redirect",
|
||||||
|
|
@ -47,103 +45,3 @@ def test_organization_linked_in_sidebar(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert organization.name in response.content.decode()
|
assert organization.name in response.content.decode()
|
||||||
assert other_organization.name not 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
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue