Compare commits
251 commits
2025.09.22
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f2b38a9f11 | |||
| cdc5947a63 | |||
| 5ef1fcc28c | |||
|
|
0aebbc42f0 | ||
|
|
0a47eedc9e | ||
|
|
c60a69a305 | ||
| f9bf71a2c1 | |||
| 1cf77e3b25 | |||
| 230b66ef92 | |||
|
|
0c09ae8eca | ||
|
|
8e725932c4 | ||
|
|
870acfa81b | ||
| 0a454964f0 | |||
| 31692a333f | |||
|
|
ea313eca1d | ||
|
|
1ca02bfb4d | ||
| 8b0c2a8d43 | |||
| 0a621f6372 | |||
| de6794046d | |||
| a268625d80 | |||
| c0d3a83c9d | |||
| f72c93ff2c | |||
| bcca366381 | |||
|
|
aa40027f0a | ||
|
|
ae92516747 | ||
|
|
def01771c4 | ||
| f99f8511bb | |||
| 234a169128 | |||
| 3e17e03da9 | |||
| 208f3c357d | |||
| e4c64c4a17 | |||
| 61f1065bc6 | |||
| 3d62595663 | |||
| 10fe5a9b2a | |||
| 68335a098d | |||
| dd2097a281 | |||
|
|
6996e56de7 | ||
|
|
e8d6c1acd5 | ||
| 1dbeb31985 | |||
| 3f9fdd0e48 | |||
| 4097261efe | |||
| 63f5982350 | |||
| 325d274cea | |||
| 4ffbd0fe5d | |||
| db590e0115 | |||
| e70c09dcee | |||
| 1f2524b4e8 | |||
| 35a382db1b | |||
|
|
37875349a0 | ||
| 830ebfb890 | |||
| b3ecae6fd9 | |||
| cbdbc253e8 | |||
| c00545b07c | |||
| f714923d48 | |||
| b23d216572 | |||
| 2e2843e906 | |||
|
|
edc3bdfc06 | ||
|
|
111cf3f03a | ||
|
|
779d7f72a2 | ||
|
|
3cb26fde55 | ||
| 21102f30c7 | |||
| ffed6139cd | |||
| 45f17cabaa | |||
| f3e14b4c85 | |||
| 9ac9f5e1c9 | |||
| 7a8dc91afe | |||
| c7c22aa265 | |||
| 561abc5f76 | |||
| 93b5a2a366 | |||
| 228ab9bc0d | |||
| 821e150239 | |||
|
|
0806523f18 | ||
|
|
eb8176d446 | ||
|
|
14f813fe25 | ||
|
|
ec72ab88c4 | ||
| 985e4b47c0 | |||
| 6182b36daf | |||
| 1ed261d4b2 | |||
| 14d60f80ca | |||
| fa7a170871 | |||
| ece60ad3b1 | |||
| bab9d636ee | |||
| 089dbb663a | |||
| b3bb41b322 | |||
| 29edfd2d3a | |||
| 68dce4c5fb | |||
| 7fbd57d1b1 | |||
| 16d8ac0c6d | |||
| 9e1804a141 | |||
| 5b496ec5b2 | |||
|
|
7d04e20bc0 | ||
| bbc1d735b6 | |||
| be00fb75cb | |||
| 7aa1040f16 | |||
| 6ef18d415f | |||
| 948ff5b8d8 | |||
| ca485978b9 | |||
| 59e7a75c51 | |||
| a5d46b696f | |||
| 5cc582b638 | |||
| aa77a10de2 | |||
| 7f99c78084 | |||
| 2931315b96 | |||
| 63039171c1 | |||
| 652e0798f4 | |||
| 9e7330e24d | |||
| cedcab85c4 | |||
| 0045e532ee | |||
| 357e39b543 | |||
| 1cf1947539 | |||
| 880d10c5e5 | |||
|
|
9eb6d71212 | ||
|
|
078f5aa90f | ||
|
|
ac8d38eee1 | ||
|
|
575a4c7f8f | ||
|
|
82842f0c8e | ||
| 930fd21fba | |||
|
|
660ea8af20 | ||
| 96469c0212 | |||
| 6ed6a8f4c3 | |||
| ddab04cb38 | |||
| e271c5e17e | |||
| d3e38a0ecb | |||
| eea743e5ae | |||
| 4365563fca | |||
| 775e47f484 | |||
| 211077914c | |||
|
|
4edb59c39c | ||
| fa621fe979 | |||
| 6a677f2653 | |||
|
|
1a919fa68e | ||
| a5cdbf6d63 | |||
| 3b97d4a1b6 | |||
| b8f3621b47 | |||
| 75fe0799e0 | |||
| bca79be02a | |||
| 2f593360fe | |||
|
|
214dff3ae4 | ||
|
|
6c2795d4fa | ||
|
|
62faaf2ed9 | ||
|
|
63c4b806ec | ||
| e5745ebb50 | |||
| b913e544a4 | |||
| 7a115fc6e9 | |||
| 74448d4066 | |||
| 0be3739ce6 | |||
| b6938f9204 | |||
| 5431f6ab83 | |||
| 534b2e8d72 | |||
| cb3464d9b5 | |||
| 6e7a06c544 | |||
| ed7070ff2c | |||
| 59025d14d2 | |||
| 15387c1ce7 | |||
| ea47bb3f3f | |||
| 090827bbbf | |||
| 3b49b17360 | |||
| d8ceaf4b1b | |||
| b4d239a1a6 | |||
| 714cd9be54 | |||
| 892a19bbcc | |||
| 7c6464330d | |||
| 45b2b93aba | |||
|
|
de0ac39901 | ||
| 850a791851 | |||
| 864c0ffc06 | |||
| 359bc58749 | |||
|
|
61c1aabeb0 | ||
|
|
71cf20ac1a | ||
|
|
0ef1678c3c | ||
|
|
5790e157bc | ||
|
|
ed4fbb3c1d | ||
| c346a2dac6 | |||
| 85df8ea510 | |||
| dddcbbcc1b | |||
| 41711e9a36 | |||
| ce34afa10a | |||
| 54998ab9d0 | |||
| 014e88aa24 | |||
| 8ba9787d4b | |||
|
|
a370f67932 | ||
| 3101829885 | |||
| cd886df05b | |||
| 4124add146 | |||
| 3375a1c8f3 | |||
| 573b7a5eb5 | |||
| 72fedefb7f | |||
| 27b9133ad4 | |||
| 4bf35260ad | |||
| b9ff0e61da | |||
| 21c26f9e5d | |||
| 09ab83d1e4 | |||
| 8b1e0f74bb | |||
| 842b66a84d | |||
| 6443582c0e | |||
| 819e13481e | |||
| 3730d95a6e | |||
| df3e3d5f0c | |||
| a644ad4e75 | |||
| 272451c92f | |||
|
|
ff93a6c366 | ||
|
|
5ba559dcc7 | ||
|
|
05f68fb6bd | ||
|
|
77e2e5871c | ||
| b5e7a7a9b7 | |||
| 985fa1907f | |||
| 5e279fef38 | |||
| 69807d034e | |||
| 5cd80b7270 | |||
| 26f39fec1e | |||
| c1a539bfad | |||
| cc23730d33 | |||
| e459047622 | |||
| 4be6eeb18f | |||
| 0a591f352a | |||
| f0320fb13b | |||
| 8edca54279 | |||
| 2e7143245a | |||
| 039b4a7031 | |||
| 4e5388e514 | |||
| b21148db01 | |||
| 7cbd1162ff | |||
| 261cc5e750 | |||
| 3ad16ce00b | |||
| 313e8cd09d | |||
| b2c5317da2 | |||
| da90003143 | |||
|
|
e8ba12e813 | ||
| 78a3349866 | |||
|
|
356d91a760 | ||
| 517808cc4c | |||
| 0e8b7935d2 | |||
| 912847a866 | |||
| 5ba7a66ecf | |||
| e91ad45b3a | |||
|
|
6c4ac644c6 | ||
|
|
b46ded3dca | ||
|
|
8f29b6d83f | ||
|
|
41a3b3dbee | ||
|
|
2f2a423a3c | ||
|
|
179680cd39 | ||
| 7b5dcf0619 | |||
| 1018c8726e | |||
| 2f5d42f27c | |||
| 3ef2c0bf91 | |||
|
|
5c687dd3f7 | ||
|
|
26ba06e60d | ||
| d8ba9130fb | |||
| b89e215b35 | |||
| a4ff4ecfdc | |||
| 9dcaa13569 |
92 changed files with 7313 additions and 1306 deletions
|
|
@ -4,6 +4,10 @@
|
|||
# When the environment is "development", DEBUG is set to True.
|
||||
SERVALA_ENVIRONMENT='development'
|
||||
|
||||
# Set to "False" to disable the beta testing banner at the top of every page.
|
||||
# Defaults to "True".
|
||||
SERVALA_SHOW_BETA_BANNER='True'
|
||||
|
||||
# Set SERVALA_PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions and to remain able to read encrypted fields!
|
||||
# In order to retire the previous key, run the ``reencrypt_fields`` command. Once you drop the previous secret key from
|
||||
# the rotation, all sessions that still rely on that key will be invalidated (i.e., users will have to log in again).
|
||||
|
|
@ -40,6 +44,7 @@ SERVALA_EMAIL_PASSWORD=''
|
|||
# At most one of the following settings may be set to True
|
||||
SERVALA_EMAIL_TLS='False'
|
||||
SERVALA_EMAIL_SSL='False'
|
||||
SERVALA_EMAIL_DEFAULT_FROM='noreply@servala.com'
|
||||
|
||||
# If the default OrganizationOrigin is **not** the one with the database ID 1, set it here.
|
||||
SERVALA_DEFAULT_ORIGIN='1'
|
||||
|
|
@ -68,3 +73,7 @@ SERVALA_ODOO_USERNAME=''
|
|||
SERVALA_ODOO_PASSWORD=''
|
||||
# Helpdesk team ID for support tickets in Odoo. Defaults to 5.
|
||||
SERVALA_ODOO_HELPDESK_TEAM_ID='5'
|
||||
|
||||
# OSB API authentication settings
|
||||
SERVALA_OSB_USERNAME=''
|
||||
SERVALA_OSB_PASSWORD=''
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ on:
|
|||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -23,7 +26,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -69,7 +72,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Determine image tag
|
||||
id: determine-tag
|
||||
|
|
@ -87,7 +90,7 @@ jobs:
|
|||
esac
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
uses: docker://quay.io/appuio/oc:v4.19
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
@ -104,7 +107,7 @@ jobs:
|
|||
OPENSHIFT_URL: ${{ secrets.OPENSHIFT_URL }}
|
||||
|
||||
- name: Verify deployment
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
uses: docker://quay.io/appuio/oc:v4.19
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -53,10 +53,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
uses: docker://quay.io/appuio/oc:v4.19
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
|
@ -49,10 +49,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Deploy to OpenShift
|
||||
uses: docker://quay.io/appuio/oc:v4.18
|
||||
uses: docker://quay.io/appuio/oc:v4.19
|
||||
with:
|
||||
entrypoint: /bin/bash
|
||||
args: |
|
||||
|
|
|
|||
|
|
@ -11,15 +11,15 @@ jobs:
|
|||
container: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- name: Renovate
|
||||
uses: https://github.com/renovatebot/github-action@v43.0.11
|
||||
uses: https://github.com/renovatebot/github-action@v44.0.5
|
||||
with:
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
uses: https://github.com/astral-sh/setup-uv@v7
|
||||
|
||||
- name: Run tests
|
||||
run: uv run --env-file=.env.example pytest
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.13
|
||||
3.14
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
EXPOSE 8000
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
The Servala Self-Service Portal
|
||||
|
||||
Latest release: 2025.09.22-0
|
||||
Latest release: 2025.11.17-0
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
@ -38,6 +38,12 @@ uv run --env-file=.env src/manage.py runserver
|
|||
|
||||
This will start the development server on http://localhost:8000.
|
||||
|
||||
For testing mail sending, `smtp4dev` can be used:
|
||||
|
||||
```
|
||||
docker run --rm -it -p 5000:80 -p 2525:25 docker.io/rnwood/smtp4dev
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration happens using environment variables.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
labels:
|
||||
- includeSelectors: true
|
||||
- includeSelectors: true
|
||||
pairs:
|
||||
app.kubernetes.io/instance: test
|
||||
app.kubernetes.io/instance: prod
|
||||
app.kubernetes.io/name: servala
|
||||
resources:
|
||||
- ../../base/portal
|
||||
- ../../base/database
|
||||
- ingress.yaml
|
||||
- ../../base/portal
|
||||
- ../../base/database
|
||||
- ingress.yaml
|
||||
patches:
|
||||
- path: portal-deployment.yaml
|
||||
- path: objectstorage.yaml
|
||||
- path: portal-deployment.yaml
|
||||
- path: objectstorage.yaml
|
||||
|
|
|
|||
|
|
@ -12,3 +12,30 @@ spec:
|
|||
value: production
|
||||
- name: SERVALA_ALLOWED_HOSTS
|
||||
value: portal.servala.com
|
||||
- name: SERVALA_STORAGE_BUCKET_NAME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: BUCKET_NAME
|
||||
- name: SERVALA_S3_ENDPOINT_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: ENDPOINT_URL
|
||||
- name: SERVALA_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: AWS_ACCESS_KEY_ID
|
||||
- name: SERVALA_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: portal-storage-creds
|
||||
key: AWS_SECRET_ACCESS_KEY
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
** xref:web-portal-admin.adoc[Admin]
|
||||
** xref:web-portal-controlplanes.adoc[Control-Planes]
|
||||
** xref:web-portal-billingentity.adoc[Billing Entities]
|
||||
** xref:web-portal-changelog.adoc[Changelog]
|
||||
|
||||
* xref:web-portal-planning.adoc[]
|
||||
** xref:user-stories.adoc[]
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ This Organization will be tied to Exoscale services, it can only see services fr
|
|||
|
||||
Exoscale uses the OpenServiceBroker (OSB) API for service provisioning and deprovisioning with external vendors, that's why we're integrating this way.
|
||||
|
||||
The API currently https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/#open-service-broker-api-osbapi[has the following features^]:
|
||||
The API currently https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#open-service-broker-api-osbapi[has the following features^]:
|
||||
|
||||
* Service instance provisioning
|
||||
* Service instance update (plan changes, suspensions, user sync)
|
||||
|
|
@ -155,7 +155,7 @@ https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#respon
|
|||
|
||||
Sources:
|
||||
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/#provisioning[Exoscale docs - Provisioning^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#provisioning[Exoscale docs - Provisioning^]
|
||||
* https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#provisioning[OSB API Spec^]
|
||||
|
||||
In the Servala Portal an Organization is created by the OSB API if it doesn't exist yet.
|
||||
|
|
@ -222,7 +222,7 @@ https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#respon
|
|||
|
||||
Sources:
|
||||
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/#service-instance-update[Exoscale docs - Service Instance Update^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#service-instance-update[Exoscale docs - Service Instance Update^]
|
||||
* https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#updating-a-service-instance[OSB API Spec^]
|
||||
|
||||
When the suspension plan is triggered, we send an E-Mail to customers@vshn.ch with all the information we have, so that we can check back with Exoscale what to do.
|
||||
|
|
@ -268,7 +268,7 @@ https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#respon
|
|||
|
||||
Sources:
|
||||
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/#deprovisioning[Exoscale docs - Deprovisioning^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#de-provisioning[Exoscale docs - Deprovisioning^]
|
||||
* https://github.com/openservicebrokerapi/servicebroker/blob/master/spec.md#deprovisioning[OSB API Spec^]
|
||||
|
||||
When all services are deleted (none exists anymore), an email is sent to customer@vshn.ch for the final closure of the organization.
|
||||
|
|
@ -280,7 +280,7 @@ See also <<Deprovisioning>>, which details the single service deprovisioning.
|
|||
|
||||
=== User Synchronization
|
||||
|
||||
We don't do https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/#user-sync[user synchronization^] from Exoscale to VSHN.
|
||||
We don't do https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#user-sync[user synchronization^] from Exoscale to VSHN.
|
||||
|
||||
____
|
||||
When user sync is disabled, only the information of the user that made the product purchase will be provided. The information will never be updated.
|
||||
|
|
@ -410,6 +410,6 @@ POST /orgs/:uuid/usage <1>
|
|||
* https://kb.vshn.ch/appuio-cloud/references/architecture/invitations.html[APPUiO Invitations]
|
||||
* https://github.com/vshn/crossplane-service-broker[Crossplane Service Broker (Code)^] - https://kb.vshn.ch/app-catalog/csp/spks/crossplane/overview.html[Crossplane Service Broker (Docs)^]
|
||||
* https://github.com/vshn/swisscom-service-broker[Swisscom Service Broker^]
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services/[Exoscale Vendor Documentation - Managed Services^]
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-billing/[Exoscale Vendor Documentation - Managed Services Billing^]
|
||||
* https://community.exoscale.com/documentation/vendor/marketplace-managed-services-provision/[Exoscale Vendor Documentation - Managed Services Provisioning^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc/[Exoscale Vendor Documentation - Managed Services^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc-bill/[Exoscale Vendor Documentation - Managed Services Billing^]
|
||||
* https://community.exoscale.com/vendor/marketplace-managed-svc-prov/[Exoscale Vendor Documentation - Managed Services Provisioning^]
|
||||
|
|
|
|||
97
docs/modules/ROOT/pages/web-portal-changelog.adoc
Normal file
97
docs/modules/ROOT/pages/web-portal-changelog.adoc
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
= 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
Normal file
116
hack/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# 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
|
||||
```
|
||||
149
hack/bumpver-post-commit-hook.sh
Executable file
149
hack/bumpver-post-commit-hook.sh
Executable file
|
|
@ -0,0 +1,149 @@
|
|||
#!/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
|
||||
134
hack/bumpver-pre-commit-hook.sh
Executable file
134
hack/bumpver-pre-commit-hook.sh
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/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,42 +3,43 @@ name = "servala"
|
|||
version = "0.0.0"
|
||||
description = "Servala portal server and frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.14.0"
|
||||
dependencies = [
|
||||
"argon2-cffi>=25.1.0",
|
||||
"cryptography>=45.0.7",
|
||||
"django==5.2.6",
|
||||
"django-allauth>=65.11.2",
|
||||
"django-auditlog>=3.2.1",
|
||||
"django-fernet-encrypted-fields>=0.3.0",
|
||||
"cryptography>=46.0.3",
|
||||
"django==5.2.8",
|
||||
"django-allauth>=65.13.1",
|
||||
"django-auditlog>=3.3.0",
|
||||
"django-fernet-encrypted-fields>=0.3.1",
|
||||
"django-jsonform>=2.23.2",
|
||||
"django-scopes>=2.0.0",
|
||||
"django-storages[s3]>=1.14.6",
|
||||
"django-template-partials>=25.1",
|
||||
"django-template-partials>=25.3",
|
||||
"jsonschema>=4.25.1",
|
||||
"kubernetes>=33.1.0",
|
||||
"pillow>=11.3.0",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
"kubernetes>=34.1.0",
|
||||
"pillow>=12.0.0",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"pyjwt>=2.10.1",
|
||||
"requests>=2.32.5",
|
||||
"rules>=3.5",
|
||||
"sentry-sdk[django]>=2.37.1",
|
||||
"sentry-sdk[django]>=2.46.0",
|
||||
"urlman>=2.0.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=25.1.0",
|
||||
"black>=25.11.0",
|
||||
"bumpver>=2025.1131",
|
||||
"coverage>=7.10.6",
|
||||
"coverage>=7.12.0",
|
||||
"djlint>=1.36.4",
|
||||
"flake8>=7.3.0",
|
||||
"flake8-bugbear>=24.12.12",
|
||||
"flake8-bugbear>=25.11.29",
|
||||
"flake8-pyproject>=1.2.3",
|
||||
"isort>=6.0.1",
|
||||
"pytest>=8.4.2",
|
||||
"pytest-cov>=6.3.0",
|
||||
"isort>=7.0.0",
|
||||
"pytest>=9.0.1",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
|
|
@ -54,21 +55,21 @@ ignore = "E203,W503"
|
|||
extend_exclude = "src/servala/static/mazer"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "servala.settings"
|
||||
DJANGO_SETTINGS_MODULE = "servala.settings_test"
|
||||
addopts = "-p no:doctest -p no:pastebin -p no:nose --cov=./ --cov-report=term-missing:skip-covered"
|
||||
testpaths = "src/tests"
|
||||
pythonpath = "src"
|
||||
|
||||
[tool.bumpver]
|
||||
current_version = "2025.09.22-0"
|
||||
current_version = "2025.11.17-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 = ""
|
||||
post_commit_hook = ""
|
||||
pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
|
||||
post_commit_hook = "hack/bumpver-post-commit-hook.sh"
|
||||
commit = true
|
||||
tag = true
|
||||
tag = false
|
||||
push = true
|
||||
|
||||
[tool.bumpver.file_patterns]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "2025.09.22-0"
|
||||
__version__ = "2025.11.17-0"
|
||||
|
|
|
|||
0
src/servala/api/__init__.py
Normal file
0
src/servala/api/__init__.py
Normal file
6
src/servala/api/apps.py
Normal file
6
src/servala/api/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "servala.api"
|
||||
50
src/servala/api/permissions.py
Normal file
50
src/servala/api/permissions.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import base64
|
||||
from contextlib import suppress
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
def get_username_and_password(request): # pragma: no cover
|
||||
# This method is vendored from assorted DRF bits, so we
|
||||
# skip it in our test coverage report
|
||||
auth = request.META.get("HTTP_AUTHORIZATION", b"")
|
||||
if isinstance(auth, str):
|
||||
# Work around django test client oddness
|
||||
auth = auth.encode("iso-8859-1")
|
||||
auth = auth.split()
|
||||
if not auth or auth[0].lower() != b"basic":
|
||||
return False, False
|
||||
if len(auth) != 2:
|
||||
return False, False
|
||||
|
||||
with suppress(TypeError, ValueError):
|
||||
try:
|
||||
auth_decoded = base64.b64decode(auth[1]).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
auth_decoded = base64.b64decode(auth[1]).decode("latin-1")
|
||||
|
||||
return auth_decoded.split(":", 1)
|
||||
|
||||
return False, False
|
||||
|
||||
|
||||
class OSBBasicAuthPermission:
|
||||
"""
|
||||
Basic auth for OSB is implemented as a permission class rather than as
|
||||
an authentication class, because authentication is expected to associate
|
||||
the request with a Django user. However, the OSB/Exoscale requests do not
|
||||
relate to a user account, so we treat the auth result as a permission instead.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
osb_username = getattr(settings, "OSB_USERNAME", None)
|
||||
osb_password = getattr(settings, "OSB_PASSWORD", None)
|
||||
|
||||
if not osb_username or not osb_password:
|
||||
return False # pragma: no cover
|
||||
|
||||
username, password = get_username_and_password(request)
|
||||
if username == osb_username and password == osb_password:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return HttpResponseForbidden()
|
||||
13
src/servala/api/urls.py
Normal file
13
src/servala/api/urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "api"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"osb/v2/service_instances/<str:instance_id>",
|
||||
views.OSBServiceInstanceView.as_view(),
|
||||
name="osb_service_instance",
|
||||
),
|
||||
]
|
||||
345
src/servala/api/views.py
Normal file
345
src/servala/api/views.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_not_required
|
||||
from django.core.mail import send_mail
|
||||
from django.db import transaction
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from servala.api.permissions import OSBBasicAuthPermission
|
||||
from servala.core.exoscale import get_exoscale_origin
|
||||
from servala.core.models import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationRole,
|
||||
User,
|
||||
)
|
||||
from servala.core.models.service import Service, ServiceInstance, ServiceOffering
|
||||
from servala.core.odoo import create_helpdesk_ticket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
|
||||
"""
|
||||
OSB API endpoint for service instance management via Exoscale.
|
||||
"""
|
||||
|
||||
def _error(self, error):
|
||||
return JsonResponse({"error": error}, status=400)
|
||||
|
||||
def _get_user(self, data):
|
||||
email = data.get("email", "").strip().lower()
|
||||
if not email:
|
||||
raise ValueError("Email address is required but missing or empty")
|
||||
|
||||
full_name = data.get("full_name") or ""
|
||||
name_parts = full_name.split(" ", 1)
|
||||
first_name = name_parts[0] if name_parts else ""
|
||||
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
||||
user, _ = User.objects.get_or_create(
|
||||
email=email,
|
||||
defaults={"first_name": first_name, "last_name": last_name},
|
||||
)
|
||||
return user
|
||||
|
||||
def put(self, request, instance_id):
|
||||
"""
|
||||
This implements the Exoscale onboarding flow.
|
||||
https://docs.servala.com/exoscale-osb.html#_onboarding
|
||||
https://community.exoscale.com/vendor/marketplace-managed-svc-prov/#provisioning
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
context = data.get("context", {})
|
||||
parameters = data.get("parameters", {})
|
||||
|
||||
organization_guid = context.get("organization_guid") or data.get(
|
||||
"organization_guid"
|
||||
)
|
||||
organization_name = context.get("organization_name")
|
||||
organization_display_name = context.get(
|
||||
"organization_display_name", organization_name
|
||||
)
|
||||
users = parameters.get("users", [])
|
||||
service_id = data.get("service_id")
|
||||
plan_id = data.get("plan_id")
|
||||
|
||||
if not organization_guid:
|
||||
return self._error("organization_guid is required but missing.")
|
||||
if not organization_name:
|
||||
return self._error("organization_name is required but missing.")
|
||||
if not users:
|
||||
return self._error("users array is required but missing.")
|
||||
if len(users) != 1:
|
||||
return self._error("users array is expected to contain a single user.")
|
||||
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:
|
||||
user = self._get_user(users[0])
|
||||
except Exception as e:
|
||||
return self._error(f"Unable to create user: {e}")
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
exoscale_origin = get_exoscale_origin()
|
||||
try:
|
||||
organization = Organization.objects.get(
|
||||
osb_guid=organization_guid, origin=exoscale_origin
|
||||
)
|
||||
if service in organization.limit_osb_services.all():
|
||||
return JsonResponse({"message": "Service already enabled"}, status=200)
|
||||
except Organization.DoesNotExist:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if exoscale_origin.billing_entity:
|
||||
billing_entity = exoscale_origin.billing_entity
|
||||
else:
|
||||
odoo_data = {
|
||||
"company_name": organization_display_name,
|
||||
"invoice_email": user.email,
|
||||
}
|
||||
billing_entity = BillingEntity.create_from_data(
|
||||
name=f"{organization_display_name} (Exoscale)",
|
||||
odoo_data=odoo_data,
|
||||
)
|
||||
organization = Organization(
|
||||
name=organization_display_name,
|
||||
billing_entity=billing_entity,
|
||||
origin=exoscale_origin,
|
||||
osb_guid=organization_guid,
|
||||
)
|
||||
organization = Organization.create_organization(organization)
|
||||
invitation = OrganizationInvitation.objects.create(
|
||||
organization=organization,
|
||||
email=user.email.lower(),
|
||||
role=OrganizationRole.OWNER,
|
||||
)
|
||||
invitation.send_invitation_email(request)
|
||||
except Exception:
|
||||
return JsonResponse({"error": "Internal server error"}, status=500)
|
||||
|
||||
organization.limit_osb_services.add(service)
|
||||
self._send_service_welcome_email(
|
||||
request, organization, user, service, service_offering
|
||||
)
|
||||
return JsonResponse({"message": "Successfully enabled service"}, status=201)
|
||||
|
||||
def _send_service_welcome_email(
|
||||
self, request, organization, user, service, service_offering
|
||||
):
|
||||
service_path = f"{organization.urls.services}{service.slug}/offering/{service_offering.id}/"
|
||||
service_url = request.build_absolute_uri(service_path)
|
||||
|
||||
subject = f"Get started with {service.name} - {organization.name}"
|
||||
message = f"""Hello {user.first_name or user.email},
|
||||
|
||||
Your organization "{organization.name}" is now ready on Servala Portal!
|
||||
|
||||
You can create your {service.name} service directly here:
|
||||
{service_url}
|
||||
|
||||
Or browse all available services at: {request.build_absolute_uri(organization.urls.services)}
|
||||
|
||||
Need help? Contact our support team through the portal.
|
||||
|
||||
Best regards,
|
||||
The Servala Team"""
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=settings.EMAIL_DEFAULT_FROM,
|
||||
recipient_list=[user.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
def delete(self, request, instance_id):
|
||||
"""
|
||||
This implements the Exoscale offboarding flow MVP.
|
||||
https://docs.servala.com/exoscale-osb.html#_offboarding
|
||||
"""
|
||||
service_id = request.GET.get("service_id")
|
||||
plan_id = request.GET.get("plan_id")
|
||||
|
||||
if not service_id:
|
||||
return self._error("service_id is required but missing.")
|
||||
if not plan_id:
|
||||
return self._error("plan_id is required but missing.")
|
||||
|
||||
try:
|
||||
service = Service.objects.get(osb_service_id=service_id)
|
||||
service_offering = ServiceOffering.objects.get(
|
||||
osb_plan_id=plan_id, service=service
|
||||
)
|
||||
except Service.DoesNotExist:
|
||||
return self._error(f"Unknown service_id: {service_id}")
|
||||
except ServiceOffering.DoesNotExist:
|
||||
return self._error(
|
||||
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
||||
)
|
||||
|
||||
self._create_action_helpdesk_ticket(
|
||||
request=request,
|
||||
action="Offboard",
|
||||
instance_id=instance_id,
|
||||
service=service,
|
||||
service_offering=service_offering,
|
||||
)
|
||||
|
||||
return JsonResponse({}, status=200)
|
||||
|
||||
def patch(self, request, instance_id):
|
||||
"""
|
||||
This implements the Exoscale suspension flow MVP.
|
||||
https://docs.servala.com/exoscale-osb.html#_suspension
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
service_id = data.get("service_id")
|
||||
plan_id = data.get("plan_id")
|
||||
|
||||
if not service_id:
|
||||
return self._error("service_id is required but missing.")
|
||||
if not plan_id:
|
||||
return self._error("plan_id is required but missing.")
|
||||
|
||||
try:
|
||||
service = Service.objects.get(osb_service_id=service_id)
|
||||
# Special handling: when plan_id is "suspend", don't lookup service_offering
|
||||
service_offering = None
|
||||
if plan_id != "suspend":
|
||||
service_offering = ServiceOffering.objects.get(
|
||||
osb_plan_id=plan_id, service=service
|
||||
)
|
||||
except Service.DoesNotExist: # pragma: no-cover
|
||||
return self._error(f"Unknown service_id: {service_id}")
|
||||
except ServiceOffering.DoesNotExist: # pragma: no-cover
|
||||
return self._error(
|
||||
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
|
||||
)
|
||||
|
||||
self._create_action_helpdesk_ticket(
|
||||
request=request,
|
||||
action="Suspend",
|
||||
instance_id=instance_id,
|
||||
service=service,
|
||||
service_offering=service_offering,
|
||||
users=data.get("parameters", {}).get("users"),
|
||||
)
|
||||
return JsonResponse({}, status=200)
|
||||
|
||||
def _get_admin_url(self, model_name, pk):
|
||||
admin_path = reverse(f"admin:{model_name}", args=[pk])
|
||||
return self.request.build_absolute_uri(admin_path)
|
||||
|
||||
def _create_action_helpdesk_ticket(
|
||||
self, request, action, instance_id, service, service_offering=None, users=None
|
||||
):
|
||||
"""
|
||||
Create an Odoo helpdesk ticket for offboarding or suspension actions.
|
||||
This is an MVP implementation that creates a ticket for manual handling.
|
||||
"""
|
||||
try:
|
||||
service_instance = None
|
||||
organization = None
|
||||
try:
|
||||
# Look for instances with this name in the service offering's context
|
||||
filter_kwargs = {"name": instance_id}
|
||||
if service_offering:
|
||||
filter_kwargs["context__service_offering"] = service_offering
|
||||
|
||||
service_instance = (
|
||||
ServiceInstance.objects.filter(**filter_kwargs)
|
||||
.select_related("organization")
|
||||
.first()
|
||||
)
|
||||
|
||||
if service_instance:
|
||||
organization = service_instance.organization
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
description_parts = [f"Action: {action}", f"Service: {service.name}"]
|
||||
if organization:
|
||||
org_url = self._get_admin_url(
|
||||
"core_organization_change", organization.pk
|
||||
)
|
||||
description_parts.append(
|
||||
f"Organization: {organization.name} - {org_url}"
|
||||
)
|
||||
|
||||
if service_instance:
|
||||
instance_url = self._get_admin_url(
|
||||
"core_serviceinstance_change", service_instance.pk
|
||||
)
|
||||
description_parts.append(
|
||||
f"Instance: {service_instance.name} - {instance_url}"
|
||||
)
|
||||
else:
|
||||
description_parts.append(f"Instance: {instance_id}")
|
||||
|
||||
if service_offering:
|
||||
offering_url = self._get_admin_url(
|
||||
"core_serviceoffering_change", service_offering.pk
|
||||
)
|
||||
description_parts.append(f"Service Offering: {offering_url}")
|
||||
|
||||
if users:
|
||||
description_parts.append("<br/>Users:")
|
||||
for user_data in users:
|
||||
email = user_data.get("email", "N/A")
|
||||
full_name = user_data.get("full_name", "N/A")
|
||||
role = user_data.get("role", "N/A")
|
||||
|
||||
user_link = email
|
||||
if email and email != "N/A":
|
||||
try:
|
||||
user = User.objects.get(email=email.strip().lower())
|
||||
user_link = self._get_admin_url("core_user_change", user.pk)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
description_parts.append(f" - {full_name} ({user_link}) - {role}")
|
||||
|
||||
description = "<br/>".join(description_parts)
|
||||
|
||||
create_helpdesk_ticket(
|
||||
title=f"Exoscale OSB {action} - {service.name} - {instance_id}",
|
||||
description=description,
|
||||
)
|
||||
logger.info(
|
||||
f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
|
||||
)
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_jsonform.widgets import JSONFormWidget
|
||||
|
|
@ -9,9 +12,9 @@ from servala.core.models import (
|
|||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
OrganizationOrigin,
|
||||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
|
|
@ -63,10 +66,15 @@ class OrganizationAdmin(admin.ModelAdmin):
|
|||
search_fields = ("name", "namespace")
|
||||
autocomplete_fields = ("billing_entity", "origin")
|
||||
inlines = (OrganizationMembershipInline,)
|
||||
filter_horizontal = ("limit_osb_services",)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
readonly_fields = list(super().get_readonly_fields(request, obj) or [])
|
||||
readonly_fields.append("namespace") # Always read-only
|
||||
|
||||
if obj and obj.has_inherited_billing_entity:
|
||||
readonly_fields.append("billing_entity")
|
||||
|
||||
return readonly_fields
|
||||
|
||||
|
||||
|
|
@ -78,8 +86,16 @@ class BillingEntityAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(OrganizationOrigin)
|
||||
class OrganizationOriginAdmin(admin.ModelAdmin):
|
||||
list_display = ("name",)
|
||||
list_display = (
|
||||
"name",
|
||||
"billing_entity",
|
||||
"default_odoo_sale_order_id",
|
||||
"hide_billing_address",
|
||||
)
|
||||
list_filter = ("hide_billing_address",)
|
||||
search_fields = ("name",)
|
||||
autocomplete_fields = ("billing_entity",)
|
||||
filter_horizontal = ("limit_cloudproviders",)
|
||||
|
||||
|
||||
@admin.register(OrganizationMembership)
|
||||
|
|
@ -91,6 +107,58 @@ class OrganizationMembershipAdmin(admin.ModelAdmin):
|
|||
date_hierarchy = "date_joined"
|
||||
|
||||
|
||||
@admin.register(OrganizationInvitation)
|
||||
class OrganizationInvitationAdmin(admin.ModelAdmin):
|
||||
list_display = ("email", "organization", "role", "is_accepted", "created_at")
|
||||
list_filter = ("role", "created_at", "accepted_at", "organization")
|
||||
search_fields = ("email", "organization__name")
|
||||
autocomplete_fields = ("organization", "accepted_by")
|
||||
readonly_fields = (
|
||||
"secret",
|
||||
"accepted_by",
|
||||
"accepted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
actions = ["send_invitation_emails"]
|
||||
|
||||
def is_accepted(self, obj):
|
||||
return obj.is_accepted
|
||||
|
||||
is_accepted.boolean = True
|
||||
is_accepted.short_description = _("Accepted")
|
||||
|
||||
def send_invitation_emails(self, request, queryset):
|
||||
pending_invitations = queryset.filter(accepted_by__isnull=True)
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for invitation in pending_invitations:
|
||||
try:
|
||||
invitation.send_invitation_email(request)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
messages.error(
|
||||
request,
|
||||
_(f"Failed to send invitation to {invitation.email}: {str(e)}"),
|
||||
)
|
||||
|
||||
if sent_count > 0:
|
||||
messages.success(
|
||||
request,
|
||||
_(f"Successfully sent {sent_count} invitation email(s)."),
|
||||
)
|
||||
|
||||
if failed_count > 0:
|
||||
messages.warning(
|
||||
request, _(f"Failed to send {failed_count} invitation email(s).")
|
||||
)
|
||||
|
||||
send_invitation_emails.short_description = _("Send invitation emails")
|
||||
|
||||
|
||||
@admin.register(ServiceCategory)
|
||||
class ServiceCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "parent")
|
||||
|
|
@ -99,11 +167,6 @@ class ServiceCategoryAdmin(admin.ModelAdmin):
|
|||
autocomplete_fields = ("parent",)
|
||||
|
||||
|
||||
class PlanInline(admin.TabularInline):
|
||||
model = Plan
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "category")
|
||||
|
|
@ -114,7 +177,6 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
# JSON schema for external_links field
|
||||
external_links_schema = {
|
||||
"type": "array",
|
||||
"title": "External Links",
|
||||
|
|
@ -147,7 +209,6 @@ class CloudProviderAdmin(admin.ModelAdmin):
|
|||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
# JSON schema for external_links field
|
||||
external_links_schema = {
|
||||
"type": "array",
|
||||
"title": "External Links",
|
||||
|
|
@ -180,7 +241,15 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{"fields": ("name", "description", "cloud_provider", "required_label")},
|
||||
{
|
||||
"fields": (
|
||||
"name",
|
||||
"description",
|
||||
"cloud_provider",
|
||||
"required_label",
|
||||
"wildcard_dns",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("API Credentials"),
|
||||
|
|
@ -230,14 +299,6 @@ class ControlPlaneAdmin(admin.ModelAdmin):
|
|||
test_kubernetes_connection.short_description = _("Test Kubernetes connection")
|
||||
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "service_offering", "term")
|
||||
list_filter = ("service_offering", "term")
|
||||
search_fields = ("name", "description")
|
||||
autocomplete_fields = ("service_offering",)
|
||||
|
||||
|
||||
@admin.register(ServiceDefinition)
|
||||
class ServiceDefinitionAdmin(admin.ModelAdmin):
|
||||
form = ServiceDefinitionAdminForm
|
||||
|
|
@ -258,8 +319,29 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
|
|||
"description": _("API definition for the Kubernetes Custom Resource"),
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Form Configuration"),
|
||||
{
|
||||
"fields": ("form_config", "hide_expert_mode"),
|
||||
"description": _(
|
||||
"Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec."
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
|
||||
with open(schema_path) as f:
|
||||
form_config_schema = json.load(f)
|
||||
|
||||
if "form_config" in form.base_fields:
|
||||
form.base_fields["form_config"].widget = JSONFormWidget(
|
||||
schema=form_config_schema
|
||||
)
|
||||
return form
|
||||
|
||||
def get_exclude(self, request, obj=None):
|
||||
# Exclude the original api_definition field as we're using our custom fields
|
||||
return ["api_definition"]
|
||||
|
|
@ -317,7 +399,24 @@ class ServiceOfferingAdmin(admin.ModelAdmin):
|
|||
list_filter = ("service", "provider")
|
||||
search_fields = ("description",)
|
||||
autocomplete_fields = ("service", "provider")
|
||||
inlines = (
|
||||
ControlPlaneCRDInline,
|
||||
PlanInline,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -84,3 +84,5 @@ def check_servala_production_settings(app_configs, **kwargs):
|
|||
id="servala.W001",
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
|
|
|||
|
|
@ -1,419 +0,0 @@
|
|||
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)
|
||||
31
src/servala/core/crd/__init__.py
Normal file
31
src/servala/core/crd/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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",
|
||||
]
|
||||
479
src/servala/core/crd/forms.py
Normal file
479
src/servala/core/crd/forms.py
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
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)
|
||||
167
src/servala/core/crd/models.py
Normal file
167
src/servala/core/crd/models.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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
|
||||
115
src/servala/core/crd/utils.py
Normal file
115
src/servala/core/crd/utils.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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)
|
||||
11
src/servala/core/exoscale.py
Normal file
11
src/servala/core/exoscale.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from servala.core.models import OrganizationOrigin
|
||||
|
||||
|
||||
def get_exoscale_origin():
|
||||
origin, _ = OrganizationOrigin.objects.get_or_create(
|
||||
name="exoscale-marketplace",
|
||||
defaults={
|
||||
"description": "Organizations created via Exoscale marketplace onboarding"
|
||||
},
|
||||
)
|
||||
return origin
|
||||
|
|
@ -1,18 +1,34 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import jsonschema
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_jsonform.widgets import JSONFormWidget
|
||||
|
||||
from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
|
||||
from servala.core.models import ControlPlane, ServiceDefinition
|
||||
|
||||
CONTROL_PLANE_USER_INFO_SCHEMA = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"CNAME Record": {
|
||||
"title": "CNAME Record",
|
||||
"title": {
|
||||
"type": "string",
|
||||
"title": "Title",
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"title": "Content",
|
||||
},
|
||||
"help_text": {
|
||||
"type": "string",
|
||||
"title": "Help Text (optional)",
|
||||
},
|
||||
},
|
||||
"additionalProperties": {"type": "string"},
|
||||
"required": ["title", "content"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -85,6 +101,12 @@ class ControlPlaneAdminForm(forms.ModelForm):
|
|||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
def fields_empty(fields):
|
||||
if not fields:
|
||||
return True
|
||||
return all(not field.get("controlplane_field_mapping") for field in fields)
|
||||
|
||||
|
||||
class ServiceDefinitionAdminForm(forms.ModelForm):
|
||||
api_group = forms.CharField(
|
||||
required=False,
|
||||
|
|
@ -113,6 +135,10 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
|||
self.fields["api_version"].initial = api_def.get("version", "")
|
||||
self.fields["api_kind"].initial = api_def.get("kind", "")
|
||||
|
||||
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
|
||||
with open(schema_path) as f:
|
||||
self.form_config_schema = json.load(f)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
|
|
@ -140,8 +166,250 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
|
|||
api_def["kind"] = api_kind
|
||||
cleaned_data["api_definition"] = api_def
|
||||
|
||||
form_config = cleaned_data.get("form_config")
|
||||
|
||||
# Convert empty form_config to None (no custom form)
|
||||
if form_config:
|
||||
if not form_config.get("fieldsets") or all(
|
||||
fields_empty(fieldset.get("fields"))
|
||||
for fieldset in form_config.get("fieldsets")
|
||||
):
|
||||
form_config = None
|
||||
cleaned_data["form_config"] = None
|
||||
|
||||
if form_config:
|
||||
form_config = self._normalize_form_config_types(form_config)
|
||||
cleaned_data["form_config"] = form_config
|
||||
|
||||
try:
|
||||
jsonschema.validate(
|
||||
instance=form_config, schema=self.form_config_schema
|
||||
)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise forms.ValidationError(
|
||||
{
|
||||
"form_config": _("Invalid form configuration: {}").format(
|
||||
e.message
|
||||
)
|
||||
}
|
||||
)
|
||||
except jsonschema.SchemaError as e:
|
||||
raise forms.ValidationError(
|
||||
{"form_config": _("Schema error: {}").format(e.message)}
|
||||
)
|
||||
|
||||
self._validate_field_mappings(form_config, cleaned_data)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def _normalize_form_config_types(self, form_config):
|
||||
"""
|
||||
Normalize form_config by converting string representations of numbers
|
||||
to actual integers/floats. The JSON form widget sends all values
|
||||
as strings, but the schema expects proper types.
|
||||
"""
|
||||
if not isinstance(form_config, dict):
|
||||
return form_config
|
||||
|
||||
integer_fields = ["max_length", "rows", "min_values", "max_values"]
|
||||
number_fields = ["min_value", "max_value"]
|
||||
|
||||
for fieldset in form_config.get("fieldsets", []):
|
||||
for field in fieldset.get("fields", []):
|
||||
for field_name in integer_fields:
|
||||
if field_name in field and field[field_name] is not None:
|
||||
value = field[field_name]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
field[field_name] = int(value) if value else None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
for field_name in number_fields:
|
||||
if field_name in field and field[field_name] is not None:
|
||||
value = field[field_name]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
field[field_name] = (
|
||||
int(value) if "." not in value else float(value)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return form_config
|
||||
|
||||
def _validate_field_mappings(self, form_config, cleaned_data):
|
||||
if not self.instance.pk:
|
||||
return
|
||||
crd = self.instance.offering_control_planes.all().first()
|
||||
if not crd:
|
||||
return
|
||||
|
||||
schema = None
|
||||
try:
|
||||
schema = crd.resource_schema
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
|
||||
return
|
||||
|
||||
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"}
|
||||
included_mappings = set()
|
||||
errors = []
|
||||
for fieldset in form_config.get("fieldsets", []):
|
||||
for field in fieldset.get("fields", []):
|
||||
mapping = field.get("controlplane_field_mapping")
|
||||
included_mappings.add(mapping)
|
||||
|
||||
# Validate that fields without defaults have required properties
|
||||
if mapping not in DEFAULT_FIELD_CONFIGS:
|
||||
if not field.get("label"):
|
||||
errors.append(
|
||||
_(
|
||||
"Field with mapping '{}' must have a 'label' property "
|
||||
"(or use a mapping with default config)"
|
||||
).format(mapping)
|
||||
)
|
||||
if not field.get("type"):
|
||||
errors.append(
|
||||
_(
|
||||
"Field with mapping '{}' must have a 'type' property "
|
||||
"(or use a mapping with default config)"
|
||||
).format(mapping)
|
||||
)
|
||||
|
||||
if mapping and mapping not in valid_paths:
|
||||
field_name = field.get("label", field.get("name", mapping))
|
||||
errors.append(
|
||||
_(
|
||||
"Field '{}' has invalid mapping '{}'. Valid paths are: {}"
|
||||
).format(
|
||||
field_name,
|
||||
mapping,
|
||||
", ".join(sorted(valid_paths)[:10])
|
||||
+ ("..." if len(valid_paths) > 10 else ""),
|
||||
)
|
||||
)
|
||||
|
||||
if field.get("type") == "choice" and field.get("choices"):
|
||||
self._validate_choice_field(
|
||||
field, mapping, spec_schema, "spec", errors
|
||||
)
|
||||
|
||||
for mandatory_field in MANDATORY_FIELDS:
|
||||
if mandatory_field not in included_mappings:
|
||||
errors.append(
|
||||
_(
|
||||
"Required field '{}' must be included in the form configuration"
|
||||
).format(mandatory_field)
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise forms.ValidationError({"form_config": errors})
|
||||
|
||||
def _validate_choice_field(self, field, mapping, spec_schema, prefix, errors):
|
||||
if not mapping:
|
||||
return
|
||||
|
||||
field_name = field.get("label", mapping)
|
||||
custom_choices = field.get("choices", [])
|
||||
|
||||
# Single-element choices [value] are transformed to [value, value]
|
||||
for i, choice in enumerate(custom_choices):
|
||||
if not isinstance(choice, (list, tuple)):
|
||||
errors.append(
|
||||
_(
|
||||
"Field '{}': Choice at index {} must be a list or tuple, "
|
||||
"but got: {}"
|
||||
).format(field_name, i, repr(choice))
|
||||
)
|
||||
return
|
||||
|
||||
choice_len = len(choice)
|
||||
if choice_len == 1:
|
||||
custom_choices[i] = [choice[0], choice[0]]
|
||||
elif choice_len == 0 or choice_len > 2:
|
||||
errors.append(
|
||||
_(
|
||||
"Field '{}': Choice at index {} must have 1 or 2 elements "
|
||||
"(got {}): {}"
|
||||
).format(field_name, i, choice_len, repr(choice))
|
||||
)
|
||||
return
|
||||
|
||||
field_schema = self._get_field_schema(spec_schema, mapping, prefix)
|
||||
if not field_schema:
|
||||
return
|
||||
|
||||
control_plane_choices = field_schema.get("enum", [])
|
||||
if not control_plane_choices:
|
||||
return
|
||||
|
||||
custom_choice_values = [choice[0] for choice in custom_choices]
|
||||
|
||||
invalid_choices = [
|
||||
value
|
||||
for value in custom_choice_values
|
||||
if value not in control_plane_choices
|
||||
]
|
||||
|
||||
if invalid_choices:
|
||||
errors.append(
|
||||
_(
|
||||
"Field '{}' has invalid choice values: {}. "
|
||||
"Valid choices from control plane are: {}"
|
||||
).format(
|
||||
field_name,
|
||||
", ".join(f"'{c}'" for c in invalid_choices),
|
||||
", ".join(f"'{c}'" for c in control_plane_choices),
|
||||
)
|
||||
)
|
||||
|
||||
def _get_field_schema(self, schema, field_path, prefix):
|
||||
if not field_path or not schema:
|
||||
return None
|
||||
|
||||
if field_path.startswith(prefix + "."):
|
||||
field_path = field_path[len(prefix) + 1 :]
|
||||
|
||||
parts = field_path.split(".")
|
||||
current_schema = schema
|
||||
|
||||
for part in parts:
|
||||
if not isinstance(current_schema, dict):
|
||||
return None
|
||||
|
||||
properties = current_schema.get("properties", {})
|
||||
if part not in properties:
|
||||
return None
|
||||
|
||||
current_schema = properties[part]
|
||||
|
||||
return current_schema
|
||||
|
||||
def _extract_field_paths(self, schema, prefix=""):
|
||||
paths = set()
|
||||
|
||||
if not isinstance(schema, dict):
|
||||
return paths
|
||||
|
||||
if "type" in schema and schema["type"] != "object":
|
||||
if prefix:
|
||||
paths.add(prefix)
|
||||
|
||||
if schema.get("properties"):
|
||||
for prop_name, prop_schema in schema["properties"].items():
|
||||
new_prefix = f"{prefix}.{prop_name}" if prefix else prop_name
|
||||
paths.add(new_prefix)
|
||||
paths.update(self._extract_field_paths(prop_schema, new_prefix))
|
||||
|
||||
if schema.get("type") == "array" and "items" in schema:
|
||||
if prefix:
|
||||
paths.add(prefix)
|
||||
|
||||
return paths
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.api_definition = self.cleaned_data["api_definition"]
|
||||
return super().save(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ class Migration(migrations.Migration):
|
|||
name="external_links",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
help_text='JSON array of link objects: {"url": "…", "title": "…", "featured": false}. Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages.',
|
||||
help_text=(
|
||||
'JSON array of link objects: {"url": "…", "title": "…", "featured": false}. '
|
||||
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
|
||||
),
|
||||
null=True,
|
||||
verbose_name="External links",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
# Generated by Django 5.2.6 on 2025-10-02 07:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0007_controlplane_user_info_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="organization",
|
||||
name="osb_guid",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Open Service Broker GUID, used for organizations created via OSB API",
|
||||
max_length=100,
|
||||
null=True,
|
||||
verbose_name="OSB GUID",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="osb_service_id",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Open Service Broker service ID for API matching",
|
||||
max_length=100,
|
||||
null=True,
|
||||
verbose_name="OSB Service ID",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="serviceoffering",
|
||||
name="osb_plan_id",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Open Service Broker plan ID for API matching",
|
||||
max_length=100,
|
||||
null=True,
|
||||
verbose_name="OSB Plan ID",
|
||||
),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Plan",
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# 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),
|
||||
]
|
||||
32
src/servala/core/migrations/0012_remove_advanced_fields.py
Normal file
32
src/servala/core/migrations/0012_remove_advanced_fields.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
27
src/servala/core/migrations/0013_add_form_config.py
Normal file
27
src/servala/core/migrations/0013_add_form_config.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
44
src/servala/core/migrations/0014_hide_billing_address.py
Normal file
44
src/servala/core/migrations/0014_hide_billing_address.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# 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,6 +1,7 @@
|
|||
from .organization import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
OrganizationOrigin,
|
||||
OrganizationRole,
|
||||
|
|
@ -9,7 +10,6 @@ from .service import (
|
|||
CloudProvider,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
Plan,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceDefinition,
|
||||
|
|
@ -24,10 +24,10 @@ __all__ = [
|
|||
"ControlPlane",
|
||||
"ControlPlaneCRD",
|
||||
"Organization",
|
||||
"OrganizationInvitation",
|
||||
"OrganizationMembership",
|
||||
"OrganizationOrigin",
|
||||
"OrganizationRole",
|
||||
"Plan",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceInstance",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import secrets
|
||||
|
||||
import rules
|
||||
import urlman
|
||||
from auditlog.registry import auditlog
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import models, transaction
|
||||
from django.http import HttpRequest
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import slugify
|
||||
|
|
@ -14,7 +21,18 @@ from servala.core.odoo import CLIENT
|
|||
|
||||
|
||||
class Organization(ServalaModelMixin, models.Model):
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
name = models.CharField(
|
||||
max_length=32,
|
||||
verbose_name=_("Name"),
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r"^[A-Za-z0-9\s]+$",
|
||||
message=_(
|
||||
"Organization name can only contain letters, numbers, and spaces."
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
# The namespace is generated as "org-{id}" in accordance with RFC 1035 Label Names.
|
||||
# It is nullable as we need to write to the database in order to read the ID, but should
|
||||
# not be null in practical use.
|
||||
|
|
@ -46,6 +64,12 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
related_name="organizations",
|
||||
verbose_name=_("Members"),
|
||||
)
|
||||
limit_osb_services = models.ManyToManyField(
|
||||
to="Service",
|
||||
related_name="+",
|
||||
verbose_name=_("Services activated from OSB"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
odoo_sale_order_id = models.IntegerField(
|
||||
null=True, blank=True, verbose_name=_("Odoo Sale Order ID")
|
||||
|
|
@ -53,6 +77,15 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
odoo_sale_order_name = models.CharField(
|
||||
max_length=100, null=True, blank=True, verbose_name=_("Odoo Sale Order Name")
|
||||
)
|
||||
osb_guid = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("OSB GUID"),
|
||||
help_text=_(
|
||||
"Open Service Broker GUID, used for organizations created via OSB API"
|
||||
),
|
||||
)
|
||||
|
||||
class urls(urlman.Urls):
|
||||
base = "/org/{self.slug}/"
|
||||
|
|
@ -68,6 +101,18 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
def get_absolute_url(self):
|
||||
return self.urls.base
|
||||
|
||||
@property
|
||||
def has_inherited_billing_entity(self):
|
||||
return self.origin and self.billing_entity == self.origin.billing_entity
|
||||
|
||||
@property
|
||||
def limit_cloudproviders(self):
|
||||
if self.origin:
|
||||
return self.origin.limit_cloudproviders.all()
|
||||
from servala.core.models import CloudProvider
|
||||
|
||||
return CloudProvider.objects.none()
|
||||
|
||||
def set_owner(self, user):
|
||||
with scopes_disabled():
|
||||
OrganizationMembership.objects.filter(user=user, organization=self).delete()
|
||||
|
|
@ -85,7 +130,7 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_organization(cls, instance, owner):
|
||||
def create_organization(cls, instance, owner=None):
|
||||
try:
|
||||
instance.origin
|
||||
except Exception:
|
||||
|
|
@ -93,9 +138,23 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
pk=settings.SERVALA_DEFAULT_ORIGIN
|
||||
)
|
||||
instance.save()
|
||||
if owner:
|
||||
instance.set_owner(owner)
|
||||
|
||||
if (
|
||||
if instance.origin and instance.origin.default_odoo_sale_order_id:
|
||||
sale_order_id = instance.origin.default_odoo_sale_order_id
|
||||
sale_order_data = CLIENT.search_read(
|
||||
model="sale.order",
|
||||
domain=[["id", "=", sale_order_id]],
|
||||
fields=["name"],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
instance.odoo_sale_order_id = sale_order_id
|
||||
if sale_order_data:
|
||||
instance.odoo_sale_order_name = sale_order_data[0]["name"]
|
||||
instance.save(update_fields=["odoo_sale_order_id", "odoo_sale_order_name"])
|
||||
elif (
|
||||
instance.billing_entity.odoo_company_id
|
||||
and instance.billing_entity.odoo_invoice_id
|
||||
):
|
||||
|
|
@ -122,6 +181,34 @@ class Organization(ServalaModelMixin, models.Model):
|
|||
|
||||
return instance
|
||||
|
||||
def get_visible_services(self):
|
||||
from servala.core.models import Service
|
||||
|
||||
queryset = Service.objects.all()
|
||||
if self.limit_osb_services.exists():
|
||||
queryset = self.limit_osb_services.all()
|
||||
if self.limit_cloudproviders.exists():
|
||||
queryset = queryset.filter(
|
||||
offerings__provider__in=self.limit_cloudproviders
|
||||
).distinct()
|
||||
return queryset.prefetch_related(
|
||||
"offerings", "offerings__provider"
|
||||
).select_related("category")
|
||||
|
||||
def get_deactivated_services(self):
|
||||
from servala.core.models import Service
|
||||
|
||||
if not self.limit_osb_services.exists():
|
||||
return Service.objects.none()
|
||||
|
||||
queryset = Service.objects.select_related("category")
|
||||
if self.limit_cloudproviders.exists():
|
||||
queryset = queryset.filter(
|
||||
offerings__provider__in=self.limit_cloudproviders
|
||||
).distinct()
|
||||
queryset = queryset.exclude(id__in=self.limit_osb_services.all())
|
||||
return queryset.prefetch_related("offerings", "offerings__provider")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organization")
|
||||
verbose_name_plural = _("Organizations")
|
||||
|
|
@ -304,6 +391,48 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
|
|||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
billing_entity = models.ForeignKey(
|
||||
to="BillingEntity",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="origins",
|
||||
verbose_name=_("Billing entity"),
|
||||
help_text=_(
|
||||
"If set, this billing entity will be used on new organizations with this origin."
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
limit_cloudproviders = models.ManyToManyField(
|
||||
to="CloudProvider",
|
||||
related_name="+",
|
||||
verbose_name=_("Limit to these Cloud providers"),
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"If set, all organizations with this origin will be limited to these cloud providers."
|
||||
),
|
||||
)
|
||||
default_odoo_sale_order_id = models.IntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Default Odoo Sale Order ID"),
|
||||
help_text=_(
|
||||
"If set, this sale order will be used for new organizations with this origin."
|
||||
),
|
||||
)
|
||||
hide_billing_address = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Hide Billing Address"),
|
||||
help_text=_(
|
||||
"If enabled, the billing address will not be shown in the organization details view."
|
||||
),
|
||||
)
|
||||
billing_message = models.TextField(
|
||||
blank=True,
|
||||
verbose_name=_("Billing Message"),
|
||||
help_text=_(
|
||||
"Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale')."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organization origin")
|
||||
|
|
@ -352,3 +481,120 @@ class OrganizationMembership(ServalaModelMixin, models.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"{self.user} in {self.organization} as {self.role}"
|
||||
|
||||
|
||||
class OrganizationInvitation(ServalaModelMixin, models.Model):
|
||||
organization = models.ForeignKey(
|
||||
to=Organization,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="invitations",
|
||||
verbose_name=_("Organization"),
|
||||
)
|
||||
email = models.EmailField(verbose_name=_("Email address"))
|
||||
role = models.CharField(
|
||||
max_length=20,
|
||||
choices=OrganizationRole.choices,
|
||||
default=OrganizationRole.MEMBER,
|
||||
verbose_name=_("Role"),
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=64,
|
||||
unique=True,
|
||||
editable=False,
|
||||
verbose_name=_("Secret token"),
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
to="core.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="created_invitations",
|
||||
verbose_name=_("Created by"),
|
||||
)
|
||||
accepted_by = models.ForeignKey(
|
||||
to="core.User",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="accepted_invitations",
|
||||
verbose_name=_("Accepted by"),
|
||||
)
|
||||
accepted_at = models.DateTimeField(
|
||||
null=True, blank=True, verbose_name=_("Accepted at")
|
||||
)
|
||||
|
||||
class urls(urlman.Urls):
|
||||
accept = "/invitations/{self.secret}/accept/"
|
||||
delete = "{self.organization.urls.details}invitations/{self.pk}/delete/"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organization invitation")
|
||||
verbose_name_plural = _("Organization invitations")
|
||||
|
||||
def __str__(self):
|
||||
return f"Invitation for {self.email} to {self.organization}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.secret:
|
||||
self.secret = secrets.token_urlsafe(48)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_accepted(self):
|
||||
# We check both accepted_by and accepted_at to avoid a deleted user
|
||||
# freeing up an invitation
|
||||
return bool(self.accepted_by or self.accepted_at)
|
||||
|
||||
@property
|
||||
def can_be_accepted(self):
|
||||
return not self.is_accepted
|
||||
|
||||
def send_invitation_email(self, request=None):
|
||||
subject = _("You're invited to join {organization} on Servala").format(
|
||||
organization=self.organization.name
|
||||
)
|
||||
|
||||
if request:
|
||||
invitation_url = request.build_absolute_uri(self.urls.accept)
|
||||
organization_url = request.build_absolute_uri(self.organization.urls.base)
|
||||
else:
|
||||
fake_request = HttpRequest()
|
||||
fake_request.META["SERVER_NAME"] = get_current_site(None).domain
|
||||
fake_request.META["SERVER_PORT"] = "443"
|
||||
fake_request.META["wsgi.url_scheme"] = "https"
|
||||
invitation_url = fake_request.build_absolute_uri(self.urls.accept)
|
||||
organization_url = fake_request.build_absolute_uri(
|
||||
self.organization.urls.base
|
||||
)
|
||||
|
||||
message = _(
|
||||
"""Hello,
|
||||
|
||||
You have been invited to join the organization "{organization}" on Servala Portal as a {role}.
|
||||
|
||||
To accept this invitation, please click the link below:
|
||||
{invitation_url}
|
||||
|
||||
Once you accept, you'll be able to access the organization at:
|
||||
{organization_url}
|
||||
|
||||
Best regards,
|
||||
The Servala Team"""
|
||||
).format(
|
||||
organization=self.organization.name,
|
||||
role=self.get_role_display(),
|
||||
invitation_url=invitation_url,
|
||||
organization_url=organization_url,
|
||||
)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=settings.EMAIL_DEFAULT_FROM,
|
||||
recipient_list=[self.email],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
auditlog.register(OrganizationInvitation, serialize_data=True)
|
||||
auditlog.register(OrganizationMembership, serialize_data=True)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,13 @@ class Service(ServalaModelMixin, models.Model):
|
|||
"Featured links will be shown on the service list page, all other links will only show on the service and offering detail pages."
|
||||
),
|
||||
)
|
||||
osb_service_id = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("OSB Service ID"),
|
||||
help_text=_("Open Service Broker service ID for API matching"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Service")
|
||||
|
|
@ -149,7 +156,17 @@ class ControlPlane(ServalaModelMixin, models.Model):
|
|||
blank=True,
|
||||
verbose_name=_("User Information"),
|
||||
help_text=_(
|
||||
"Key-value information displayed to users when selecting this control plane"
|
||||
'Array of info objects: [{"title": "…", "content": "…", "help_text": "…"}]. '
|
||||
"The help_text field is optional and will be shown as a hover popover on an info icon."
|
||||
),
|
||||
)
|
||||
wildcard_dns = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Wildcard DNS"),
|
||||
help_text=_(
|
||||
"Wildcard DNS domain for auto-generating FQDNs (e.g., apps.exoscale-ch-gva-2-prod2.services.servala.com)"
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -297,35 +314,6 @@ class CloudProvider(ServalaModelMixin, models.Model):
|
|||
return self.name
|
||||
|
||||
|
||||
class Plan(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
Each service offering can have multiple plans, e.g. for different tiers.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
# TODO schema
|
||||
features = models.JSONField(verbose_name=_("Features"), null=True, blank=True)
|
||||
# TODO schema
|
||||
pricing = models.JSONField(verbose_name=_("Pricing"), null=True, blank=True)
|
||||
term = models.PositiveIntegerField(
|
||||
verbose_name=_("Term"), help_text=_("Term in months")
|
||||
)
|
||||
service_offering = models.ForeignKey(
|
||||
to="ServiceOffering",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="plans",
|
||||
verbose_name=_("Service offering"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Plan")
|
||||
verbose_name_plural = _("Plans")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
def validate_api_definition(value):
|
||||
required_fields = ("group", "version", "kind")
|
||||
return validate_dict(value, required_fields)
|
||||
|
|
@ -372,6 +360,24 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
|
|||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
form_config = models.JSONField(
|
||||
verbose_name=_("Form Configuration"),
|
||||
help_text=_(
|
||||
"Optional custom form configuration. When provided, this configuration will be used "
|
||||
"to render the service form instead of auto-generating it from the OpenAPI spec. "
|
||||
'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
|
||||
),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
hide_expert_mode = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Disable Expert Mode"),
|
||||
help_text=_(
|
||||
"When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form "
|
||||
"configuration will be available. Only applies when a custom form configuration is provided."
|
||||
),
|
||||
)
|
||||
service = models.ForeignKey(
|
||||
to="Service",
|
||||
on_delete=models.CASCADE,
|
||||
|
|
@ -514,6 +520,22 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
|
|||
return
|
||||
return generate_model_form_class(self.django_model)
|
||||
|
||||
@cached_property
|
||||
def custom_model_form_class(self):
|
||||
from servala.core.crd import generate_custom_form_class
|
||||
|
||||
if not self.django_model:
|
||||
return
|
||||
if not (
|
||||
self.service_definition
|
||||
and self.service_definition.form_config
|
||||
and self.service_definition.form_config.get("fieldsets")
|
||||
):
|
||||
return
|
||||
return generate_custom_form_class(
|
||||
self.service_definition.form_config, self.django_model
|
||||
)
|
||||
|
||||
|
||||
class ServiceOffering(ServalaModelMixin, models.Model):
|
||||
"""
|
||||
|
|
@ -533,6 +555,19 @@ class ServiceOffering(ServalaModelMixin, models.Model):
|
|||
verbose_name=_("Provider"),
|
||||
)
|
||||
description = models.TextField(blank=True, verbose_name=_("Description"))
|
||||
external_links = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("External links"),
|
||||
help_text=('JSON array of link objects: {"url": "…", "title": "…"}. '),
|
||||
)
|
||||
osb_plan_id = models.CharField(
|
||||
max_length=100,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("OSB Plan ID"),
|
||||
help_text=_("Open Service Broker plan ID for API matching"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Service offering")
|
||||
|
|
@ -593,7 +628,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
}
|
||||
|
||||
class urls(urlman.Urls):
|
||||
base = "{self.organization.urls.instances}{self.name}/"
|
||||
base = "{self.organization.urls.instances}{self.name}-{self.pk}/"
|
||||
update = "{base}update/"
|
||||
delete = "{base}delete/"
|
||||
|
||||
|
|
@ -672,6 +707,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
return mark_safe(f"<ul>{error_items}</ul>")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def create_instance(cls, name, organization, context, created_by, spec_data):
|
||||
# Ensure the namespace exists
|
||||
context.control_plane.get_or_create_namespace(organization)
|
||||
|
|
@ -719,7 +755,7 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
body=create_data,
|
||||
)
|
||||
except Exception as e:
|
||||
instance.delete()
|
||||
# Transaction will automatically roll back the instance creation
|
||||
if isinstance(e, ApiException):
|
||||
try:
|
||||
error_body = json.loads(e.body)
|
||||
|
|
@ -863,7 +899,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
return
|
||||
return self.context.django_model(
|
||||
name=self.name,
|
||||
organization=self.organization,
|
||||
context=self.context,
|
||||
spec=self.spec,
|
||||
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
|
||||
|
|
@ -920,6 +955,9 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
import base64
|
||||
|
||||
for key, value in secret.data.items():
|
||||
# Skip keys ending with _HOST as they're only useful for dedicated OpenShift clusters
|
||||
if key.endswith("_HOST"):
|
||||
continue
|
||||
try:
|
||||
credentials[key] = base64.b64decode(value).decode("utf-8")
|
||||
except Exception:
|
||||
|
|
@ -931,5 +969,21 @@ class ServiceInstance(ServalaModelMixin, models.Model):
|
|||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@property
|
||||
def fqdn_url(self):
|
||||
try:
|
||||
fqdn = self.spec.get("parameters", {}).get("service", {}).get("fqdn")
|
||||
if not fqdn:
|
||||
return None
|
||||
|
||||
if isinstance(fqdn, list):
|
||||
return fqdn[0]
|
||||
elif isinstance(fqdn, str):
|
||||
return fqdn
|
||||
else:
|
||||
return None
|
||||
except (AttributeError, KeyError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)
|
||||
|
|
|
|||
|
|
@ -207,3 +207,19 @@ def get_invoice_addresses(user):
|
|||
return invoice_addresses or []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def create_helpdesk_ticket(title, description, partner_id=None, sale_order_id=None):
|
||||
ticket_data = {
|
||||
"name": title,
|
||||
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
|
||||
"description": description,
|
||||
}
|
||||
|
||||
if partner_id:
|
||||
ticket_data["partner_id"] = partner_id
|
||||
|
||||
if sale_order_id:
|
||||
ticket_data["sale_order_id"] = sale_order_id
|
||||
|
||||
return CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
|
||||
|
|
|
|||
|
|
@ -14,20 +14,26 @@ def has_organization_role(user, org, roles):
|
|||
|
||||
@rules.predicate
|
||||
def is_organization_owner(user, obj):
|
||||
from servala.core.models.organization import OrganizationRole
|
||||
|
||||
if hasattr(obj, "organization"):
|
||||
org = obj.organization
|
||||
else:
|
||||
org = obj
|
||||
return has_organization_role(user, org, ["owner"])
|
||||
return has_organization_role(user, org, [OrganizationRole.OWNER])
|
||||
|
||||
|
||||
@rules.predicate
|
||||
def is_organization_admin(user, obj):
|
||||
from servala.core.models.organization import OrganizationRole
|
||||
|
||||
if hasattr(obj, "organization"):
|
||||
org = obj.organization
|
||||
else:
|
||||
org = obj
|
||||
return has_organization_role(user, org, ["owner", "admin"])
|
||||
return has_organization_role(
|
||||
user, org, [OrganizationRole.OWNER, OrganizationRole.ADMIN]
|
||||
)
|
||||
|
||||
|
||||
@rules.predicate
|
||||
|
|
|
|||
107
src/servala/core/schemas/form_config_schema.json
Normal file
107
src/servala/core/schemas/form_config_schema.json
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
{
|
||||
"$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,5 +1,12 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def add_organizations(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {"user_organizations": []}
|
||||
|
||||
return {"user_organizations": request.user.organizations.all().order_by("name")}
|
||||
|
||||
|
||||
def add_beta_banner(request):
|
||||
return {"show_beta_banner": settings.SERVALA_SHOW_BETA_BANNER}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,73 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
from django.forms import ModelForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from servala.core.models import Organization
|
||||
from servala.core.models import Organization, OrganizationInvitation, OrganizationRole
|
||||
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
|
||||
from servala.frontend.forms.mixins import HtmxMixin
|
||||
|
||||
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"
|
||||
|
||||
|
||||
class OrganizationForm(HtmxMixin, ModelForm):
|
||||
name_validator = RegexValidator(
|
||||
regex=f"^{ORG_NAME_PATTERN}$",
|
||||
message=_(
|
||||
"Organization name can only contain letters, numbers, spaces, and common punctuation (-.,&'()+)."
|
||||
),
|
||||
)
|
||||
|
||||
# def __init__(self, *args, **kwargs):
|
||||
# super().__init__(*args, **kwargs)
|
||||
# if self.instance and self.instance.has_inherited_billing_entity:
|
||||
# TODO disable billing entity editing
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ("name",)
|
||||
widgets = {
|
||||
"name": forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "100",
|
||||
"pattern": ORG_NAME_PATTERN,
|
||||
"title": _(
|
||||
"Organization name can contain letters, numbers, spaces, and common punctuation (-.,&'()+). Emoji not allowed."
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["name"].validators.append(self.name_validator)
|
||||
self.fields["name"].max_length = 100
|
||||
|
||||
|
||||
class OrganizationCreateForm(OrganizationForm):
|
||||
address_validator = RegexValidator(
|
||||
regex=r"^[\w\s\.,\-/()\']+$",
|
||||
message=_(
|
||||
"This field can only contain letters, numbers, spaces, and basic punctuation (.,-/()')."
|
||||
),
|
||||
)
|
||||
city_validator = RegexValidator(
|
||||
regex=r"^[\w\s\-\']+$",
|
||||
message=_("City name contains invalid characters."),
|
||||
)
|
||||
postal_code_validator = RegexValidator(
|
||||
regex=r"^[\w\s\-]+$",
|
||||
message=_(
|
||||
"Postal code can only contain letters, numbers, spaces, and hyphens."
|
||||
),
|
||||
)
|
||||
phone_validator = RegexValidator(
|
||||
regex=r"^[0-9\s\+\-()]+$",
|
||||
message=_(
|
||||
"Phone number can only contain numbers, spaces, and basic punctuation (+,-,())."
|
||||
),
|
||||
)
|
||||
|
||||
billing_processing_choice = forms.ChoiceField(
|
||||
choices=[
|
||||
("existing", _("Use an existing billing address")),
|
||||
|
|
@ -29,24 +83,93 @@ class OrganizationCreateForm(OrganizationForm):
|
|||
)
|
||||
|
||||
# Fields for creating a new billing address in Odoo, prefixed with 'invoice_'
|
||||
invoice_street = forms.CharField(label=_("Line 1"), required=False, max_length=100)
|
||||
invoice_street2 = forms.CharField(label=_("Line 2"), required=False, max_length=100)
|
||||
invoice_city = forms.CharField(label=_("City"), required=False, max_length=100)
|
||||
invoice_zip = forms.CharField(label=_("Postal Code"), required=False, max_length=20)
|
||||
invoice_street = forms.CharField(
|
||||
label=_("Line 1"),
|
||||
required=False,
|
||||
max_length=128,
|
||||
validators=[address_validator],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "128",
|
||||
"title": _(
|
||||
"Letters, numbers, spaces, and basic punctuation allowed. Emoji not allowed."
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
invoice_street2 = forms.CharField(
|
||||
label=_("Line 2"),
|
||||
required=False,
|
||||
max_length=128,
|
||||
validators=[address_validator],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "128",
|
||||
"title": _(
|
||||
"Letters, numbers, spaces, and basic punctuation allowed. Emoji not allowed."
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
invoice_city = forms.CharField(
|
||||
label=_("City"),
|
||||
required=False,
|
||||
max_length=64,
|
||||
validators=[city_validator],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "64",
|
||||
"title": _(
|
||||
"Letters, spaces, hyphens, and apostrophes allowed. Emoji not allowed."
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
invoice_zip = forms.CharField(
|
||||
label=_("Postal Code"),
|
||||
required=False,
|
||||
max_length=20,
|
||||
validators=[postal_code_validator],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "20",
|
||||
"title": _(
|
||||
"Letters, numbers, spaces, and hyphens allowed. Emoji not allowed."
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
invoice_country = forms.ChoiceField(
|
||||
label=_("Country"),
|
||||
required=False,
|
||||
choices=get_odoo_countries(),
|
||||
)
|
||||
invoice_email = forms.EmailField(label=_("Billing Email"), required=False)
|
||||
invoice_phone = forms.CharField(label=_("Phone"), required=False, max_length=30)
|
||||
invoice_email = forms.EmailField(
|
||||
label=_("Billing Email"),
|
||||
required=False,
|
||||
max_length=254,
|
||||
widget=forms.EmailInput(attrs={"maxlength": "254"}),
|
||||
)
|
||||
invoice_phone = forms.CharField(
|
||||
label=_("Phone"),
|
||||
required=False,
|
||||
max_length=30,
|
||||
validators=[phone_validator],
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"maxlength": "30",
|
||||
"pattern": r"[0-9\s\+\-()]+",
|
||||
"title": _("Only numbers, spaces, and basic punctuation allowed"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(OrganizationForm.Meta):
|
||||
pass
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.user = user
|
||||
if not self.initial.get("invoice_country"):
|
||||
default_country_name = "Switzerland"
|
||||
country_choices = self.fields["invoice_country"].choices
|
||||
|
|
@ -55,7 +178,6 @@ class OrganizationCreateForm(OrganizationForm):
|
|||
self.initial["invoice_country"] = country_id
|
||||
break
|
||||
|
||||
self.user = user
|
||||
self.odoo_addresses = get_invoice_addresses(self.user)
|
||||
|
||||
if self.odoo_addresses:
|
||||
|
|
@ -108,3 +230,68 @@ class OrganizationCreateForm(OrganizationForm):
|
|||
"existing_odoo_address_id", _("Please select an invoice address.")
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class OrganizationInvitationForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, organization=None, user_role=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.organization = organization
|
||||
self.user_role = user_role
|
||||
|
||||
if user_role:
|
||||
allowed_roles = self._get_allowed_roles(user_role)
|
||||
self.fields["role"].choices = [
|
||||
(value, label)
|
||||
for value, label in OrganizationRole.choices
|
||||
if value in allowed_roles
|
||||
]
|
||||
|
||||
def _get_allowed_roles(self, user_role):
|
||||
role_hierarchy = {
|
||||
OrganizationRole.OWNER: [
|
||||
OrganizationRole.OWNER,
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.MEMBER,
|
||||
],
|
||||
OrganizationRole.ADMIN: [
|
||||
OrganizationRole.ADMIN,
|
||||
OrganizationRole.MEMBER,
|
||||
],
|
||||
OrganizationRole.MEMBER: [],
|
||||
}
|
||||
return role_hierarchy.get(user_role, [])
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"].lower()
|
||||
|
||||
if self.organization.members.filter(email__iexact=email).exists():
|
||||
raise ValidationError(
|
||||
_("A user with this email is already a member of this organization.")
|
||||
)
|
||||
|
||||
if OrganizationInvitation.objects.filter(
|
||||
organization=self.organization,
|
||||
email__iexact=email,
|
||||
accepted_by__isnull=True,
|
||||
).exists():
|
||||
raise ValidationError(
|
||||
_("An invitation has already been sent to this email address.")
|
||||
)
|
||||
|
||||
return email
|
||||
|
||||
def save(self, commit=True):
|
||||
invitation = super().save(commit=False)
|
||||
invitation.organization = self.organization
|
||||
if commit:
|
||||
invitation.save()
|
||||
return invitation
|
||||
|
||||
class Meta:
|
||||
model = OrganizationInvitation
|
||||
fields = ("email", "role")
|
||||
widgets = {
|
||||
"email": forms.EmailInput(attrs={"placeholder": _("user@example.com")}),
|
||||
"role": forms.RadioSelect(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ class ServiceFilterForm(forms.Form):
|
|||
)
|
||||
q = forms.CharField(label=_("Search"), required=False)
|
||||
|
||||
def __init__(self, *args, organization=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if organization and organization.limit_cloudproviders.exists():
|
||||
allowed_providers = organization.limit_cloudproviders
|
||||
if allowed_providers.count() <= 1:
|
||||
self.fields.pop("cloud_provider", None)
|
||||
else:
|
||||
self.fields["cloud_provider"].queryset = allowed_providers
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
if category := self.cleaned_data.get("category"):
|
||||
queryset = queryset.filter(category=category)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms.widgets import NumberInput
|
||||
|
||||
|
||||
class DynamicArrayWidget(forms.Widget):
|
||||
|
|
@ -216,3 +217,21 @@ class DynamicArrayField(forms.JSONField):
|
|||
raise ValidationError(
|
||||
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
|
||||
)
|
||||
|
||||
|
||||
class NumberInputWithAddon(NumberInput):
|
||||
"""
|
||||
Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
|
||||
Renders as a Bootstrap input-group with the suffix displayed as an add-on.
|
||||
"""
|
||||
|
||||
template_name = "frontend/forms/number_input_with_addon.html"
|
||||
|
||||
def __init__(self, addon_text="", attrs=None):
|
||||
super().__init__(attrs)
|
||||
self.addon_text = addon_text
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context["widget"]["addon_text"] = self.addon_text
|
||||
return context
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% translate "Sign in" %}
|
||||
{% endblock html_title %}
|
||||
{% block page_title %}
|
||||
{% translate "Welcome to Servala" %}
|
||||
{% translate "Welcome to Servala - Sovereign App Store" %}
|
||||
{% endblock page_title %}
|
||||
{% block card_header %}
|
||||
<div class="card-header text-center py-4"
|
||||
|
|
@ -26,21 +26,23 @@
|
|||
<div class="text-center mb-4">
|
||||
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
|
||||
<p class="text-muted mb-0">
|
||||
{% translate "Sign in to access your managed service instances and the Servala service catalog" %}
|
||||
{% translate "Sign in to your account or create a new one to access your managed service instances and the Servala service catalog" %}
|
||||
</p>
|
||||
</div>
|
||||
{% for provider in socialaccount_providers %}
|
||||
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
|
||||
<form method="post" action="{{ href }}">
|
||||
<form method="post"
|
||||
action="{{ href }}"
|
||||
class="d-flex justify-content-center">
|
||||
{% csrf_token %}
|
||||
{{ redirect_field }}
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg w-100 py-3 mb-4 fw-semibold"
|
||||
class="btn btn-primary btn-lg py-2 px-4 mb-4 fw-semibold"
|
||||
title="{{ provider.name }}"
|
||||
style="border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
|
||||
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
|
||||
<span>{% translate "Sign in with VSHN Account" %}</span>
|
||||
<span>{% translate "Sign in or Register" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
<script src="{% static 'mazer/static/js/initTheme.js' %}"></script>
|
||||
<div id="app">
|
||||
<div id="main" class="layout-horizontal">
|
||||
{% include 'includes/beta_banner.html' %}
|
||||
{% include 'includes/header.html' %}
|
||||
<div class="content-wrapper container">
|
||||
<div class="page-heading">
|
||||
|
|
@ -65,6 +66,8 @@
|
|||
<div class="float-end">
|
||||
<p>
|
||||
Crafted with <span class="text-danger"><i class="bi bi-heart-fill icon-mid"></i></span> in Zurich
|
||||
{% load version_tags %}
|
||||
- {% get_version_or_env %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,5 +79,22 @@
|
|||
<script src="{% static 'mazer/extensions/perfect-scrollbar/perfect-scrollbar.min.js' %}"></script>
|
||||
<script src="{% static 'mazer/compiled/js/app.js' %}"></script>
|
||||
<script src="{% static 'js/dynamic-array.js' %}"></script>
|
||||
<!-- Ybug code start (https://ybug.io) -->
|
||||
<script type='text/javascript'>
|
||||
(function() {
|
||||
window.ybug_settings = {
|
||||
"id": "q1tgbdjp26ydh8gygggv"
|
||||
};
|
||||
var ybug = document.createElement('script');
|
||||
ybug.type = 'text/javascript';
|
||||
ybug.async = true;
|
||||
ybug.src = 'https://widget.ybug.io/button/' + window.ybug_settings.id + '.js';
|
||||
var s = document.getElementsByTagName('script')[0];
|
||||
s.parentNode.insertBefore(ybug, s);
|
||||
})();
|
||||
</script>
|
||||
<!-- Ybug code end -->
|
||||
{% block extra_js %}
|
||||
{% endblock extra_js %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<div class="dynamic-array-widget"
|
||||
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
|
||||
data-name="{{ widget.name }}">
|
||||
id="{{ widget.name }}_container"
|
||||
data-name="{{ widget.name }}"
|
||||
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}>
|
||||
<div class="array-items">
|
||||
{% for item in value_list %}
|
||||
<div class="array-item d-flex mb-2">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
{% endif %}
|
||||
{% if field.use_fieldset %}</fieldset>{% endif %}
|
||||
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
|
||||
{% if field.help_text %}
|
||||
{% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %}
|
||||
<small class="form-text text-muted"
|
||||
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
<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" %}
|
||||
{% load i18n static %}
|
||||
{% block html_title %}
|
||||
{{ object.name }} {% translate "Dashboard" %}
|
||||
{% translate "Dashboard" %} for {{ object.name }}
|
||||
{% endblock html_title %}
|
||||
{% block page_title %}{% endblock %}
|
||||
{% block content %}
|
||||
|
|
@ -87,7 +87,6 @@
|
|||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Service" %}</th>
|
||||
<th>{% translate "Status" %}</th>
|
||||
<th>{% translate "Created" %}</th>
|
||||
<th>{% translate "Actions" %}</th>
|
||||
</tr>
|
||||
|
|
@ -96,7 +95,7 @@
|
|||
{% for instance in service_instances %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
|
||||
<a href="{{ instance.urls.base }}"
|
||||
class="fw-semibold text-decoration-none">{{ instance.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -117,13 +116,13 @@
|
|||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{% url 'frontend:organization.instance' organization=object.slug slug=instance.name %}"
|
||||
<a href="{{ instance.urls.base }}"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{% translate 'View Details' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if instance.has_change_permission %}
|
||||
<a href="{% url 'frontend:organization.instance.update' organization=object.slug slug=instance.name %}"
|
||||
<a href="{{ instance.urls.update }}"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
title="{% translate 'Edit' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
{% 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,13 +32,7 @@
|
|||
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for link in service.external_links %}
|
||||
<a href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
{{ link.title }}
|
||||
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
{% include "includes/external_link.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -47,7 +41,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="row match-height card-grid">
|
||||
{% for offering in service.offerings.all %}
|
||||
{% for offering in visible_offerings %}
|
||||
<div class="col-6 col-lg-3 col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header card-header-with-logo">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
{% endblock html_title %}
|
||||
{% block page_title_extra %}
|
||||
<div>
|
||||
{% if instance.fqdn_url %}
|
||||
<a href="https://{{ instance.fqdn_url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-success me-1 mb-1">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>
|
||||
{% translate "Open" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if has_change_permission %}
|
||||
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
|
||||
{% endif %}
|
||||
|
|
@ -102,8 +111,17 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if control_plane.user_info %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
{% include "includes/control_plane_user_info.html" with control_plane=instance.context.control_plane %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if instance.spec and spec_fieldsets %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
|
|
@ -173,6 +191,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% if instance.connection_credentials %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{% translate "Connection Credentials" %}</h4>
|
||||
|
|
@ -204,6 +223,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -237,3 +257,12 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize Bootstrap popovers for help text
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
|
||||
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
|
||||
});
|
||||
</script>
|
||||
{% endblock extra_js %}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
|
||||
{% endblock page_title_extra %}
|
||||
{% partialdef service-form %}
|
||||
{% if form %}
|
||||
{% if form or custom_form %}
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center"></div>
|
||||
<div class="card-body">
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=form %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
{% if not form %}
|
||||
{% if not form and not custom_form %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,17 @@
|
|||
{{ offering }}
|
||||
{% endblock page_title %}
|
||||
{% endblock html_title %}
|
||||
{% partialdef control-plane-info %}
|
||||
{% if selected_plane %}
|
||||
{% partialdef control-plane-info inline=True %}
|
||||
{% if selected_plane and selected_plane.user_info %}
|
||||
<div class="mt-3">
|
||||
<div class="border-top pt-3">
|
||||
{% include "includes/control_plane_user_info.html" with control_plane=selected_plane %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endpartialdef %}
|
||||
{% partialdef service-form %}
|
||||
{% if service_form %}
|
||||
{% if service_form or custom_service_form %}
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center"></div>
|
||||
<div class="card-body">
|
||||
|
|
@ -22,7 +26,7 @@
|
|||
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
|
||||
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -30,69 +34,127 @@
|
|||
{% endpartialdef %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
{% if not has_control_planes %}
|
||||
<!-- No Service Available Message -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
{% if service.logo %}
|
||||
<img src="{{ service.logo.url }}"
|
||||
alt="{{ service.name }}"
|
||||
class="me-3"
|
||||
style="max-width: 48px;
|
||||
max-height: 48px">
|
||||
{% endif %}
|
||||
<div class="d-flex flex-column">
|
||||
<h4 class="mb-0">{{ offering }}</h4>
|
||||
<small class="text-muted">{{ offering.service.category }}</small>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<div>
|
||||
<strong>{% translate "Service Unavailable" %}</strong>
|
||||
<p class="mb-0">
|
||||
{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Two Column Layout -->
|
||||
<div class="row g-3">
|
||||
<!-- Left Column: Service Provider Zone -->
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{% translate "Service Provider Zone" %}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if offering.description %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<p>{{ offering.description|urlize }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not has_control_planes %}
|
||||
<p>{% translate "We currently cannot offer this service, sorry!" %}</p>
|
||||
{% else %}
|
||||
<form hx-trigger="change"
|
||||
hx-get="{{ request.path }}?fragment=service-form"
|
||||
hx-target="#service-form"
|
||||
hx-swap="outerHTML">
|
||||
hx-swap="outerHTML"
|
||||
class="control-plane-select-form">
|
||||
{{ select_form }}
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if service.external_links %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3">{% translate "External Links" %}</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for link in service.external_links %}
|
||||
<a href="{{ link.url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
{{ link.title }}
|
||||
<i class="bi bi-box-arrow-up-right ms-1"></i>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="service-form">{% partial service-form %}</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
{% if has_control_planes %}
|
||||
<style>
|
||||
.control-plane-select-form .form-label {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<div id="control-plane-info"
|
||||
hx-trigger="load, change from:form"
|
||||
hx-get="{{ request.path }}?fragment=control-plane-info">{% partial control-plane-info %}</div>
|
||||
hx-get="{{ request.path }}?fragment=control-plane-info">
|
||||
{% partial control-plane-info %}
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
{% block extra_js %}
|
||||
{% if wildcard_dns and organization_namespace %}
|
||||
<script>
|
||||
const fqdnConfig = {
|
||||
wildcardDns: '{{ wildcard_dns }}',
|
||||
namespace: '{{ organization_namespace }}'
|
||||
};
|
||||
</script>
|
||||
<script defer src="{% static "js/fqdn.js" %}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
// Initialize Bootstrap popovers for help text
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]');
|
||||
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
|
||||
});
|
||||
|
||||
// Re-initialize popovers after HTMX swaps
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'control-plane-info') {
|
||||
const popoverTriggerList = event.detail.target.querySelectorAll('[data-bs-toggle="popover"]');
|
||||
[...popoverTriggerList].map(el => new bootstrap.Popover(el));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock extra_js %}
|
||||
|
|
|
|||
|
|
@ -16,44 +16,11 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row match-height card-grid service-cards-container mb-5">
|
||||
<div class="row match-height card-grid service-cards-container {% if not deactivated_services %}mb-5{% endif %}">
|
||||
{% for service in services %}
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<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 "View Availability" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">{% include "includes/service_card.html" %}</div>
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-content">
|
||||
|
|
@ -61,8 +28,25 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if deactivated_services %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% translate "You may also be interested in one of these …" %}</h5>
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-info-circle mt-1"></i>
|
||||
{% translate "These services need to be enabled first before they become available in the Servala portal." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row match-height card-grid service-cards-container mb-5">
|
||||
{% for service in deactivated_services %}
|
||||
<div class="col-12 col-md-6 col-lg-3 service-deactivated">{% include "includes/service_card.html" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script src="{% static "js/autosubmit.js" %}" defer></script>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,97 @@
|
|||
</form>
|
||||
</td>
|
||||
{% endpartialdef org-name-edit %}
|
||||
{% partialdef members-list %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Name" %}</th>
|
||||
<th>{% translate "Email" %}</th>
|
||||
<th>{% translate "Role" %}</th>
|
||||
<th>{% translate "Joined" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for membership in memberships %}
|
||||
<tr>
|
||||
<td>{{ membership.user }}</td>
|
||||
<td>{{ membership.user.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if membership.role == 'owner' %}primary{% elif membership.role == 'admin' %}info{% else %}secondary{% endif %}">
|
||||
{{ membership.get_role_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ membership.date_joined|date:"Y-m-d" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center">{% translate "No members yet" %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endpartialdef members-list %}
|
||||
{% partialdef pending-invitations-card %}
|
||||
{% if pending_invitations %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-envelope"></i> {% translate "Pending Invitations" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% translate "Email" %}</th>
|
||||
<th>{% translate "Role" %}</th>
|
||||
<th>{% translate "Sent" %}</th>
|
||||
<th>{% translate "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invitation in pending_invitations %}
|
||||
<tr>
|
||||
<td>{{ invitation.email }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if invitation.role == 'owner' %}primary{% elif invitation.role == 'admin' %}info{% else %}secondary{% endif %}">
|
||||
{{ invitation.get_role_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ invitation.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="navigator.clipboard.writeText('{{ request.scheme }}://{{ request.get_host }}{{ invitation.urls.accept }}'); this.textContent='Copied!'">
|
||||
<i class="bi bi-clipboard"></i> {% translate "Copy Link" %}
|
||||
</button>
|
||||
<form method="post"
|
||||
action="{{ invitation.urls.delete }}"
|
||||
style="display: inline"
|
||||
hx-post="{{ invitation.urls.delete }}"
|
||||
hx-target="#pending-invitations-card"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="{% translate 'Are you sure you want to delete this invitation?' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="fragment" value="pending-invitations-card">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i> {% translate "Delete" %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endpartialdef pending-invitations-card %}
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="card">
|
||||
|
|
@ -65,10 +156,16 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not form.instance.origin.hide_billing_address %}
|
||||
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Address" %}</h4>
|
||||
{% if form.instance.has_inherited_billing_entity %}
|
||||
<p class="text-muted">
|
||||
<small>{% translate "This billing address cannot be modified." %}</small>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
|
|
@ -130,5 +227,68 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif form.instance.origin.billing_message %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Billing Information" %}</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<p>{{ form.instance.origin.billing_message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_manage_members %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-people"></i> {% translate "Members" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">{% partial members-list %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-invitations-card">{% partial pending-invitations-card %}</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">
|
||||
<i class="bi bi-person-plus"></i> {% translate "Invite New Member" %}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-light mb-3">
|
||||
<h6>
|
||||
<i class="bi bi-info-circle"></i> {% translate "Role Permissions" %}
|
||||
</h6>
|
||||
<ul class="mb-0">
|
||||
<li>
|
||||
<strong>{% translate "Owner" %}:</strong> {% translate "Can manage all organization settings, members, services, and can appoint administrators." %}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{% translate "Administrator" %}:</strong> {% translate "Can manage members, invite users, and manage all services and instances." %}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{% translate "Member" %}:</strong> {% translate "Can view organization details, create and manage their own service instances." %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form method="post" class="form">
|
||||
{% csrf_token %}
|
||||
<div class="row">{{ invitation_form }}</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary" name="invite_email" value="1">
|
||||
<i class="bi bi-send"></i> {% translate "Send Invitation" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
13
src/servala/frontend/templates/includes/beta_banner.html
Normal file
13
src/servala/frontend/templates/includes/beta_banner.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% 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,26 +1,24 @@
|
|||
{% load i18n %}
|
||||
{% comment %}
|
||||
Reusable snippet for displaying ControlPlane user_info
|
||||
Usage: {% include "includes/control_plane_user_info.html" with control_plane=control_plane_object %}
|
||||
{% endcomment %}
|
||||
{% if control_plane.user_info %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{% translate "Service Provider Zone Information" %}</h4>
|
||||
<div class="control-plane-info-list">
|
||||
{% for info in control_plane.user_info %}
|
||||
<div class="info-item mb-3">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<small class="text-muted fw-semibold">{{ info.title }}</small>
|
||||
{% if info.help_text %}
|
||||
<i class="bi bi-info-circle ms-1 text-muted"
|
||||
data-bs-toggle="popover"
|
||||
data-bs-trigger="hover focus"
|
||||
data-bs-placement="top"
|
||||
data-bs-content="{{ info.help_text }}"
|
||||
style="cursor: help;
|
||||
font-size: 0.875rem"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-light-subtle p-2 rounded">
|
||||
<code class="text-dark">{{ info.content }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="table-responsive">
|
||||
<table class="table mb-0 table-lg">
|
||||
<tbody>
|
||||
{% for key, value in control_plane.user_info.items %}
|
||||
<tr>
|
||||
<th>{{ key }}</th>
|
||||
<td>{{ value }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<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 %}
|
||||
<a href="{% url 'account_login' %}" class="sidebar-link">
|
||||
<i class="bi bi-person-badge-fill"></i>
|
||||
<span>{% translate 'Login' %}</span>
|
||||
<span>{% translate 'Sign in' %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="#" class="burger-btn d-block d-xl-none">
|
||||
|
|
|
|||
31
src/servala/frontend/templates/includes/service_card.html
Normal file
31
src/servala/frontend/templates/includes/service_card.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{% 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,10 +1,43 @@
|
|||
{% load i18n %}
|
||||
{% load get_field %}
|
||||
{% load static %}
|
||||
<form class="form form-vertical crd-form"
|
||||
method="post"
|
||||
{% if form_action %}action="{{ form_action }}"{% endif %}>
|
||||
{% csrf_token %}
|
||||
{% include "frontend/forms/errors.html" %}
|
||||
{% if form and expert_form and not hide_expert_mode %}
|
||||
<div class="mb-3 text-end">
|
||||
<a href="#"
|
||||
class="text-muted small"
|
||||
id="expert-mode-toggle"
|
||||
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="custom-form-container"
|
||||
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
|
||||
{% if form and form.context %}{{ form.context }}{% endif %}
|
||||
{% if form and form.get_fieldsets|length == 1 %}
|
||||
{# Single fieldset - render without tabs #}
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
<div class="my-2">
|
||||
{% for field in fieldset.fields %}
|
||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
{% for subfieldset in fieldset.fieldsets %}
|
||||
{% if subfieldset.fields %}
|
||||
<div>
|
||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||
{% for field in subfieldset.fields %}
|
||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif form %}
|
||||
{# Multiple fieldsets or auto-generated form - render with tabs #}
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
{% if not fieldset.hidden %}
|
||||
|
|
@ -12,10 +45,10 @@
|
|||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||
id="{{ fieldset.title|slugify }}-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#{{ fieldset.title|slugify }}"
|
||||
data-bs-target="#custom-{{ fieldset.title|slugify }}"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="{{ fieldset.title|slugify }}"
|
||||
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 %}
|
||||
|
|
@ -27,30 +60,90 @@
|
|||
<div class="tab-content" id="myTabContent">
|
||||
{% for fieldset in form.get_fieldsets %}
|
||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||
id="{{ fieldset.title|slugify }}"
|
||||
id="custom-{{ fieldset.title|slugify }}"
|
||||
role="tabpanel"
|
||||
aria-labelledby="{{ fieldset.title|slugify }}-tab">
|
||||
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
|
||||
{% for field in fieldset.fields %}
|
||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
{% for subfieldset in fieldset.fieldsets %}
|
||||
{% if subfieldset.fields %}
|
||||
<div>
|
||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||
{% for field in subfieldset.fields %}
|
||||
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="col-sm-12 d-flex justify-content-end">
|
||||
<button class="btn btn-primary me-1 mb-1" type="submit">
|
||||
{% if form_submit_label %}
|
||||
{{ form_submit_label }}
|
||||
{% else %}
|
||||
{% translate "Save" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if expert_form and not hide_expert_mode %}
|
||||
<div id="expert-form-container"
|
||||
class="expert-crd-form"
|
||||
style="{% if form %}display:none{% endif %}">
|
||||
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
|
||||
<ul class="nav nav-tabs" id="expertTab" role="tablist">
|
||||
{% for fieldset in expert_form.get_fieldsets %}
|
||||
{% if not fieldset.hidden %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
|
||||
id="expert-{{ fieldset.title|slugify }}-tab"
|
||||
data-bs-toggle="tab"
|
||||
data-bs-target="#expert-{{ fieldset.title|slugify }}"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls="expert-{{ fieldset.title|slugify }}"
|
||||
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
|
||||
{{ fieldset.title }}
|
||||
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content" id="expertTabContent">
|
||||
{% for fieldset in expert_form.get_fieldsets %}
|
||||
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
|
||||
id="expert-{{ fieldset.title|slugify }}"
|
||||
role="tabpanel"
|
||||
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
|
||||
{% for field in fieldset.fields %}
|
||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
{% for subfieldset in fieldset.fieldsets %}
|
||||
{% if subfieldset.fields %}
|
||||
<div>
|
||||
<h4 class="mt-3">{{ subfieldset.title }}</h4>
|
||||
{% for field in subfieldset.fields %}
|
||||
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form %}
|
||||
<input type="hidden"
|
||||
name="active_form"
|
||||
id="active-form-input"
|
||||
value="custom">
|
||||
{% endif %}
|
||||
<div class="col-sm-12 d-flex justify-content-end">
|
||||
{# browser form validation fails when there are fields missing/invalid that are hidden #}
|
||||
<input class="btn btn-primary me-1 mb-1"
|
||||
type="submit"
|
||||
{% if form and expert_form %}formnovalidate{% endif %}
|
||||
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
|
||||
</div>
|
||||
</form>
|
||||
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
|
||||
{% if form and not hide_expert_mode %}
|
||||
<script defer src="{% static 'js/expert-mode.js' %}"></script>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from contextlib import suppress
|
||||
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
|
@ -5,4 +7,5 @@ register = template.Library()
|
|||
|
||||
@register.filter
|
||||
def get_field(form, field_name):
|
||||
with suppress(KeyError):
|
||||
return form[field_name]
|
||||
|
|
|
|||
16
src/servala/frontend/templatetags/version_tags.py
Normal file
16
src/servala/frontend/templatetags/version_tags.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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,6 +6,11 @@ from servala.frontend import views
|
|||
urlpatterns = [
|
||||
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
|
||||
path("accounts/logout/", views.LogoutView.as_view(), name="logout"),
|
||||
path(
|
||||
"invitations/<str:secret>/accept/",
|
||||
views.InvitationAcceptView.as_view(),
|
||||
name="invitation.accept",
|
||||
),
|
||||
path(
|
||||
"organizations/",
|
||||
views.OrganizationSelectionView.as_view(),
|
||||
|
|
@ -25,6 +30,11 @@ urlpatterns = [
|
|||
views.OrganizationUpdateView.as_view(),
|
||||
name="organization.details",
|
||||
),
|
||||
path(
|
||||
"details/invitations/<int:pk>/delete/",
|
||||
views.InvitationDeleteView.as_view(),
|
||||
name="invitation.delete",
|
||||
),
|
||||
path(
|
||||
"services/",
|
||||
views.ServiceListView.as_view(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from .generic import (
|
|||
custom_500,
|
||||
)
|
||||
from .organization import (
|
||||
InvitationAcceptView,
|
||||
InvitationDeleteView,
|
||||
OrganizationCreateView,
|
||||
OrganizationDashboardView,
|
||||
OrganizationUpdateView,
|
||||
|
|
@ -25,6 +27,8 @@ from .support import SupportView
|
|||
|
||||
__all__ = [
|
||||
"IndexView",
|
||||
"InvitationAcceptView",
|
||||
"InvitationDeleteView",
|
||||
"LogoutView",
|
||||
"OrganizationCreateView",
|
||||
"OrganizationDashboardView",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,31 @@
|
|||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DetailView
|
||||
from django.views.generic import CreateView, DeleteView, DetailView, TemplateView
|
||||
from django_scopes import scopes_disabled
|
||||
from rules.contrib.views import AutoPermissionRequiredMixin
|
||||
|
||||
from servala.core.models import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
ServiceInstance,
|
||||
)
|
||||
from servala.frontend.forms.organization import OrganizationCreateForm, OrganizationForm
|
||||
from servala.frontend.views.mixins import HtmxUpdateView, OrganizationViewMixin
|
||||
from servala.frontend.forms.organization import (
|
||||
OrganizationCreateForm,
|
||||
OrganizationForm,
|
||||
OrganizationInvitationForm,
|
||||
)
|
||||
from servala.frontend.views.mixins import (
|
||||
HtmxUpdateView,
|
||||
HtmxViewMixin,
|
||||
OrganizationViewMixin,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationCreateView(AutoPermissionRequiredMixin, CreateView):
|
||||
|
|
@ -96,10 +111,225 @@ class OrganizationDashboardView(
|
|||
return context
|
||||
|
||||
|
||||
class OrganizationUpdateView(OrganizationViewMixin, HtmxUpdateView):
|
||||
class OrganizationMembershipMixin:
|
||||
template_name = "frontend/organizations/update.html"
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
membership = (
|
||||
OrganizationMembership.objects.filter(
|
||||
user=self.request.user, organization=self.get_object()
|
||||
)
|
||||
.order_by("role")
|
||||
.first()
|
||||
)
|
||||
return membership.role if membership else None
|
||||
|
||||
@cached_property
|
||||
def can_manage_members(self):
|
||||
return self.request.user.has_perm(
|
||||
"core.change_organization", self.request.organization
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
organization = self.get_object()
|
||||
|
||||
if self.can_manage_members:
|
||||
memberships = (
|
||||
OrganizationMembership.objects.filter(organization=organization)
|
||||
.select_related("user")
|
||||
.order_by("role", "user__email")
|
||||
)
|
||||
pending_invitations = OrganizationInvitation.objects.filter(
|
||||
organization=organization, accepted_by__isnull=True
|
||||
).order_by("-created_at")
|
||||
invitation_form = OrganizationInvitationForm(
|
||||
organization=organization, user_role=self.user_role
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"memberships": memberships,
|
||||
"pending_invitations": pending_invitations,
|
||||
"invitation_form": invitation_form,
|
||||
"can_manage_members": self.can_manage_members,
|
||||
"user_role": self.user_role,
|
||||
}
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class OrganizationUpdateView(
|
||||
OrganizationViewMixin, OrganizationMembershipMixin, HtmxUpdateView
|
||||
):
|
||||
form_class = OrganizationForm
|
||||
fragments = ("org-name", "org-name-edit")
|
||||
fragments = (
|
||||
"org-name",
|
||||
"org-name-edit",
|
||||
"members-list",
|
||||
"pending-invitations-card",
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "invite_email" in request.POST:
|
||||
return self.handle_invitation(request)
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def handle_invitation(self, request):
|
||||
organization = self.get_object()
|
||||
if not self.can_manage_members:
|
||||
messages.error(request, _("You do not have permission to invite members."))
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
form = OrganizationInvitationForm(
|
||||
request.POST, organization=organization, user_role=self.user_role
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
invitation = form.save(commit=False)
|
||||
invitation.created_by = request.user
|
||||
invitation.save()
|
||||
|
||||
try:
|
||||
invitation.send_invitation_email(request)
|
||||
messages.success(
|
||||
request,
|
||||
_(
|
||||
"Invitation sent to {email}. They will receive an email with the invitation link."
|
||||
).format(email=invitation.email),
|
||||
)
|
||||
except Exception:
|
||||
messages.warning(
|
||||
request,
|
||||
_(
|
||||
"Invitation created for {email}, but email failed to send. Share this link manually: {url}"
|
||||
).format(
|
||||
email=invitation.email,
|
||||
url=request.build_absolute_uri(invitation.urls.accept),
|
||||
),
|
||||
)
|
||||
else:
|
||||
for error in form.errors.values():
|
||||
for error_msg in error:
|
||||
messages.error(request, error_msg)
|
||||
|
||||
if self.is_htmx and self._get_fragment():
|
||||
return self.get(request, *self.args, **self.kwargs)
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
||||
|
||||
@method_decorator(scopes_disabled(), name="dispatch")
|
||||
class InvitationAcceptView(TemplateView):
|
||||
template_name = "frontend/organizations/invitation_accept.html"
|
||||
|
||||
def get_invitation(self):
|
||||
secret = self.kwargs.get("secret")
|
||||
return get_object_or_404(OrganizationInvitation, secret=secret)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
invitation = self.get_invitation()
|
||||
|
||||
if invitation.is_accepted:
|
||||
messages.warning(
|
||||
request,
|
||||
_("This invitation has already been accepted."),
|
||||
)
|
||||
return redirect("frontend:organization.selection")
|
||||
if not request.user.is_authenticated:
|
||||
request.session["invitation_next"] = request.path
|
||||
messages.info(
|
||||
request,
|
||||
_("Please log in or sign up to accept this invitation."),
|
||||
)
|
||||
return redirect(f"{reverse('account_login')}?next={request.path}")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["invitation"] = self.get_invitation()
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
invitation = self.get_invitation()
|
||||
invitation.accepted_by = request.user
|
||||
invitation.accepted_at = timezone.now()
|
||||
invitation.save()
|
||||
|
||||
OrganizationMembership.objects.get_or_create(
|
||||
user=request.user,
|
||||
organization=invitation.organization,
|
||||
defaults={"role": invitation.role},
|
||||
)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
_("You have successfully joined {organization}!").format(
|
||||
organization=invitation.organization.name
|
||||
),
|
||||
)
|
||||
|
||||
request.session.pop("invitation_next", None)
|
||||
return redirect(invitation.organization.urls.base)
|
||||
|
||||
|
||||
class InvitationDeleteView(HtmxViewMixin, OrganizationMembershipMixin, DeleteView):
|
||||
model = OrganizationInvitation
|
||||
http_method_names = ["get", "post"]
|
||||
fragments = ("pending-invitations-card",)
|
||||
|
||||
def get_queryset(self):
|
||||
return OrganizationInvitation.objects.filter(accepted_by__isnull=True)
|
||||
|
||||
def get_success_url(self):
|
||||
return self.object.organization.urls.details
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
organization = self.request.organization
|
||||
context["pending_invitations"] = OrganizationInvitation.objects.filter(
|
||||
organization=organization, accepted_by__isnull=True
|
||||
).order_by("-created_at")
|
||||
return context
|
||||
|
||||
def _check_permission(self):
|
||||
return self.request.user.has_perm(
|
||||
"core.change_organization", self.request.organization
|
||||
)
|
||||
|
||||
def get_object(self):
|
||||
if self.request.method == "POST" and self.is_htmx:
|
||||
try:
|
||||
return super().get_object()
|
||||
except Exception:
|
||||
return
|
||||
return super().get_object()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
organization = self.object.organization
|
||||
|
||||
if not self._check_permission():
|
||||
if not self.is_htmx:
|
||||
messages.error(
|
||||
request,
|
||||
_("You do not have permission to delete this invitation."),
|
||||
)
|
||||
return redirect(organization.urls.details)
|
||||
|
||||
email = self.object.email
|
||||
self.object.delete()
|
||||
if not self.is_htmx:
|
||||
messages.success(
|
||||
request,
|
||||
_("Invitation for {email} has been deleted.").format(email=email),
|
||||
)
|
||||
|
||||
if self.is_htmx and self._get_fragment():
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponse
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
|
@ -36,22 +36,24 @@ class ServiceListView(OrganizationViewMixin, ListView):
|
|||
|
||||
def get_queryset(self):
|
||||
"""Return all services."""
|
||||
services = (
|
||||
Service.objects.all()
|
||||
.select_related("category")
|
||||
.prefetch_related("offerings__provider")
|
||||
)
|
||||
services = self.request.organization.get_visible_services()
|
||||
|
||||
if self.filter_form.is_valid():
|
||||
services = self.filter_form.filter_queryset(services)
|
||||
return services.distinct()
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return ServiceFilterForm(data=self.request.GET or None)
|
||||
return ServiceFilterForm(
|
||||
data=self.request.GET or None, organization=self.request.organization
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["filter_form"] = self.filter_form
|
||||
context["deactivated_services"] = (
|
||||
self.request.organization.get_deactivated_services()
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
|
|
@ -62,10 +64,36 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
|
|||
permission_type = "view"
|
||||
|
||||
def get_queryset(self):
|
||||
return Service.objects.select_related("category").prefetch_related(
|
||||
"offerings",
|
||||
"offerings__provider",
|
||||
return self.request.organization.get_visible_services()
|
||||
|
||||
@cached_property
|
||||
def visible_offerings(self):
|
||||
offerings = self.object.offerings.all()
|
||||
if self.request.organization.limit_cloudproviders.exists():
|
||||
offerings = offerings.filter(
|
||||
provider__in=self.request.organization.limit_cloudproviders.all()
|
||||
)
|
||||
return offerings
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
# If there's exactly one offering, skip provider selection and go directly to it
|
||||
if self.visible_offerings.count() == 1:
|
||||
offering = self.visible_offerings.first()
|
||||
return redirect(
|
||||
"frontend:organization.offering",
|
||||
organization=self.request.organization.slug,
|
||||
slug=self.object.slug,
|
||||
pk=offering.pk,
|
||||
)
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["visible_offerings"] = self.visible_offerings.select_related("provider")
|
||||
return context
|
||||
|
||||
|
||||
class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView):
|
||||
|
|
@ -76,7 +104,14 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
fragments = ("service-form", "control-plane-info")
|
||||
|
||||
def has_permission(self):
|
||||
return self.has_organization_permission()
|
||||
if not self.has_organization_permission():
|
||||
return False
|
||||
if self.request.organization.limit_cloudproviders.exists():
|
||||
return (
|
||||
self.get_object().provider
|
||||
in self.request.organization.limit_cloudproviders.all()
|
||||
)
|
||||
return True
|
||||
|
||||
def get_queryset(self):
|
||||
return ServiceOffering.objects.all().select_related(
|
||||
|
|
@ -107,7 +142,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
def context_object(self):
|
||||
if self.request.method == "POST":
|
||||
return ControlPlaneCRD.objects.filter(
|
||||
pk=self.request.POST.get("context"),
|
||||
pk=self.request.POST.get(
|
||||
"expert-context", self.request.POST.get("custom-context")
|
||||
),
|
||||
# Make sure we don’t use a malicious ID
|
||||
control_plane__in=self.planes,
|
||||
).first()
|
||||
|
|
@ -115,15 +152,52 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
control_plane=self.selected_plane, service_offering=self.object
|
||||
).first()
|
||||
|
||||
def get_instance_form(self):
|
||||
if not self.context_object or not self.context_object.model_form_class:
|
||||
return None
|
||||
return self.context_object.model_form_class(
|
||||
data=self.request.POST if self.request.method == "POST" else None,
|
||||
initial={
|
||||
def get_instance_form_kwargs(self, ignore_data=False):
|
||||
return {
|
||||
"initial": {
|
||||
"organization": self.request.organization,
|
||||
"context": self.context_object,
|
||||
},
|
||||
"prefix": "expert",
|
||||
"data": (
|
||||
self.request.POST
|
||||
if (self.request.method == "POST" and not ignore_data)
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def get_instance_form(self, ignore_data=False):
|
||||
if (
|
||||
not self.context_object
|
||||
or not self.context_object.model_form_class
|
||||
or self.hide_expert_mode
|
||||
):
|
||||
return
|
||||
|
||||
return self.context_object.model_form_class(
|
||||
**self.get_instance_form_kwargs(ignore_data=ignore_data)
|
||||
)
|
||||
|
||||
def get_custom_instance_form(self, ignore_data=False):
|
||||
if not self.context_object or not self.context_object.custom_model_form_class:
|
||||
return
|
||||
kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data)
|
||||
kwargs["prefix"] = "custom"
|
||||
return self.context_object.custom_model_form_class(**kwargs)
|
||||
|
||||
@property
|
||||
def is_custom_form(self):
|
||||
# Note: "custom form" = user-friendly, subset of fields
|
||||
# vs "expert form" = auto-generated (all technical fields)
|
||||
return self.request.POST.get("active_form", "expert") == "custom"
|
||||
|
||||
@cached_property
|
||||
def hide_expert_mode(self):
|
||||
return (
|
||||
self.context_object
|
||||
and self.context_object.service_definition
|
||||
and self.context_object.service_definition.form_config
|
||||
and self.context_object.service_definition.hide_expert_mode
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
|
@ -131,7 +205,23 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
context["select_form"] = self.select_form
|
||||
context["has_control_planes"] = self.planes.exists()
|
||||
context["selected_plane"] = self.selected_plane
|
||||
context["hide_expert_mode"] = self.hide_expert_mode
|
||||
if self.request.method == "POST":
|
||||
if self.is_custom_form:
|
||||
context["service_form"] = self.get_instance_form(ignore_data=True)
|
||||
context["custom_service_form"] = self.get_custom_instance_form()
|
||||
else:
|
||||
context["service_form"] = self.get_instance_form()
|
||||
context["custom_service_form"] = self.get_custom_instance_form(
|
||||
ignore_data=True
|
||||
)
|
||||
else:
|
||||
context["service_form"] = self.get_instance_form()
|
||||
context["custom_service_form"] = self.get_custom_instance_form()
|
||||
|
||||
if self.selected_plane and self.selected_plane.wildcard_dns:
|
||||
context["wildcard_dns"] = self.selected_plane.wildcard_dns
|
||||
context["organization_namespace"] = self.request.organization.namespace
|
||||
return context
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -142,6 +232,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
context["form_error"] = True
|
||||
return self.render_to_response(context)
|
||||
|
||||
if self.is_custom_form:
|
||||
form = self.get_custom_instance_form()
|
||||
else:
|
||||
form = self.get_instance_form()
|
||||
if not form: # Should not happen if context_object is valid, but as a safeguard
|
||||
messages.error(
|
||||
|
|
@ -170,15 +263,13 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
|
|||
)
|
||||
form.add_error(None, error_message)
|
||||
|
||||
# If the form is not valid or if the service creation failed, we render it again
|
||||
context["service_form"] = form
|
||||
return self.render_to_response(context)
|
||||
|
||||
|
||||
class ServiceInstanceMixin:
|
||||
model = ServiceInstance
|
||||
context_object_name = "instance"
|
||||
slug_field = "name"
|
||||
pk_url_kwarg = "slug"
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
self._has_warned = False
|
||||
|
|
@ -195,7 +286,25 @@ class ServiceInstanceMixin:
|
|||
)
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
instance = super().get_object(**kwargs)
|
||||
queryset = kwargs.get("queryset") or self.get_queryset()
|
||||
|
||||
# Get the slug from URL (format: "my-instance-123")
|
||||
slug = self.kwargs.get(self.pk_url_kwarg)
|
||||
if slug is None:
|
||||
raise Http404("No slug provided in URL")
|
||||
|
||||
# Extract pk from the slug (everything after the last dash)
|
||||
try:
|
||||
pk_str = slug.rsplit("-", 1)[-1]
|
||||
pk = int(pk_str)
|
||||
except (ValueError, IndexError):
|
||||
raise Http404(f"Invalid slug format: {slug}")
|
||||
|
||||
try:
|
||||
instance = queryset.get(pk=pk)
|
||||
except ServiceInstance.DoesNotExist:
|
||||
raise Http404("Service instance not found")
|
||||
|
||||
if not instance.kubernetes_object and not self._has_warned:
|
||||
messages.warning(
|
||||
self.request,
|
||||
|
|
@ -342,11 +451,88 @@ class ServiceInstanceUpdateView(
|
|||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["instance"] = self.object.spec_object
|
||||
kwargs["prefix"] = "expert"
|
||||
return kwargs
|
||||
|
||||
def get_form(self, *args, ignore_data=False, **kwargs):
|
||||
if self.hide_expert_mode:
|
||||
return
|
||||
if not ignore_data:
|
||||
return super().get_form(*args, **kwargs)
|
||||
cls = self.get_form_class()
|
||||
kwargs = self.get_form_kwargs()
|
||||
if ignore_data:
|
||||
kwargs.pop("data", None)
|
||||
return cls(**kwargs)
|
||||
|
||||
def get_custom_form(self, ignore_data=False):
|
||||
cls = self.object.context.custom_model_form_class
|
||||
if not cls:
|
||||
return
|
||||
kwargs = self.get_form_kwargs()
|
||||
kwargs["prefix"] = "custom"
|
||||
if ignore_data:
|
||||
kwargs.pop("data", None)
|
||||
return cls(**kwargs)
|
||||
|
||||
@property
|
||||
def is_custom_form(self):
|
||||
# Note: "custom form" = user-friendly, subset of fields
|
||||
# vs "expert form" = auto-generated (all technical fields)
|
||||
return self.request.POST.get("active_form", "expert") == "custom"
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
|
||||
if self.is_custom_form:
|
||||
form = self.get_custom_form()
|
||||
else:
|
||||
form = self.get_form()
|
||||
|
||||
if form.is_valid():
|
||||
return self.form_valid(form)
|
||||
return self.form_invalid(form)
|
||||
|
||||
@cached_property
|
||||
def hide_expert_mode(self):
|
||||
return (
|
||||
self.object
|
||||
and self.object.context
|
||||
and self.object.context.service_definition
|
||||
and self.object.context.service_definition.form_config
|
||||
and self.object.context.service_definition.hide_expert_mode
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["hide_expert_mode"] = self.hide_expert_mode
|
||||
if self.request.method == "POST":
|
||||
if self.is_custom_form:
|
||||
context["custom_form"] = self.get_custom_form()
|
||||
context["form"] = self.get_form(ignore_data=True)
|
||||
else:
|
||||
context["custom_form"] = self.get_custom_form(ignore_data=True)
|
||||
else:
|
||||
context["custom_form"] = self.get_custom_form()
|
||||
return context
|
||||
|
||||
def _deep_merge(self, base, update):
|
||||
for key, value in update.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
def form_valid(self, form):
|
||||
try:
|
||||
spec_data = form.get_nested_data().get("spec")
|
||||
form_data = form.get_nested_data()
|
||||
spec_data = form_data.get("spec")
|
||||
|
||||
if self.is_custom_form:
|
||||
current_spec = dict(self.object.spec) if self.object.spec else {}
|
||||
spec_data = self._deep_merge(current_spec, spec_data)
|
||||
|
||||
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
|
||||
messages.success(
|
||||
self.request,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
|
||||
from servala.core.odoo import CLIENT
|
||||
from servala.core.odoo import create_helpdesk_ticket
|
||||
from servala.frontend.forms.support import SupportForm
|
||||
from servala.frontend.views.mixins import OrganizationViewMixin
|
||||
|
||||
|
|
@ -24,21 +23,16 @@ class SupportView(OrganizationViewMixin, FormView):
|
|||
if not partner_id:
|
||||
raise Exception("Could not get or create Odoo contact for user")
|
||||
|
||||
ticket_data = {
|
||||
"name": f"Servala Support - Organization {organization.name}",
|
||||
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
|
||||
"partner_id": partner_id,
|
||||
"description": message,
|
||||
}
|
||||
|
||||
# All orgs should have a sale order ID, but legacy ones might not have it.
|
||||
# Also, we want to be very sure that support requests work, especially for
|
||||
# organizations where something in the creation process may have gone wrong,
|
||||
# so if the ID does not exist, we omit it entirely.
|
||||
if organization.odoo_sale_order_id:
|
||||
ticket_data["sale_order_id"] = organization.odoo_sale_order_id
|
||||
|
||||
CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
|
||||
create_helpdesk_ticket(
|
||||
title=f"Servala Support - Organization {organization.name}",
|
||||
description=message,
|
||||
partner_id=partner_id,
|
||||
sale_order_id=organization.odoo_sale_order_id or None,
|
||||
)
|
||||
messages.success(
|
||||
self.request,
|
||||
_(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from servala.__about__ import __version__ as version
|
|||
|
||||
SERVALA_ENVIRONMENT = os.environ.get("SERVALA_ENVIRONMENT", "development")
|
||||
DEBUG = SERVALA_ENVIRONMENT == "development"
|
||||
SERVALA_SHOW_BETA_BANNER = os.environ.get("SERVALA_SHOW_BETA_BANNER", "True") == "True"
|
||||
|
||||
SECRET_KEY = os.environ.get("SERVALA_SECRET_KEY")
|
||||
if previous_secret_key := os.environ.get("SERVALA_PREVIOUS_SECRET_KEY"):
|
||||
|
|
@ -72,9 +73,13 @@ EMAIL_HOST_USER = os.environ.get("SERVALA_EMAIL_USER", "")
|
|||
EMAIL_HOST_PASSWORD = os.environ.get("SERVALA_EMAIL_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.environ.get("SERVALA_EMAIL_TLS", "False") == "True"
|
||||
EMAIL_USE_SSL = os.environ.get("SERVALA_EMAIL_SSL", "False") == "True"
|
||||
EMAIL_DEFAULT_FROM = os.environ.get("SERVALA_EMAIL_DEFAULT_FROM", "noreply@servala.com")
|
||||
|
||||
SERVALA_DEFAULT_ORIGIN = int(os.environ.get("SERVALA_DEFAULT_ORIGIN", "1"))
|
||||
|
||||
OSB_USERNAME = os.environ.get("SERVALA_OSB_USERNAME")
|
||||
OSB_PASSWORD = os.environ.get("SERVALA_OSB_PASSWORD")
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"openid_connect": {
|
||||
"APPS": [
|
||||
|
|
@ -159,6 +164,7 @@ INSTALLED_APPS = [
|
|||
"allauth.socialaccount.providers.openid_connect",
|
||||
"auditlog",
|
||||
"servala.core",
|
||||
"servala.api",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
|
@ -214,6 +220,7 @@ TEMPLATES = [
|
|||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.static",
|
||||
"servala.frontend.context_processors.add_organizations",
|
||||
"servala.frontend.context_processors.add_beta_banner",
|
||||
],
|
||||
"loaders": template_loaders,
|
||||
},
|
||||
|
|
|
|||
38
src/servala/settings_test.py
Normal file
38
src/servala/settings_test.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""
|
||||
Django test settings that extend the main settings.
|
||||
|
||||
This file imports all settings from the main settings module and
|
||||
overrides/adds settings specific to testing.
|
||||
"""
|
||||
|
||||
from servala.settings import * # noqa: F403, F401
|
||||
|
||||
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
|
||||
SALT_KEY = SECRET_KEY
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||
]
|
||||
|
||||
|
||||
class DisableMigrations:
|
||||
def __contains__(self, item):
|
||||
return True
|
||||
|
||||
def __getitem__(self, item):
|
||||
return None
|
||||
|
||||
|
||||
MIGRATION_MODULES = DisableMigrations()
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": ":memory:",
|
||||
"OPTIONS": {"timeout": 10},
|
||||
}
|
||||
}
|
||||
|
||||
OSB_USERNAME = "testuser"
|
||||
OSB_PASSWORD = "testpass"
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
|
@ -237,45 +237,127 @@ a.btn-keycloak {
|
|||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* CRD Form mandatory field styling */
|
||||
.crd-form .form-group.mandatory .form-label {
|
||||
/* Expert CRD Form mandatory field styling */
|
||||
.expert-crd-form .form-group.mandatory .form-label {
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.crd-form .form-group.mandatory .form-label::after {
|
||||
.expert-crd-form .form-group.mandatory .form-label::after {
|
||||
content: " *";
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.crd-form .form-group.mandatory {
|
||||
.expert-crd-form .form-group.mandatory {
|
||||
border-left: 3px solid #dc3545;
|
||||
padding-left: 10px;
|
||||
background-color: rgba(220, 53, 69, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .crd-form .form-group.mandatory {
|
||||
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
border-left-color: #ff6b6b;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after {
|
||||
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||
html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.crd-form .nav-tabs .nav-link.has-mandatory {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.service-deactivated .card {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
img {
|
||||
opacity: 75%
|
||||
}
|
||||
h4, small, p {
|
||||
color: var(--bs-secondary-color) !important;
|
||||
}
|
||||
a.btn-outline-secondary {
|
||||
color: var(--bs-btn-disabled-color) !important;
|
||||
background-color: var(--bs-btn-disabled-bg) !important;
|
||||
border-color: var(--bs-btn-disabled-border-color) !important;
|
||||
opacity: var(--bs-btn-disabled-opacity);
|
||||
}
|
||||
a.btn-secondary {
|
||||
color: white !important;
|
||||
}
|
||||
a.btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.ml-auto {
|
||||
margin-left: auto !important
|
||||
}
|
||||
|
||||
.beta-banner {
|
||||
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%);
|
||||
color: white;
|
||||
padding: 0.75rem 0;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.beta-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.beta-banner-badge {
|
||||
background-color: white;
|
||||
color: var(--bs-primary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.beta-banner-text {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.beta-banner-button {
|
||||
background-color: white;
|
||||
color: var(--bs-primary);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
padding: 0.375rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.beta-banner-button:hover {
|
||||
background-color: var(--brand-light);
|
||||
color: var(--bs-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
html[data-bs-theme="dark"] .beta-banner {
|
||||
background: linear-gradient(135deg, var(--bs-primary) 0%, #7a4fc4 100%);
|
||||
}
|
||||
html[data-bs-theme="dark"] .beta-banner-badge {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
html[data-bs-theme="dark"] .beta-banner-button {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
html[data-bs-theme="dark"] .beta-banner-button:hover {
|
||||
background-color: white;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
|
|
|||
30
src/servala/static/js/bootstrap-tabs.js
vendored
Normal file
30
src/servala/static/js/bootstrap-tabs.js
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// 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,6 +7,10 @@ const initDynamicArrayWidget = () => {
|
|||
const containers = document.querySelectorAll('.dynamic-array-widget')
|
||||
|
||||
containers.forEach(container => {
|
||||
if (container.dataset.initialized === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
const itemsContainer = container.querySelector('.array-items')
|
||||
const addButton = container.querySelector('.add-array-item')
|
||||
const hiddenInput = container.querySelector('input[type="hidden"]')
|
||||
|
|
@ -22,6 +26,7 @@ const initDynamicArrayWidget = () => {
|
|||
|
||||
// Ensure hidden input is synced with visible inputs on initialization
|
||||
updateHiddenInput(container)
|
||||
container.dataset.initialized = 'true'
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +129,8 @@ const updateRemoveButtonVisibility = (container) => {
|
|||
})
|
||||
}
|
||||
|
||||
window.updateHiddenInput = updateHiddenInput
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDynamicArrayWidget)
|
||||
document.addEventListener('htmx:afterSwap', initDynamicArrayWidget)
|
||||
document.addEventListener('htmx:afterSettle', initDynamicArrayWidget)
|
||||
|
|
|
|||
49
src/servala/static/js/expert-mode.js
Normal file
49
src/servala/static/js/expert-mode.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
(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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
62
src/servala/static/js/fqdn.js
Normal file
62
src/servala/static/js/fqdn.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ urlpatterns = [
|
|||
# - accounts/keycloak/login/callback/
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/", include("servala.api.urls")),
|
||||
]
|
||||
|
||||
# Serve static and media files in development
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import base64
|
||||
|
||||
import pytest
|
||||
|
||||
from servala.core.models import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
OrganizationMembership,
|
||||
OrganizationOrigin,
|
||||
User,
|
||||
)
|
||||
from servala.core.models.service import (
|
||||
CloudProvider,
|
||||
Service,
|
||||
ServiceCategory,
|
||||
ServiceOffering,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -13,6 +22,11 @@ def origin():
|
|||
return OrganizationOrigin.objects.create(name="TESTORIGIN")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def billing_entity():
|
||||
return BillingEntity.objects.create(name="Test Entity")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def organization(origin):
|
||||
return Organization.objects.create(name="Test Org", origin=origin)
|
||||
|
|
@ -30,3 +44,76 @@ def org_owner(organization):
|
|||
organization=organization, user=user, role="owner"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service_category():
|
||||
return ServiceCategory.objects.create(
|
||||
name="Databases",
|
||||
description="Database services",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service(test_service_category):
|
||||
return Service.objects.create(
|
||||
name="Redis",
|
||||
slug="redis",
|
||||
category=test_service_category,
|
||||
description="Redis database service",
|
||||
osb_service_id="test-service-123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_cloud_provider():
|
||||
return CloudProvider.objects.create(
|
||||
name="Exoscale",
|
||||
description="Exoscale cloud provider",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_service_offering(test_service, test_cloud_provider):
|
||||
return ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=test_cloud_provider,
|
||||
description="Redis on Exoscale",
|
||||
osb_plan_id="test-plan-123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def osb_client(client):
|
||||
credentials = base64.b64encode(b"testuser:testpass").decode("ascii")
|
||||
client.defaults = {"HTTP_AUTHORIZATION": f"Basic {credentials}"}
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_odoo_success(mocker):
|
||||
"""
|
||||
Mock Odoo client with successful responses for organization creation.
|
||||
Returns the mock object for further customization if needed.
|
||||
"""
|
||||
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
|
||||
|
||||
# Default successful responses for organization creation
|
||||
mock_client.execute.side_effect = [
|
||||
123, # company_id
|
||||
456, # invoice_address_id
|
||||
789, # sale_order_id
|
||||
]
|
||||
mock_client.search_read.return_value = [{"name": "SO001"}]
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_odoo_failure(mocker):
|
||||
"""
|
||||
Mock Odoo client that raises an exception to simulate failure.
|
||||
"""
|
||||
mock_client = mocker.patch("servala.core.models.organization.CLIENT")
|
||||
mock_client.execute.side_effect = Exception("Odoo connection failed")
|
||||
return mock_client
|
||||
|
|
|
|||
746
src/tests/test_api_exoscale.py
Normal file
746
src/tests/test_api_exoscale.py
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
from django.core import mail
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from servala.core.models import Organization, OrganizationOrigin, User
|
||||
from servala.core.models.service import (
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
ServiceDefinition,
|
||||
ServiceInstance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exoscale_origin():
|
||||
origin, _ = OrganizationOrigin.objects.get_or_create(
|
||||
name="exoscale-marketplace",
|
||||
defaults={
|
||||
"description": "Organizations created via Exoscale marketplace onboarding"
|
||||
},
|
||||
)
|
||||
return origin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def instance_id():
|
||||
return "test-instance-123"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_osb_payload():
|
||||
return {
|
||||
"service_id": None,
|
||||
"plan_id": None,
|
||||
"context": {
|
||||
"organization_guid": "test-org-guid-123",
|
||||
"organization_name": "Test Organization",
|
||||
"organization_display_name": "Test Organization Display",
|
||||
},
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"full_name": "Test User",
|
||||
"role": "owner",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_successful_onboarding_new_organization(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
|
||||
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.origin == exoscale_origin
|
||||
assert org.namespace.startswith("org-")
|
||||
|
||||
with scopes_disabled():
|
||||
assert org.invitations.all().filter(email="test@example.com").exists()
|
||||
|
||||
billing_entity = org.billing_entity
|
||||
assert billing_entity.name == "Test Organization Display (Exoscale)"
|
||||
assert billing_entity.odoo_company_id == 123
|
||||
assert billing_entity.odoo_invoice_id == 456
|
||||
|
||||
assert org.odoo_sale_order_id == 789
|
||||
assert org.odoo_sale_order_name == "SO001"
|
||||
assert org.limit_osb_services.all().count() == 1
|
||||
|
||||
assert len(mail.outbox) == 2
|
||||
invitation_email = mail.outbox[0]
|
||||
assert (
|
||||
invitation_email.subject
|
||||
== "You're invited to join Test Organization Display on Servala"
|
||||
)
|
||||
assert "test@example.com" in invitation_email.to
|
||||
|
||||
welcome_email = mail.outbox[1]
|
||||
assert welcome_email.subject == "Get started with Redis - Test Organization Display"
|
||||
assert "redis/offering/" in welcome_email.body
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_new_organization_inherits_origin(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
billing_entity,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
exoscale_origin.billing_entity = billing_entity
|
||||
exoscale_origin.save()
|
||||
|
||||
response = osb_client.put(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(valid_osb_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
response_data = json.loads(response.content)
|
||||
assert response_data["message"] == "Successfully enabled service"
|
||||
|
||||
org = Organization.objects.get(osb_guid="test-org-guid-123")
|
||||
assert org.name == "Test Organization Display"
|
||||
assert org.billing_entity == exoscale_origin.billing_entity
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_duplicate_organization_returns_existing(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
org = Organization.objects.create(
|
||||
name="Existing Org",
|
||||
osb_guid="test-org-guid-123",
|
||||
origin=exoscale_origin,
|
||||
)
|
||||
org.limit_osb_services.add(test_service)
|
||||
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
|
||||
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 == 200
|
||||
response_data = json.loads(response.content)
|
||||
assert response_data["message"] == "Service already enabled"
|
||||
assert Organization.objects.filter(osb_guid="test-org-guid-123").count() == 1
|
||||
assert len(mail.outbox) == 0 # No email necessary
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_unauthenticated_osb_api_request_fails(
|
||||
client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
|
||||
response = client.put(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(valid_osb_payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
"field_to_remove,expected_error",
|
||||
[
|
||||
(
|
||||
("context", "organization_guid"),
|
||||
"organization_guid is required but missing",
|
||||
),
|
||||
(
|
||||
("context", "organization_name"),
|
||||
"organization_name is required but missing",
|
||||
),
|
||||
("service_id", "service_id is required but missing"),
|
||||
("plan_id", "plan_id is required but missing"),
|
||||
(("parameters", "users"), "users array is required but missing"),
|
||||
],
|
||||
)
|
||||
def test_missing_required_fields_error(
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
field_to_remove,
|
||||
expected_error,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
|
||||
if isinstance(field_to_remove, tuple):
|
||||
if field_to_remove[0] == "context":
|
||||
del valid_osb_payload["context"][field_to_remove[1]]
|
||||
elif field_to_remove[0] == "parameters":
|
||||
del valid_osb_payload["parameters"][field_to_remove[1]]
|
||||
else:
|
||||
if field_to_remove in valid_osb_payload:
|
||||
del valid_osb_payload[field_to_remove]
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert expected_error in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_service_id_error(osb_client, valid_osb_payload, instance_id):
|
||||
valid_osb_payload["service_id"] = 99999
|
||||
valid_osb_payload["plan_id"] = 1
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "Unknown service_id: 99999" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_plan_id_error(
|
||||
osb_client, test_service, valid_osb_payload, instance_id
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = 99999
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert (
|
||||
f"Unknown plan_id: 99999 for service_id: {test_service.osb_service_id}"
|
||||
in response_data["error"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_empty_users_array_error(
|
||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_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["parameters"]["users"] = []
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "users array is required but missing" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiple_users_error(
|
||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_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["parameters"]["users"] = [
|
||||
{"email": "user1@example.com", "full_name": "User One"},
|
||||
{"email": "user2@example.com", "full_name": "User Two"},
|
||||
]
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "users array is expected to contain a single user" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_empty_email_address_error(
|
||||
osb_client, test_service, test_service_offering, valid_osb_payload, instance_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["parameters"]["users"] = [
|
||||
{"email": "", "full_name": "User With No Email"},
|
||||
]
|
||||
|
||||
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 == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "Unable to create user:" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invalid_json_error(osb_client, instance_id):
|
||||
response = osb_client.put(
|
||||
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_user_creation_with_name_parsing(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_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["parameters"]["users"][0]["full_name"] = "John Doe Smith"
|
||||
|
||||
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
|
||||
user = User.objects.get(email="test@example.com")
|
||||
assert user.first_name == "John"
|
||||
assert user.last_name == "Doe Smith"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_email_normalization(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_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["parameters"]["users"][0]["email"] = " TEST@EXAMPLE.COM "
|
||||
|
||||
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
|
||||
user = User.objects.get(email="test@example.com")
|
||||
assert user.email == "test@example.com"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_odoo_integration_failure_handling(
|
||||
mock_odoo_failure,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
valid_osb_payload,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
valid_osb_payload["service_id"] = test_service.osb_service_id
|
||||
valid_osb_payload["plan_id"] = test_service_offering.osb_plan_id
|
||||
|
||||
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 == 500
|
||||
response_data = json.loads(response.content)
|
||||
assert response_data["error"] == "Internal server error"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_organization_creation_with_context_only(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
exoscale_origin,
|
||||
instance_id,
|
||||
):
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"context": {
|
||||
"organization_guid": "fallback-org-guid",
|
||||
"organization_name": "Fallback Organization",
|
||||
},
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
"email": "fallback@example.com",
|
||||
"full_name": "Fallback User",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
response = osb_client.put(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
org = Organization.objects.get(osb_guid="fallback-org-guid")
|
||||
assert org is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_offboarding_success(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
instance_id,
|
||||
):
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}"
|
||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"{}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "service_id is required but missing" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_missing_plan_id(osb_client, test_service, instance_id):
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "plan_id is required but missing" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_invalid_service_id(osb_client, instance_id):
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}?service_id=invalid&plan_id=invalid"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "Unknown service_id: invalid" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}"
|
||||
f"?service_id={test_service.osb_service_id}&plan_id=invalid"
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert (
|
||||
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
|
||||
in response_data["error"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_suspension_success(
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
instance_id,
|
||||
):
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"full_name": "Test User",
|
||||
"role": "owner",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
response = osb_client.patch(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.content == b"{}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
|
||||
payload = {
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"parameters": {"users": []},
|
||||
}
|
||||
|
||||
response = osb_client.patch(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "service_id is required but missing" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_missing_plan_id(osb_client, test_service, instance_id):
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"parameters": {"users": []},
|
||||
}
|
||||
|
||||
response = osb_client.patch(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "plan_id is required but missing" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_invalid_json(osb_client, instance_id):
|
||||
response = osb_client.patch(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data="invalid json{",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
response_data = json.loads(response.content)
|
||||
assert "Invalid JSON in request body" in response_data["error"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_creates_ticket_with_admin_links(
|
||||
mocker,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
instance_id,
|
||||
):
|
||||
# Mock the create_helpdesk_ticket function
|
||||
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
|
||||
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_id}"
|
||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the ticket was created with admin URL
|
||||
mock_create_ticket.assert_called_once()
|
||||
call_kwargs = mock_create_ticket.call_args[1]
|
||||
|
||||
# Check that the description contains an admin URL
|
||||
assert "admin/core/serviceoffering" in call_kwargs["description"]
|
||||
assert f"/{test_service_offering.pk}/" in call_kwargs["description"]
|
||||
assert (
|
||||
call_kwargs["title"]
|
||||
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_creates_ticket_with_user_admin_links(
|
||||
mocker,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
instance_id,
|
||||
org_owner,
|
||||
):
|
||||
# Mock the create_helpdesk_ticket function
|
||||
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
|
||||
|
||||
payload = {
|
||||
"service_id": test_service.osb_service_id,
|
||||
"plan_id": test_service_offering.osb_plan_id,
|
||||
"parameters": {
|
||||
"users": [
|
||||
{
|
||||
"email": org_owner.email,
|
||||
"full_name": "Test User",
|
||||
"role": "owner",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
response = osb_client.patch(
|
||||
f"/api/osb/v2/service_instances/{instance_id}",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the ticket was created with admin URLs
|
||||
mock_create_ticket.assert_called_once()
|
||||
call_kwargs = mock_create_ticket.call_args[1]
|
||||
|
||||
# Check that the description contains admin URLs
|
||||
assert "admin/core/serviceoffering" in call_kwargs["description"]
|
||||
assert "admin/core/user" in call_kwargs["description"]
|
||||
assert f"/{org_owner.pk}/" in call_kwargs["description"]
|
||||
assert (
|
||||
call_kwargs["title"]
|
||||
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ticket_includes_organization_and_instance_when_found(
|
||||
mocker,
|
||||
mock_odoo_success,
|
||||
osb_client,
|
||||
test_service,
|
||||
test_service_offering,
|
||||
organization,
|
||||
):
|
||||
# Mock the create_helpdesk_ticket function
|
||||
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
|
||||
|
||||
service_definition = ServiceDefinition.objects.create(
|
||||
name="Test Definition",
|
||||
service=test_service,
|
||||
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
|
||||
)
|
||||
control_plane = ControlPlane.objects.create(
|
||||
name="Test Control Plane",
|
||||
cloud_provider=test_service_offering.provider,
|
||||
api_credentials={
|
||||
"certificate-authority-data": "test",
|
||||
"server": "https://test",
|
||||
"token": "test",
|
||||
},
|
||||
)
|
||||
crd = ControlPlaneCRD.objects.create(
|
||||
service_offering=test_service_offering,
|
||||
control_plane=control_plane,
|
||||
service_definition=service_definition,
|
||||
)
|
||||
instance_name = "test-instance-123"
|
||||
service_instance = ServiceInstance.objects.create(
|
||||
name=instance_name,
|
||||
organization=organization,
|
||||
context=crd,
|
||||
)
|
||||
|
||||
response = osb_client.delete(
|
||||
f"/api/osb/v2/service_instances/{instance_name}"
|
||||
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the ticket was created with all admin URLs
|
||||
mock_create_ticket.assert_called_once()
|
||||
call_kwargs = mock_create_ticket.call_args[1]
|
||||
|
||||
# Check organization is included
|
||||
assert f"Organization: {organization.name}" in call_kwargs["description"]
|
||||
assert "admin/core/organization" in call_kwargs["description"]
|
||||
assert f"/{organization.pk}/" in call_kwargs["description"]
|
||||
|
||||
# Check instance is included
|
||||
assert f"Instance: {service_instance.name}" in call_kwargs["description"]
|
||||
assert "admin/core/serviceinstance" in call_kwargs["description"]
|
||||
assert f"/{service_instance.pk}/" in call_kwargs["description"]
|
||||
1095
src/tests/test_form_config.py
Normal file
1095
src/tests/test_form_config.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,7 @@
|
|||
import pytest
|
||||
|
||||
from servala.core.models.service import CloudProvider, ServiceOffering
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,redirect",
|
||||
|
|
@ -45,3 +47,103 @@ def test_organization_linked_in_sidebar(
|
|||
assert response.status_code == 200
|
||||
assert organization.name in response.content.decode()
|
||||
assert other_organization.name not in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_service_detail_redirects_with_single_offering(
|
||||
client, org_owner, organization, test_service, test_service_offering
|
||||
):
|
||||
client.force_login(org_owner)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
|
||||
assert response.url == expected_url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_service_detail_shows_multiple_offerings(
|
||||
client, org_owner, organization, test_service, test_service_offering
|
||||
):
|
||||
second_provider = CloudProvider.objects.create(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
second_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
osb_plan_id="test-plan-456",
|
||||
)
|
||||
|
||||
client.force_login(org_owner)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
assert test_service_offering.provider.name in content
|
||||
assert second_offering.provider.name in content
|
||||
assert "Create Instance" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_service_detail_respects_cloud_provider_restrictions(
|
||||
client, org_owner, organization, test_service, test_service_offering
|
||||
):
|
||||
second_provider = CloudProvider.objects.create(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
osb_plan_id="test-plan-456",
|
||||
)
|
||||
organization.origin.limit_cloudproviders.add(test_service_offering.provider)
|
||||
|
||||
client.force_login(org_owner)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
|
||||
assert response.url == expected_url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_service_detail_no_redirect_with_restricted_multiple_offerings(
|
||||
client, org_owner, organization, test_service, test_service_offering
|
||||
):
|
||||
second_provider = CloudProvider.objects.create(
|
||||
name="AWS", description="Amazon Web Services"
|
||||
)
|
||||
second_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=second_provider,
|
||||
description="Redis on AWS",
|
||||
osb_plan_id="test-plan-456",
|
||||
)
|
||||
third_provider = CloudProvider.objects.create(
|
||||
name="Azure", description="Microsoft Azure"
|
||||
)
|
||||
third_offering = ServiceOffering.objects.create(
|
||||
service=test_service,
|
||||
provider=third_provider,
|
||||
description="Redis on Azure",
|
||||
osb_plan_id="test-plan-789",
|
||||
)
|
||||
organization.origin.limit_cloudproviders.add(
|
||||
test_service_offering.provider, second_provider
|
||||
)
|
||||
|
||||
client.force_login(org_owner)
|
||||
url = f"/org/{organization.slug}/services/{test_service.slug}/"
|
||||
response = client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert test_service_offering.provider.name in content
|
||||
assert second_offering.provider.name in content
|
||||
assert third_offering.provider.name not in content
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue