From bfb64efdec8b18474eb0496a371c3bf48a27cec9 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 30 May 2025 15:44:37 +0200 Subject: [PATCH 01/13] add yaml output for exoscale marketplace --- hub/services/views/offerings.py | 97 ++++++++++++++++++++++++++++++++- pyproject.toml | 3 + uv.lock | 79 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index c6e13a9..7a747ce 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,5 +1,10 @@ +import re +import pyaml + from django.shortcuts import render, get_object_or_404 from django.db.models import Q +from django.http import HttpResponse +from django.template.loader import render_to_string from hub.services.models import ( ServiceOffering, CloudProvider, @@ -8,8 +13,8 @@ from hub.services.models import ( ComputePlan, VSHNAppCatPrice, ) -import re from collections import defaultdict +from markdownify import markdownify def natural_sort_key(name): @@ -79,6 +84,10 @@ def offering_detail(request, provider_slug, service_slug): service__slug=service_slug, ) + # Check if Exoscale marketplace YAML is requested + if request.GET.get("exo_marketplace") == "true": + return generate_exoscale_marketplace_yaml(offering) + pricing_data_by_group_and_service_level = None # Generate pricing data for VSHN offerings @@ -92,6 +101,92 @@ def offering_detail(request, provider_slug, service_slug): return render(request, "services/offering_detail.html", context) +def generate_exoscale_marketplace_yaml(offering): + """Generate YAML structure for Exoscale marketplace""" + + # Create service name slug for YAML key + service_slug = offering.service.slug.replace("-", "") + yaml_key = f"marketplace_PRODUCTS_servala-{service_slug}" + + # Generate product overview content from service description (convert HTML to Markdown) + product_overview = "" + if offering.service.description: + product_overview = markdownify( + offering.service.description, heading_style="ATX" + ) + + # Generate highlights content from offering description and offer_description (convert HTML to Markdown) + highlights = "" + if offering.description: + highlights += markdownify(offering.description, heading_style="ATX") + if offering.offer_description: + if highlights: + highlights += "\n\n" + highlights += markdownify( + offering.offer_description.get_full_text(), heading_style="ATX" + ) + + # Build YAML structure + yaml_structure = { + yaml_key: { + "page_class": "tmpl-marketplace-product", + "html_title": f"Managed {offering.service.name} by VSHN via Servala", + "meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.", + "page_header_title": f"Managed {offering.service.name} by VSHN via Servala", + "provider_key": "vshn", + "slug": f"servala-managed-{offering.service.slug}", + "title": f"Managed {offering.service.name} by VSHN via Servala", + "logo": f"img/servala-{offering.service.slug}.svg", + "list_display": [], + "meta": [ + {"key": "exoscale-iaas", "value": True}, + {"key": "availability", "zones": "all"}, + ], + "action_link": f"https://servala.com/offering/{offering.cloud_provider.slug}/{offering.service.slug}/?source=exoscale_marketplace", + "action_link_text": "Subscribe now", + "blobs": [ + { + "key": "product-overview", + "blob": ( + product_overview.strip() + if product_overview + else "Service description not available." + ), + }, + { + "key": "highlights", + "blob": ( + highlights.strip() + if highlights + else "Offering highlights not available." + ), + }, + "editor", + { + "key": "pricing", + "blob": f"Find all the pricing information on the [Servala website](https://servala.com/offering/{offering.cloud_provider.slug}/{offering.service.slug}/?source=exoscale_marketplace#plans)", + }, + { + "key": "service-and-support", + "blob": "Servala is operated by VSHN AG in Zurich, Switzerland.\n\nSeveral SLAs are available on request, offering support 24/7.\n\nMore details can be found in the [VSHN Service Levels Documentation](https://products.vshn.ch/service_levels.html).", + }, + { + "key": "terms-of-service", + "blob": "- [Product Description](https://products.vshn.ch/servala/index.html)\n- [General Terms and Conditions](https://products.vshn.ch/legal/gtc_en.html)\n- [SLA](https://products.vshn.ch/service_levels.html)\n- [DPA](https://products.vshn.ch/legal/dpa_en.html)\n- [Privacy Policy](https://products.vshn.ch/legal/privacy_policy_en.html)", + }, + ], + } + } + + # Generate YAML response for browser display + yaml_content = pyaml.dump(yaml_structure, sort_dicts=False) + + # Return as plain text for browser display + response = HttpResponse(yaml_content, content_type="text/plain") + + return response + + def generate_pricing_data(offering): """Generate pricing data for a specific offering and cloud provider""" # Fetch compute plans for this cloud provider diff --git a/pyproject.toml b/pyproject.toml index c1cd69f..fead6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,11 @@ dependencies = [ "django-schema-viewer>=0.5.2", "djangorestframework>=3.15.2", "environs[django]~=14.0", + "markdownify>=1.1.0", "odoorpc>=0.10.1", "pillow>=11.1.0", + "pyaml>=25.5.0", + "pyyaml>=6.0.2", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 9f8cfae..e5606b8 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, +] + [[package]] name = "diff-match-patch" version = "20241021" @@ -202,6 +215,19 @@ django = [ { name = "django-cache-url" }, ] +[[package]] +name = "markdownify" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload-time = "2025-03-05T11:54:40.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload-time = "2025-03-05T11:54:39.454Z" }, +] + [[package]] name = "marshmallow" version = "3.26.0" @@ -290,6 +316,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" }, ] +[[package]] +name = "pyaml" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/40/94f10f32ab952c5cca713d9ac9d8b2fdc37392d90eea403823eeac674c24/pyaml-25.5.0.tar.gz", hash = "sha256:5799560c7b1c9daf35a7a4535f53e2c30323f74cbd7cb4f2e715b16dd681a58a", size = 29812, upload-time = "2025-05-29T05:34:05.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/7d/1b5061beff826f902285827261485a058b943332eba8a5532a0164735205/pyaml-25.5.0-py3-none-any.whl", hash = "sha256:b9e0c4e58a5e8003f8f18e802db49fd0563ada587209b13e429bdcbefa87d035", size = 26422, upload-time = "2025-05-29T05:34:03.594Z" }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -308,6 +346,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/a2/b6a5cbd5822b4d049adfedf496ce0908480e5a41722fda7b7ffaacb086d6/python_monkey_business-1.1.0-py2.py3-none-any.whl", hash = "sha256:15b4f603c749ba9a7b4f1acd36af023a6c5ba0f7e591c945f8253f0ef44bf389", size = 4670, upload-time = "2024-07-11T16:34:58.565Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "servala-fe" version = "0.1.0" @@ -322,8 +377,11 @@ dependencies = [ { name = "django-schema-viewer" }, { name = "djangorestframework" }, { name = "environs", extra = ["django"] }, + { name = "markdownify" }, { name = "odoorpc" }, { name = "pillow" }, + { name = "pyaml" }, + { name = "pyyaml" }, ] [package.optional-dependencies] @@ -343,11 +401,32 @@ requires-dist = [ { name = "django-schema-viewer", specifier = ">=0.5.2" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "environs", extras = ["django"], specifier = "~=14.0" }, + { name = "markdownify", specifier = ">=1.1.0" }, { name = "odoorpc", specifier = ">=0.10.1" }, { name = "pillow", specifier = ">=11.1.0" }, + { name = "pyaml", specifier = ">=25.5.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, ] provides-extras = ["dev"] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, +] + [[package]] name = "sqlparse" version = "0.5.3" From 4f9a39fd36dd0eafa19a2cae6e67b2be5b4edd49 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Fri, 30 May 2025 16:07:38 +0200 Subject: [PATCH 02/13] changed yaml output --- hub/services/views/offerings.py | 18 +++++++++++++----- pyproject.toml | 1 - uv.lock | 14 -------------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 7a747ce..a7df1cc 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,5 +1,5 @@ import re -import pyaml +import yaml from django.shortcuts import render, get_object_or_404 from django.db.models import Q @@ -113,18 +113,18 @@ def generate_exoscale_marketplace_yaml(offering): if offering.service.description: product_overview = markdownify( offering.service.description, heading_style="ATX" - ) + ).strip() # Generate highlights content from offering description and offer_description (convert HTML to Markdown) highlights = "" if offering.description: - highlights += markdownify(offering.description, heading_style="ATX") + highlights += markdownify(offering.description, heading_style="ATX").strip() if offering.offer_description: if highlights: highlights += "\n\n" highlights += markdownify( offering.offer_description.get_full_text(), heading_style="ATX" - ) + ).strip() # Build YAML structure yaml_structure = { @@ -179,7 +179,15 @@ def generate_exoscale_marketplace_yaml(offering): } # Generate YAML response for browser display - yaml_content = pyaml.dump(yaml_structure, sort_dicts=False) + yaml_content = yaml.dump( + yaml_structure, + default_flow_style=False, + allow_unicode=True, + indent=2, + width=120, + sort_keys=False, + default_style=None, + ) # Return as plain text for browser display response = HttpResponse(yaml_content, content_type="text/plain") diff --git a/pyproject.toml b/pyproject.toml index fead6d1..82adb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "markdownify>=1.1.0", "odoorpc>=0.10.1", "pillow>=11.1.0", - "pyaml>=25.5.0", "pyyaml>=6.0.2", ] diff --git a/uv.lock b/uv.lock index e5606b8..5584e25 100644 --- a/uv.lock +++ b/uv.lock @@ -316,18 +316,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" }, ] -[[package]] -name = "pyaml" -version = "25.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/40/94f10f32ab952c5cca713d9ac9d8b2fdc37392d90eea403823eeac674c24/pyaml-25.5.0.tar.gz", hash = "sha256:5799560c7b1c9daf35a7a4535f53e2c30323f74cbd7cb4f2e715b16dd681a58a", size = 29812, upload-time = "2025-05-29T05:34:05.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/7d/1b5061beff826f902285827261485a058b943332eba8a5532a0164735205/pyaml-25.5.0-py3-none-any.whl", hash = "sha256:b9e0c4e58a5e8003f8f18e802db49fd0563ada587209b13e429bdcbefa87d035", size = 26422, upload-time = "2025-05-29T05:34:03.594Z" }, -] - [[package]] name = "python-dotenv" version = "1.0.1" @@ -380,7 +368,6 @@ dependencies = [ { name = "markdownify" }, { name = "odoorpc" }, { name = "pillow" }, - { name = "pyaml" }, { name = "pyyaml" }, ] @@ -404,7 +391,6 @@ requires-dist = [ { name = "markdownify", specifier = ">=1.1.0" }, { name = "odoorpc", specifier = ">=0.10.1" }, { name = "pillow", specifier = ">=11.1.0" }, - { name = "pyaml", specifier = ">=25.5.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, ] provides-extras = ["dev"] From 475a4643fdc2067e892816951b51c5069f85deef Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Mon, 2 Jun 2025 16:22:54 +0200 Subject: [PATCH 03/13] frontend price calculator --- hub/services/static/css/price-calculator.css | 36 ++ hub/services/static/js/price-calculator.js | 411 ++++++++++++++++++ .../templates/services/offering_detail.html | 208 ++++++--- hub/services/views/offerings.py | 26 +- 4 files changed, 617 insertions(+), 64 deletions(-) create mode 100644 hub/services/static/css/price-calculator.css create mode 100644 hub/services/static/js/price-calculator.js diff --git a/hub/services/static/css/price-calculator.css b/hub/services/static/css/price-calculator.css new file mode 100644 index 0000000..de8074e --- /dev/null +++ b/hub/services/static/css/price-calculator.css @@ -0,0 +1,36 @@ +.form-range::-webkit-slider-thumb { + background: #6f42c1; +} + +.form-range::-moz-range-thumb { + background: #6f42c1; + border: none; +} + +.btn-check:checked+.btn-outline-primary { + background-color: #6f42c1; + border-color: #6f42c1; + color: white; +} + +.card { + transition: box-shadow 0.2s ease-in-out; +} + +.card:hover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); +} + +#selectedPlanDetails { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} \ No newline at end of file diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js new file mode 100644 index 0000000..2cafe7d --- /dev/null +++ b/hub/services/static/js/price-calculator.js @@ -0,0 +1,411 @@ +/** + * Price Calculator for Service Offerings + * Handles interactive pricing calculation with sliders and plan selection + */ + +class PriceCalculator { + constructor() { + this.pricingData = null; + this.storagePrice = 0.15; // CHF per GB per month + this.currentOffering = null; + this.init(); + } + + // Initialize calculator elements and event listeners + init() { + // Get offering info from URL + const pathParts = window.location.pathname.split('/'); + if (pathParts.length >= 4 && pathParts[1] === 'offering') { + this.currentOffering = { + provider_slug: pathParts[2], + service_slug: pathParts[3] + }; + } + + // Initialize DOM elements + this.initElements(); + + // Load pricing data and setup calculator + if (this.currentOffering) { + this.loadPricingData(); + } + } + + // Initialize DOM element references + initElements() { + // Calculator controls + this.cpuRange = document.getElementById('cpuRange'); + this.memoryRange = document.getElementById('memoryRange'); + this.storageRange = document.getElementById('storageRange'); + this.cpuValue = document.getElementById('cpuValue'); + this.memoryValue = document.getElementById('memoryValue'); + this.storageValue = document.getElementById('storageValue'); + this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); + this.planSelect = document.getElementById('planSelect'); + + // Result display elements + this.planMatchStatus = document.getElementById('planMatchStatus'); + this.selectedPlanDetails = document.getElementById('selectedPlanDetails'); + this.noMatchFound = document.getElementById('noMatchFound'); + + // Plan detail elements + this.planGroup = document.getElementById('planGroup'); + this.planName = document.getElementById('planName'); + this.planDescription = document.getElementById('planDescription'); + this.planCpus = document.getElementById('planCpus'); + this.planMemory = document.getElementById('planMemory'); + this.computePrice = document.getElementById('computePrice'); + this.servicePrice = document.getElementById('servicePrice'); + this.storagePriceEl = document.getElementById('storagePrice'); + this.storageAmount = document.getElementById('storageAmount'); + this.totalPrice = document.getElementById('totalPrice'); + } + + // Load pricing data from API endpoint + async loadPricingData() { + try { + const response = await fetch(`/offering/${this.currentOffering.provider_slug}/${this.currentOffering.service_slug}/?pricing=json`); + if (!response.ok) { + throw new Error('Failed to load pricing data'); + } + + this.pricingData = await response.json(); + this.setupEventListeners(); + this.populatePlanDropdown(); + this.updatePricing(); + } catch (error) { + console.error('Error loading pricing data:', error); + this.showError('Failed to load pricing information'); + } + } + + // Setup event listeners for calculator controls + setupEventListeners() { + if (!this.cpuRange || !this.memoryRange || !this.storageRange) return; + + // Setup service levels based on available data + this.setupServiceLevels(); + + // Slider event listeners + this.cpuRange.addEventListener('input', () => { + this.cpuValue.textContent = this.cpuRange.value; + this.updatePricing(); + }); + + this.memoryRange.addEventListener('input', () => { + this.memoryValue.textContent = this.memoryRange.value; + this.updatePricing(); + }); + + this.storageRange.addEventListener('input', () => { + this.storageValue.textContent = this.storageRange.value; + this.updatePricing(); + }); + + // Service level change listeners + this.serviceLevelInputs.forEach(input => { + input.addEventListener('change', () => { + this.populatePlanDropdown(); + this.updatePricing(); + }); + }); + + // Plan selection listener + if (this.planSelect) { + this.planSelect.addEventListener('change', () => { + if (this.planSelect.value) { + const selectedPlan = JSON.parse(this.planSelect.value); + + // Update sliders to match selected plan + this.cpuRange.value = selectedPlan.vcpus; + this.memoryRange.value = selectedPlan.ram; + this.cpuValue.textContent = selectedPlan.vcpus; + this.memoryValue.textContent = selectedPlan.ram; + + this.updatePricingWithPlan(selectedPlan); + } else { + this.updatePricing(); + } + }); + } + } + + // Setup service levels dynamically from pricing data + setupServiceLevels() { + if (!this.pricingData) return; + + const serviceLevelGroup = document.getElementById('serviceLevelGroup'); + if (!serviceLevelGroup) return; + + // Get all available service levels from the pricing data + const availableServiceLevels = new Set(); + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + availableServiceLevels.add(serviceLevel); + }); + }); + + // Clear existing service level buttons + serviceLevelGroup.innerHTML = ''; + + // Create buttons for each available service level + let isFirst = true; + availableServiceLevels.forEach(serviceLevel => { + const inputId = `serviceLevel${serviceLevel.replace(/\s+/g, '')}`; + + // Create radio input + const input = document.createElement('input'); + input.type = 'radio'; + input.className = 'btn-check'; + input.name = 'serviceLevel'; + input.id = inputId; + input.value = serviceLevel; + if (isFirst) { + input.checked = true; + isFirst = false; + } + + // Create label + const label = document.createElement('label'); + label.className = 'btn btn-outline-primary'; + label.setAttribute('for', inputId); + label.textContent = serviceLevel; + + // Add event listener + input.addEventListener('change', () => { + this.populatePlanDropdown(); + this.updatePricing(); + }); + + serviceLevelGroup.appendChild(input); + serviceLevelGroup.appendChild(label); + }); + + // Update the serviceLevelInputs reference + this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); + + // Calculate and set slider maximums based on available plans + this.updateSliderMaximums(); + } + + // Calculate maximum values for sliders based on available plans + updateSliderMaximums() { + if (!this.pricingData || !this.cpuRange || !this.memoryRange) return; + + let maxCpus = 0; + let maxMemory = 0; + + // Find maximum CPU and memory across all plans + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + Object.keys(group).forEach(serviceLevel => { + group[serviceLevel].forEach(plan => { + const planCpus = parseFloat(plan.vcpus); + const planMemory = parseFloat(plan.ram); + + if (planCpus > maxCpus) maxCpus = planCpus; + if (planMemory > maxMemory) maxMemory = planMemory; + }); + }); + }); + + // Set slider maximums with some padding + if (maxCpus > 0) { + this.cpuRange.max = Math.ceil(maxCpus); + // Update the max display under the slider + const cpuMaxDisplay = this.cpuRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child'); + if (cpuMaxDisplay) cpuMaxDisplay.textContent = Math.ceil(maxCpus); + } + + if (maxMemory > 0) { + this.memoryRange.max = Math.ceil(maxMemory); + // Update the max display under the slider + const memoryMaxDisplay = this.memoryRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child'); + if (memoryMaxDisplay) memoryMaxDisplay.textContent = Math.ceil(maxMemory) + ' GB'; + } + } + + // Populate plan dropdown based on selected service level + populatePlanDropdown() { + if (!this.planSelect || !this.pricingData) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + if (!serviceLevel) return; + + // Clear existing options + this.planSelect.innerHTML = ''; + + // Collect all plans for selected service level + const availablePlans = []; + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + if (group[serviceLevel]) { + group[serviceLevel].forEach(plan => { + availablePlans.push({ + ...plan, + groupName: groupName + }); + }); + } + }); + + // Sort plans by vCPU, then by RAM + availablePlans.sort((a, b) => { + if (parseInt(a.vcpus) !== parseInt(b.vcpus)) { + return parseInt(a.vcpus) - parseInt(b.vcpus); + } + return parseInt(a.ram) - parseInt(b.ram); + }); + + // Add plans to dropdown + availablePlans.forEach(plan => { + const option = document.createElement('option'); + option.value = JSON.stringify(plan); + option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM (CHF ${parseFloat(plan.final_price).toFixed(2)}/month)`; + this.planSelect.appendChild(option); + }); + } + + // Find best matching plan based on requirements + findBestMatchingPlan(cpus, memory, serviceLevel) { + if (!this.pricingData) return null; + + let bestMatch = null; + let bestScore = Infinity; + + // Iterate through all groups and service levels + Object.keys(this.pricingData).forEach(groupName => { + const group = this.pricingData[groupName]; + + if (group[serviceLevel]) { + group[serviceLevel].forEach(plan => { + const planCpus = parseInt(plan.vcpus); + const planMemory = parseInt(plan.ram); + + // Check if plan meets minimum requirements + if (planCpus >= cpus && planMemory >= memory) { + // Calculate efficiency score (lower is better) + const cpuOverhead = planCpus - cpus; + const memoryOverhead = planMemory - memory; + const score = cpuOverhead + memoryOverhead + plan.final_price * 0.1; + + if (score < bestScore) { + bestScore = score; + bestMatch = { + ...plan, + groupName: groupName + }; + } + } + }); + } + }); + + return bestMatch; + } + + // Update pricing with specific plan + updatePricingWithPlan(selectedPlan) { + const storage = parseInt(this.storageRange?.value || 20); + + this.showPlanDetails(selectedPlan, storage); + this.updateStatusMessage('Plan selected directly!', 'success'); + } + + // Main pricing update function + updatePricing() { + if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange) return; + + // Reset plan selection if in auto-select mode + if (!this.planSelect?.value) { + const cpus = parseInt(this.cpuRange.value); + const memory = parseInt(this.memoryRange.value); + const storage = parseInt(this.storageRange.value); + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (!serviceLevel) return; + + // Find best matching plan + const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel); + + if (matchedPlan) { + this.showPlanDetails(matchedPlan, storage); + this.updateStatusMessage('Perfect match found!', 'success'); + } else { + this.showNoMatch(); + } + } else { + // Plan is directly selected, update storage pricing + const selectedPlan = JSON.parse(this.planSelect.value); + this.updatePricingWithPlan(selectedPlan); + } + } + + // Show plan details in the UI + showPlanDetails(plan, storage) { + if (!this.selectedPlanDetails) return; + + // Show plan details section + this.planMatchStatus.style.display = 'block'; + this.selectedPlanDetails.style.display = 'block'; + if (this.noMatchFound) this.noMatchFound.style.display = 'none'; + + // Update plan information + if (this.planGroup) this.planGroup.textContent = plan.groupName; + if (this.planName) this.planName.textContent = plan.compute_plan; + if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || ''; + if (this.planCpus) this.planCpus.textContent = plan.vcpus; + if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB'; + + // Calculate pricing + const computePriceValue = parseFloat(plan.compute_plan_price); + const servicePriceValue = parseFloat(plan.sla_price); + const storagePriceValue = storage * this.storagePrice; + const totalPriceValue = computePriceValue + servicePriceValue + storagePriceValue; + + // Update pricing display + if (this.computePrice) this.computePrice.textContent = computePriceValue.toFixed(2); + if (this.servicePrice) this.servicePrice.textContent = servicePriceValue.toFixed(2); + if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2); + if (this.storageAmount) this.storageAmount.textContent = storage; + if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2); + } + + // Show no matching plan found + showNoMatch() { + if (this.planMatchStatus) this.planMatchStatus.style.display = 'none'; + if (this.selectedPlanDetails) this.selectedPlanDetails.style.display = 'none'; + if (this.noMatchFound) this.noMatchFound.style.display = 'block'; + } + + // Update status message + updateStatusMessage(message, type) { + if (!this.planMatchStatus) return; + + const iconClass = type === 'success' ? 'bi-check-circle' : 'bi-info-circle'; + const textClass = type === 'success' ? 'text-success' : ''; + const alertClass = type === 'success' ? 'alert-success' : 'alert-info'; + + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = `alert ${alertClass} mb-3`; + this.planMatchStatus.style.display = 'block'; + } + + // Show error message + showError(message) { + if (this.planMatchStatus) { + this.planMatchStatus.innerHTML = `${message}`; + this.planMatchStatus.className = 'alert alert-danger mb-3'; + this.planMatchStatus.style.display = 'block'; + } + } +} + +// Initialize calculator when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + // Only initialize if we're on an offering detail page with pricing calculator + if (document.getElementById('cpuRange')) { + new PriceCalculator(); + } +}); \ No newline at end of file diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 77b4413..92f5c28 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -4,6 +4,11 @@ {% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %} +{% block extra_js %} + + +{% endblock %} + {% block content %}
@@ -152,76 +157,153 @@
{% endif %} - +
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %} - -

Service Plans

-
- {% for group_name, service_levels in pricing_data_by_group_and_service_level.items %} -
-

- -

-
-
- {% comment %} Display group description from first available plan {% endcomment %} - {% for service_level, pricing_data in service_levels.items %} - {% if pricing_data and forloop.first %} - {% with pricing_data.0 as representative_plan %} - {% if representative_plan.compute_plan_group_description %} -

{{ representative_plan.compute_plan_group_description }}

- {% endif %} - {% endwith %} - {% endif %} - {% if forloop.first %} - {% comment %} Only show description for first service level {% endcomment %} - {% endif %} - {% endfor %} + +

Configure Your Plan

+
+
+ +
+
+
+
Customize Your Configuration
- {% for service_level, pricing_data in service_levels.items %} -
-

{{ service_level }}

- {% if pricing_data %} -
- - - - - - - - - - - - - - {% for row in pricing_data %} - - - - - - - - - - {% endfor %} - -
Compute PlanvCPUsRAM (GB)CurrencyCompute PriceService PriceTotal Price
{{ row.compute_plan }}{{ row.vcpus }}{{ row.ram }}{{ row.currency }}{{ row.compute_plan_price|floatformat:2 }}{{ row.sla_price|floatformat:2 }}{{ row.final_price|floatformat:2 }}
-
- {% else %} -

No pricing data available for {{ service_level }}.

- {% endif %} + +
+ + +
+ 1 + 32
- {% endfor %} +
+ + +
+ + +
+ 1 GB + 128 GB +
+
+ + +
+ + +
+ 10 GB + 1000 GB +
+
+ + +
+ +
+ + + + + +
+
+ + +
+ + + Selecting a plan will override the slider configuration +
- {% endfor %} + + +
+
+
+
Your Configuration
+ + +
+ + Finding best matching plan... +
+ + + + + + +
+
+
+
+
+ + + {% elif offering.plans.all %} diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index a7df1cc..68de2d4 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,9 +1,11 @@ import re import yaml +import json +from decimal import Decimal from django.shortcuts import render, get_object_or_404 from django.db.models import Q -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.template.loader import render_to_string from hub.services.models import ( ServiceOffering, @@ -17,6 +19,17 @@ from collections import defaultdict from markdownify import markdownify +def decimal_to_float(obj): + """Convert Decimal objects to float for JSON serialization""" + if isinstance(obj, Decimal): + return float(obj) + elif isinstance(obj, dict): + return {key: decimal_to_float(value) for key, value in obj.items()} + elif isinstance(obj, list): + return [decimal_to_float(item) for item in obj] + return obj + + def natural_sort_key(name): """Extract numeric part from compute plan name for natural sorting""" match = re.search(r"compute-std-(\d+)", name) @@ -84,6 +97,17 @@ def offering_detail(request, provider_slug, service_slug): service__slug=service_slug, ) + # Check if JSON pricing data is requested + if request.GET.get("pricing") == "json": + pricing_data = None + if offering.msp == "VS": + pricing_data = generate_pricing_data(offering) + if pricing_data: + # Convert Decimal objects to float for JSON serialization + pricing_data = decimal_to_float(pricing_data) + + return JsonResponse(pricing_data or {}) + # Check if Exoscale marketplace YAML is requested if request.GET.get("exo_marketplace") == "true": return generate_exoscale_marketplace_yaml(offering) From 227feb3a5527f8cc1a0e5f4bc534b5d75aedb17d Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 11:34:01 +0200 Subject: [PATCH 04/13] price comparison - cheaper is red --- hub/services/templates/services/pricelist.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/services/templates/services/pricelist.html b/hub/services/templates/services/pricelist.html index ca865ae..7173cdc 100644 --- a/hub/services/templates/services/pricelist.html +++ b/hub/services/templates/services/pricelist.html @@ -291,9 +291,9 @@ {{ comparison.amount|floatformat:2 }} {{ comparison.currency }} {% if comparison.difference > 0 %} - +{{ comparison.difference|floatformat:2 }} + +{{ comparison.difference|floatformat:2 }} {% elif comparison.difference < 0 %} - {{ comparison.difference|floatformat:2 }} + {{ comparison.difference|floatformat:2 }} {% endif %} From b32a19ffa2642710dcea01579e558e73f959aa06 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 11:36:45 +0200 Subject: [PATCH 05/13] natural sorting of compute plans in admin --- hub/services/admin/pricing.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 1077b68..19eba5f 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -2,6 +2,7 @@ Admin classes for pricing models including compute plans, storage plans, and VSHN AppCat pricing """ +import re from django.contrib import admin from django.utils.html import format_html from adminsortable2.admin import SortableAdminMixin @@ -27,6 +28,12 @@ from ..models import ( ) +def natural_sort_key(obj): + """Extract numeric parts for natural sorting""" + parts = re.split(r"(\d+)", obj.name) + return [int(part) if part.isdigit() else part for part in parts] + + class ComputePlanPriceInline(admin.TabularInline): """Inline admin for ComputePlanPrice model""" @@ -117,7 +124,7 @@ class ComputePlanResource(resources.ModelResource): @admin.register(ComputePlan) -class ComputePlansAdmin(ImportExportModelAdmin): +class ComputePlanAdmin(ImportExportModelAdmin): """Admin configuration for ComputePlan model with import/export functionality""" resource_class = ComputePlanResource @@ -133,9 +140,21 @@ class ComputePlansAdmin(ImportExportModelAdmin): ) search_fields = ("name", "cloud_provider__name", "group__name") list_filter = ("active", "cloud_provider", "group") - ordering = ("name",) inlines = [ComputePlanPriceInline] + def changelist_view(self, request, extra_context=None): + """Override changelist view to apply natural sorting""" + # Get the response from parent + response = super().changelist_view(request, extra_context) + + # If it's a TemplateResponse, we can modify the context + if hasattr(response, "context_data") and "cl" in response.context_data: + cl = response.context_data["cl"] + if hasattr(cl, "result_list"): + cl.result_list = sorted(cl.result_list, key=natural_sort_key) + + return response + def display_prices(self, obj): """Display formatted prices for the list view""" prices = obj.prices.all() From 2da6285800a3fea10116a4ed2a9c94afcfa01718 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 15:50:19 +0200 Subject: [PATCH 06/13] mass edit for compute plans --- hub/services/admin/pricing.py | 118 +++++++++++++++- .../admin/mass_update_compute_plans.html | 126 ++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 hub/services/templates/admin/mass_update_compute_plans.html diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 19eba5f..7ee0b51 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -3,8 +3,12 @@ Admin classes for pricing models including compute plans, storage plans, and VSH """ import re -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.admin import helpers from django.utils.html import format_html +from django import forms +from django.shortcuts import render +from django.http import HttpResponseRedirect from adminsortable2.admin import SortableAdminMixin from import_export.admin import ImportExportModelAdmin from import_export import resources @@ -27,6 +31,8 @@ from ..models import ( Service, ) +from ..models.base import Term + def natural_sort_key(obj): """Extract numeric parts for natural sorting""" @@ -123,6 +129,41 @@ class ComputePlanResource(resources.ModelResource): ) +class MassUpdateComputePlanForm(forms.Form): + """Form for mass updating ComputePlan fields""" + + active = forms.ChoiceField( + choices=[("", "-- No change --"), ("True", "Active"), ("False", "Inactive")], + required=False, + help_text="Set active status for selected compute plans", + ) + + term = forms.ChoiceField( + choices=[("", "-- No change --")] + Term.choices, + required=False, + help_text="Set billing term for selected compute plans", + ) + + group = forms.ModelChoiceField( + queryset=ComputePlanGroup.objects.all(), + required=False, + empty_label="-- No change --", + help_text="Set group for selected compute plans", + ) + + valid_from = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + help_text="Set valid from date for selected compute plans", + ) + + valid_to = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), + required=False, + help_text="Set valid to date for selected compute plans", + ) + + @admin.register(ComputePlan) class ComputePlanAdmin(ImportExportModelAdmin): """Admin configuration for ComputePlan model with import/export functionality""" @@ -141,6 +182,7 @@ class ComputePlanAdmin(ImportExportModelAdmin): search_fields = ("name", "cloud_provider__name", "group__name") list_filter = ("active", "cloud_provider", "group") inlines = [ComputePlanPriceInline] + actions = ["mass_update_compute_plans"] def changelist_view(self, request, extra_context=None): """Override changelist view to apply natural sorting""" @@ -164,6 +206,80 @@ class ComputePlanAdmin(ImportExportModelAdmin): display_prices.short_description = "Prices (Amount Currency)" + def mass_update_compute_plans(self, request, queryset): + """Admin action to mass update compute plan fields""" + if request.POST.get("post"): + # Process the form submission + form = MassUpdateComputePlanForm(request.POST) + if form.is_valid(): + updated_count = 0 + updated_fields = [] + + # Prepare update data + update_data = {} + + # Handle active field + if form.cleaned_data["active"]: + update_data["active"] = form.cleaned_data["active"] == "True" + updated_fields.append("active") + + # Handle term field + if form.cleaned_data["term"]: + update_data["term"] = form.cleaned_data["term"] + updated_fields.append("term") + + # Handle group field + if form.cleaned_data["group"]: + update_data["group"] = form.cleaned_data["group"] + updated_fields.append("group") + + # Handle valid_from field + if form.cleaned_data["valid_from"]: + update_data["valid_from"] = form.cleaned_data["valid_from"] + updated_fields.append("valid_from") + + # Handle valid_to field + if form.cleaned_data["valid_to"]: + update_data["valid_to"] = form.cleaned_data["valid_to"] + updated_fields.append("valid_to") + + # Perform the bulk update + if update_data: + updated_count = queryset.update(**update_data) + + # Create success message + field_list = ", ".join(updated_fields) + self.message_user( + request, + f"Successfully updated {updated_count} compute plan(s). " + f"Updated fields: {field_list}", + messages.SUCCESS, + ) + else: + self.message_user( + request, "No fields were selected for update.", messages.WARNING + ) + + return HttpResponseRedirect(request.get_full_path()) + else: + # Show the form + form = MassUpdateComputePlanForm() + + # Render the mass update template + return render( + request, + "admin/mass_update_compute_plans.html", + { + "form": form, + "queryset": queryset, + "action_checkbox_name": helpers.ACTION_CHECKBOX_NAME, + "opts": self.model._meta, + "title": f"Mass Update {queryset.count()} Compute Plans", + }, + ) + + mass_update_compute_plans.short_description = "Mass update selected compute plans" + class VSHNAppCatBaseFeeInline(admin.TabularInline): """Inline admin for VSHNAppCatBaseFee model""" diff --git a/hub/services/templates/admin/mass_update_compute_plans.html b/hub/services/templates/admin/mass_update_compute_plans.html new file mode 100644 index 0000000..df2c558 --- /dev/null +++ b/hub/services/templates/admin/mass_update_compute_plans.html @@ -0,0 +1,126 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_modify %} + +{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{{ title }}

+
+
+

You are about to update {{ queryset.count }} compute plan(s). Please select the fields you want to update:

+ + + + + +
+ {% csrf_token %} + + + {% for obj in queryset %} + + {% endfor %} + + + + +
+ +
+ {{ form.active }} + {% if form.active.help_text %} +
{{ form.active.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.term }} + {% if form.term.help_text %} +
{{ form.term.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.group }} + {% if form.group.help_text %} +
{{ form.group.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.valid_from }} + {% if form.valid_from.help_text %} +
{{ form.valid_from.help_text }}
+ {% endif %} +
+
+ +
+ +
+ {{ form.valid_to }} + {% if form.valid_to.help_text %} +
{{ form.valid_to.help_text }}
+ {% endif %} +
+
+ + +
+
+ + + Cancel + +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file From 15ede53cc31e7586843c1af3719a7a989d1847da Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 16:22:47 +0200 Subject: [PATCH 07/13] merge compute and sla price into managed service price --- hub/services/static/js/price-calculator.js | 11 +++++------ hub/services/templates/services/offering_detail.html | 10 +++------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index 2cafe7d..ef44778 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -54,8 +54,7 @@ class PriceCalculator { this.planDescription = document.getElementById('planDescription'); this.planCpus = document.getElementById('planCpus'); this.planMemory = document.getElementById('planMemory'); - this.computePrice = document.getElementById('computePrice'); - this.servicePrice = document.getElementById('servicePrice'); + this.managedServicePrice = document.getElementById('managedServicePrice'); this.storagePriceEl = document.getElementById('storagePrice'); this.storageAmount = document.getElementById('storageAmount'); this.totalPrice = document.getElementById('totalPrice'); @@ -262,7 +261,7 @@ class PriceCalculator { availablePlans.forEach(plan => { const option = document.createElement('option'); option.value = JSON.stringify(plan); - option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM (CHF ${parseFloat(plan.final_price).toFixed(2)}/month)`; + option.textContent = `${plan.compute_plan} - ${plan.vcpus} vCPUs, ${plan.ram} GB RAM`; this.planSelect.appendChild(option); }); } @@ -361,12 +360,12 @@ class PriceCalculator { // Calculate pricing const computePriceValue = parseFloat(plan.compute_plan_price); const servicePriceValue = parseFloat(plan.sla_price); + const managedServicePrice = computePriceValue + servicePriceValue; const storagePriceValue = storage * this.storagePrice; - const totalPriceValue = computePriceValue + servicePriceValue + storagePriceValue; + const totalPriceValue = managedServicePrice + storagePriceValue; // Update pricing display - if (this.computePrice) this.computePrice.textContent = computePriceValue.toFixed(2); - if (this.servicePrice) this.servicePrice.textContent = servicePriceValue.toFixed(2); + if (this.managedServicePrice) this.managedServicePrice.textContent = managedServicePrice.toFixed(2); if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2); if (this.storageAmount) this.storageAmount.textContent = storage; if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2); diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 92f5c28..3dfdd09 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -269,15 +269,11 @@
- Compute Plan - CHF 0.00 + Managed Service (incl. Compute) + CHF 0.00
- Service Price - CHF 0.00 -
-
- Storage (20 GB) + Storage - 20 GB CHF 0.00

From 21c9734fd3acd03ef1fd5890813f37bc54850b3d Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 16:30:28 +0200 Subject: [PATCH 08/13] add service level detail infos --- hub/services/static/js/price-calculator.js | 5 +++++ .../templates/services/offering_detail.html | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index ef44778..a8dd8ff 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -54,6 +54,7 @@ class PriceCalculator { this.planDescription = document.getElementById('planDescription'); this.planCpus = document.getElementById('planCpus'); this.planMemory = document.getElementById('planMemory'); + this.planServiceLevel = document.getElementById('planServiceLevel'); this.managedServicePrice = document.getElementById('managedServicePrice'); this.storagePriceEl = document.getElementById('storagePrice'); this.storageAmount = document.getElementById('storageAmount'); @@ -350,12 +351,16 @@ class PriceCalculator { this.selectedPlanDetails.style.display = 'block'; if (this.noMatchFound) this.noMatchFound.style.display = 'none'; + // Get current service level + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value || 'Best Effort'; + // Update plan information if (this.planGroup) this.planGroup.textContent = plan.groupName; if (this.planName) this.planName.textContent = plan.compute_plan; if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || ''; if (this.planCpus) this.planCpus.textContent = plan.vcpus; if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB'; + if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; // Calculate pricing const computePriceValue = parseFloat(plan.compute_plan_price); diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 3dfdd09..ee34d36 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -256,14 +256,20 @@
-
+
vCPUs
-
+
Memory
+
+ Service Level +
+ +
+
@@ -281,6 +287,10 @@ Total Monthly Price CHF 0.00
+ + + Monthly pricing based on 30 days (720 hours). Billing is conducted per hour. +
From 206e46aa6a6643f60a1ad4f43997df213101e7eb Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 16:39:16 +0200 Subject: [PATCH 09/13] wording improvements --- .../templates/services/offering_detail.html | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index ee34d36..4fc2fbd 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -161,15 +161,13 @@
{% if offering.msp == "VS" and pricing_data_by_group_and_service_level %} -

Configure Your Plan

+

Choose your Plan

-
-
Customize Your Configuration
- +
@@ -237,7 +236,7 @@
-
Your Configuration
+
Your Plan
@@ -358,7 +357,7 @@ {% endif %} {% if offering.plans.exists %} -
+

I'm interested in a plan

From c8c224cfb83495a8100d91c39de66bebff098e93 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 16:58:47 +0200 Subject: [PATCH 10/13] order button implementation to send message --- hub/services/static/js/price-calculator.js | 86 +++++++++++++++++++ .../services/embedded_contact_form.html | 19 +++- .../templates/services/offering_detail.html | 14 ++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index a8dd8ff..e5dde85 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -8,6 +8,7 @@ class PriceCalculator { this.pricingData = null; this.storagePrice = 0.15; // CHF per GB per month this.currentOffering = null; + this.selectedConfiguration = null; this.init(); } @@ -29,6 +30,9 @@ class PriceCalculator { if (this.currentOffering) { this.loadPricingData(); } + + // Setup order button click handler + this.setupOrderButton(); } // Initialize DOM element references @@ -59,6 +63,77 @@ class PriceCalculator { this.storagePriceEl = document.getElementById('storagePrice'); this.storageAmount = document.getElementById('storageAmount'); this.totalPrice = document.getElementById('totalPrice'); + + // Order button + this.orderButton = document.querySelector('a[href="#order-form"]'); + } + + // Setup order button click handler + setupOrderButton() { + if (this.orderButton) { + this.orderButton.addEventListener('click', (e) => { + e.preventDefault(); + this.handleOrderClick(); + }); + } + } + + // Handle order button click + handleOrderClick() { + if (this.selectedConfiguration) { + // Pre-fill the contact form with configuration details + this.prefillContactForm(); + + // Scroll to the contact form + const contactForm = document.getElementById('order-form'); + if (contactForm) { + contactForm.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + } + + // Pre-fill contact form with selected configuration + prefillContactForm() { + if (!this.selectedConfiguration) return; + + const config = this.selectedConfiguration; + + // Create configuration summary message + const configMessage = this.generateConfigurationMessage(config); + + // Find and fill the message textarea in the contact form + const messageField = document.querySelector('#order-form textarea[name="message"]'); + if (messageField) { + messageField.value = configMessage; + } + + // Store configuration details in hidden field + const detailsField = document.querySelector('#order-form input[name="details"]'); + if (detailsField) { + detailsField.value = JSON.stringify({ + plan: config.planName, + vcpus: config.vcpus, + memory: config.memory, + storage: config.storage, + serviceLevel: config.serviceLevel, + totalPrice: config.totalPrice + }); + } + } + + // Generate human-readable configuration message + generateConfigurationMessage(config) { + return `I would like to order the following configuration: + +Plan: ${config.planName} (${config.planGroup}) +vCPUs: ${config.vcpus} +Memory: ${config.memory} GB +Storage: ${config.storage} GB +Service Level: ${config.serviceLevel} + +Total Monthly Price: CHF ${config.totalPrice} + +Please contact me with next steps for ordering this configuration.`; } // Load pricing data from API endpoint @@ -374,6 +449,17 @@ class PriceCalculator { if (this.storagePriceEl) this.storagePriceEl.textContent = storagePriceValue.toFixed(2); if (this.storageAmount) this.storageAmount.textContent = storage; if (this.totalPrice) this.totalPrice.textContent = totalPriceValue.toFixed(2); + + // Store current configuration for order button + this.selectedConfiguration = { + planName: plan.compute_plan, + planGroup: plan.groupName, + vcpus: plan.vcpus, + memory: plan.ram, + storage: storage, + serviceLevel: serviceLevel, + totalPrice: totalPriceValue.toFixed(2) + }; } // Show no matching plan found diff --git a/hub/services/templates/services/embedded_contact_form.html b/hub/services/templates/services/embedded_contact_form.html index 660be51..46a144f 100644 --- a/hub/services/templates/services/embedded_contact_form.html +++ b/hub/services/templates/services/embedded_contact_form.html @@ -73,14 +73,29 @@ {% endif %}
- + {{ form.message|addclass:"form-control" }} {% if form.message.errors %}
{{ form.message.errors }}
{% endif %} + {% if source == "Configuration Order" %} + Your selected configuration will be automatically filled here when you click "Order This Configuration". + {% endif %}
- +
\ No newline at end of file diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 4fc2fbd..2962d66 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -306,10 +306,20 @@ + + +
+

Order Your Configuration

+
+
+ {% embedded_contact_form source="Configuration Order" service=offering.service offering_id=offering.id %} +
+
+
{% elif offering.plans.all %}

Available Plans

@@ -356,7 +366,7 @@
{% endif %} - {% if offering.plans.exists %} + {% if offering.plans.exists and not pricing_data_by_group_and_service_level %}

I'm interested in a plan

From 8bb893036179a195f4c3098926bff08edddb982a Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 17:06:32 +0200 Subject: [PATCH 11/13] retrieve storage price from db --- hub/services/static/js/price-calculator.js | 30 +++++++++++++++++++--- hub/services/views/offerings.py | 19 +++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index e5dde85..f5a7726 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -6,7 +6,7 @@ class PriceCalculator { constructor() { this.pricingData = null; - this.storagePrice = 0.15; // CHF per GB per month + this.storagePrice = null; this.currentOffering = null; this.selectedConfiguration = null; this.init(); @@ -145,6 +145,10 @@ Please contact me with next steps for ordering this configuration.`; } this.pricingData = await response.json(); + + // Extract storage price from the first available plan + this.extractStoragePrice(); + this.setupEventListeners(); this.populatePlanDropdown(); this.updatePricing(); @@ -154,6 +158,23 @@ Please contact me with next steps for ordering this configuration.`; } } + // Extract storage price from pricing data + extractStoragePrice() { + if (!this.pricingData) return; + + // Find the first plan with storage pricing data + for (const groupName of Object.keys(this.pricingData)) { + const group = this.pricingData[groupName]; + for (const serviceLevel of Object.keys(group)) { + const plans = group[serviceLevel]; + if (plans.length > 0 && plans[0].storage_price !== undefined) { + this.storagePrice = parseFloat(plans[0].storage_price); + return; + } + } + } + } + // Setup event listeners for calculator controls setupEventListeners() { if (!this.cpuRange || !this.memoryRange || !this.storageRange) return; @@ -437,11 +458,14 @@ Please contact me with next steps for ordering this configuration.`; if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB'; if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; - // Calculate pricing + // Calculate pricing using storage price from the plan data const computePriceValue = parseFloat(plan.compute_plan_price); const servicePriceValue = parseFloat(plan.sla_price); const managedServicePrice = computePriceValue + servicePriceValue; - const storagePriceValue = storage * this.storagePrice; + + // Use storage price from plan data or fallback to instance variable + const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; + const storagePriceValue = storage * storageUnitPrice; const totalPriceValue = managedServicePrice + storagePriceValue; // Update pricing display diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 68de2d4..3c71b39 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -1,6 +1,5 @@ import re import yaml -import json from decimal import Decimal from django.shortcuts import render, get_object_or_404 @@ -14,6 +13,7 @@ from hub.services.models import ( Service, ComputePlan, VSHNAppCatPrice, + StoragePlan, ) from collections import defaultdict from markdownify import markdownify @@ -239,6 +239,22 @@ def generate_pricing_data(offering): ), ) + # Fetch storage plans for this cloud provider + storage_plans = ( + StoragePlan.objects.filter(cloud_provider=offering.cloud_provider) + .prefetch_related("prices") + .order_by("name") + ) + + # Get default storage pricing (use first available storage plan) + storage_price_data = {} + if storage_plans.exists(): + default_storage_plan = storage_plans.first() + for currency in ["CHF", "EUR", "USD"]: # Add currencies as needed + price = default_storage_plan.get_price(currency) + if price is not None: + storage_price_data[currency] = price + # Fetch pricing for this specific service try: appcat_price = ( @@ -352,6 +368,7 @@ def generate_pricing_data(offering): "compute_plan_price": compute_plan_price, "sla_price": sla_price, "final_price": final_price, + "storage_price": storage_price_data.get(currency, 0), } ) From 6ad8b9aa4993888e2298911b41d1c4cf3f2d145e Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 17:39:53 +0200 Subject: [PATCH 12/13] introduce support for instances in price calculator --- hub/services/static/js/price-calculator.js | 119 +++++++++++++++--- .../templates/services/offering_detail.html | 38 ++++-- hub/services/views/offerings.py | 2 + 3 files changed, 134 insertions(+), 25 deletions(-) diff --git a/hub/services/static/js/price-calculator.js b/hub/services/static/js/price-calculator.js index f5a7726..a65a9f2 100644 --- a/hub/services/static/js/price-calculator.js +++ b/hub/services/static/js/price-calculator.js @@ -9,6 +9,7 @@ class PriceCalculator { this.storagePrice = null; this.currentOffering = null; this.selectedConfiguration = null; + this.replicaInfo = null; this.init(); } @@ -41,9 +42,11 @@ class PriceCalculator { this.cpuRange = document.getElementById('cpuRange'); this.memoryRange = document.getElementById('memoryRange'); this.storageRange = document.getElementById('storageRange'); + this.instancesRange = document.getElementById('instancesRange'); this.cpuValue = document.getElementById('cpuValue'); this.memoryValue = document.getElementById('memoryValue'); this.storageValue = document.getElementById('storageValue'); + this.instancesValue = document.getElementById('instancesValue'); this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); this.planSelect = document.getElementById('planSelect'); @@ -58,6 +61,7 @@ class PriceCalculator { this.planDescription = document.getElementById('planDescription'); this.planCpus = document.getElementById('planCpus'); this.planMemory = document.getElementById('planMemory'); + this.planInstances = document.getElementById('planInstances'); this.planServiceLevel = document.getElementById('planServiceLevel'); this.managedServicePrice = document.getElementById('managedServicePrice'); this.storagePriceEl = document.getElementById('storagePrice'); @@ -68,6 +72,41 @@ class PriceCalculator { this.orderButton = document.querySelector('a[href="#order-form"]'); } + // Update slider display values (min/max text below sliders) + updateSliderDisplayValues() { + // Update CPU slider display + if (this.cpuRange) { + const cpuMinDisplay = document.getElementById('cpuMinDisplay'); + const cpuMaxDisplay = document.getElementById('cpuMaxDisplay'); + if (cpuMinDisplay) cpuMinDisplay.textContent = this.cpuRange.min; + if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.cpuRange.max; + } + + // Update Memory slider display + if (this.memoryRange) { + const memoryMinDisplay = document.getElementById('memoryMinDisplay'); + const memoryMaxDisplay = document.getElementById('memoryMaxDisplay'); + if (memoryMinDisplay) memoryMinDisplay.textContent = this.memoryRange.min + ' GB'; + if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.memoryRange.max + ' GB'; + } + + // Update Storage slider display + if (this.storageRange) { + const storageMinDisplay = document.getElementById('storageMinDisplay'); + const storageMaxDisplay = document.getElementById('storageMaxDisplay'); + if (storageMinDisplay) storageMinDisplay.textContent = this.storageRange.min + ' GB'; + if (storageMaxDisplay) storageMaxDisplay.textContent = this.storageRange.max + ' GB'; + } + + // Update Instances slider display + if (this.instancesRange) { + const instancesMinDisplay = document.getElementById('instancesMinDisplay'); + const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); + if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; + if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; + } + } + // Setup order button click handler setupOrderButton() { if (this.orderButton) { @@ -115,6 +154,7 @@ class PriceCalculator { vcpus: config.vcpus, memory: config.memory, storage: config.storage, + instances: config.instances, serviceLevel: config.serviceLevel, totalPrice: config.totalPrice }); @@ -129,6 +169,7 @@ Plan: ${config.planName} (${config.planGroup}) vCPUs: ${config.vcpus} Memory: ${config.memory} GB Storage: ${config.storage} GB +Instances: ${config.instances} Service Level: ${config.serviceLevel} Total Monthly Price: CHF ${config.totalPrice} @@ -158,17 +199,21 @@ Please contact me with next steps for ordering this configuration.`; } } - // Extract storage price from pricing data + // Extract replica information and storage price from pricing data extractStoragePrice() { if (!this.pricingData) return; - // Find the first plan with storage pricing data + // Find the first plan with storage pricing data and replica info for (const groupName of Object.keys(this.pricingData)) { const group = this.pricingData[groupName]; for (const serviceLevel of Object.keys(group)) { const plans = group[serviceLevel]; if (plans.length > 0 && plans[0].storage_price !== undefined) { this.storagePrice = parseFloat(plans[0].storage_price); + this.replicaInfo = { + ha_replica_min: plans[0].ha_replica_min || 1, + ha_replica_max: plans[0].ha_replica_max || 1 + }; return; } } @@ -177,7 +222,7 @@ Please contact me with next steps for ordering this configuration.`; // Setup event listeners for calculator controls setupEventListeners() { - if (!this.cpuRange || !this.memoryRange || !this.storageRange) return; + if (!this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; // Setup service levels based on available data this.setupServiceLevels(); @@ -198,9 +243,15 @@ Please contact me with next steps for ordering this configuration.`; this.updatePricing(); }); + this.instancesRange.addEventListener('input', () => { + this.instancesValue.textContent = this.instancesRange.value; + this.updatePricing(); + }); + // Service level change listeners this.serviceLevelInputs.forEach(input => { input.addEventListener('change', () => { + this.updateInstancesSlider(); this.populatePlanDropdown(); this.updatePricing(); }); @@ -224,6 +275,39 @@ Please contact me with next steps for ordering this configuration.`; } }); } + + // Initialize instances slider + this.updateInstancesSlider(); + } + + // Update instances slider based on service level and replica info + updateInstancesSlider() { + if (!this.instancesRange || !this.replicaInfo) return; + + const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; + + if (serviceLevel === 'Guaranteed Availability') { + // For GA, min is ha_replica_min + this.instancesRange.min = this.replicaInfo.ha_replica_min; + this.instancesRange.value = Math.max(this.instancesRange.value, this.replicaInfo.ha_replica_min); + } else { + // For BE, min is 1 + this.instancesRange.min = 1; + this.instancesRange.value = Math.max(this.instancesRange.value, 1); + } + + // Set max to ha_replica_max + this.instancesRange.max = this.replicaInfo.ha_replica_max; + + // Update display value + this.instancesValue.textContent = this.instancesRange.value; + + // Update the min/max display under the slider using direct IDs + const instancesMinDisplay = document.getElementById('instancesMinDisplay'); + const instancesMaxDisplay = document.getElementById('instancesMaxDisplay'); + + if (instancesMinDisplay) instancesMinDisplay.textContent = this.instancesRange.min; + if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.instancesRange.max; } // Setup service levels dynamically from pricing data @@ -270,6 +354,7 @@ Please contact me with next steps for ordering this configuration.`; // Add event listener input.addEventListener('change', () => { + this.updateInstancesSlider(); this.populatePlanDropdown(); this.updatePricing(); }); @@ -281,7 +366,7 @@ Please contact me with next steps for ordering this configuration.`; // Update the serviceLevelInputs reference this.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]'); - // Calculate and set slider maximums based on available plans + // Calculate and set slider maximums based on available plans - this will call updateSliderDisplayValues() this.updateSliderMaximums(); } @@ -309,17 +394,14 @@ Please contact me with next steps for ordering this configuration.`; // Set slider maximums with some padding if (maxCpus > 0) { this.cpuRange.max = Math.ceil(maxCpus); - // Update the max display under the slider - const cpuMaxDisplay = this.cpuRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child'); - if (cpuMaxDisplay) cpuMaxDisplay.textContent = Math.ceil(maxCpus); } if (maxMemory > 0) { this.memoryRange.max = Math.ceil(maxMemory); - // Update the max display under the slider - const memoryMaxDisplay = this.memoryRange.parentElement.querySelector('.d-flex.justify-content-between .text-muted span:last-child'); - if (memoryMaxDisplay) memoryMaxDisplay.textContent = Math.ceil(maxMemory) + ' GB'; } + + // Update display values after changing min/max - moved to end and call explicitly + this.updateSliderDisplayValues(); } // Populate plan dropdown based on selected service level @@ -404,20 +486,22 @@ Please contact me with next steps for ordering this configuration.`; // Update pricing with specific plan updatePricingWithPlan(selectedPlan) { const storage = parseInt(this.storageRange?.value || 20); + const instances = parseInt(this.instancesRange?.value || 1); - this.showPlanDetails(selectedPlan, storage); + this.showPlanDetails(selectedPlan, storage, instances); this.updateStatusMessage('Plan selected directly!', 'success'); } // Main pricing update function updatePricing() { - if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange) return; + if (!this.pricingData || !this.cpuRange || !this.memoryRange || !this.storageRange || !this.instancesRange) return; // Reset plan selection if in auto-select mode if (!this.planSelect?.value) { const cpus = parseInt(this.cpuRange.value); const memory = parseInt(this.memoryRange.value); const storage = parseInt(this.storageRange.value); + const instances = parseInt(this.instancesRange.value); const serviceLevel = document.querySelector('input[name="serviceLevel"]:checked')?.value; if (!serviceLevel) return; @@ -426,7 +510,7 @@ Please contact me with next steps for ordering this configuration.`; const matchedPlan = this.findBestMatchingPlan(cpus, memory, serviceLevel); if (matchedPlan) { - this.showPlanDetails(matchedPlan, storage); + this.showPlanDetails(matchedPlan, storage, instances); this.updateStatusMessage('Perfect match found!', 'success'); } else { this.showNoMatch(); @@ -439,7 +523,7 @@ Please contact me with next steps for ordering this configuration.`; } // Show plan details in the UI - showPlanDetails(plan, storage) { + showPlanDetails(plan, storage, instances) { if (!this.selectedPlanDetails) return; // Show plan details section @@ -456,16 +540,18 @@ Please contact me with next steps for ordering this configuration.`; if (this.planDescription) this.planDescription.textContent = plan.compute_plan_group_description || ''; if (this.planCpus) this.planCpus.textContent = plan.vcpus; if (this.planMemory) this.planMemory.textContent = plan.ram + ' GB'; + if (this.planInstances) this.planInstances.textContent = instances; if (this.planServiceLevel) this.planServiceLevel.textContent = serviceLevel; // Calculate pricing using storage price from the plan data const computePriceValue = parseFloat(plan.compute_plan_price); const servicePriceValue = parseFloat(plan.sla_price); - const managedServicePrice = computePriceValue + servicePriceValue; + const managedServicePricePerInstance = computePriceValue + servicePriceValue; + const managedServicePrice = managedServicePricePerInstance * instances; // Use storage price from plan data or fallback to instance variable const storageUnitPrice = plan.storage_price !== undefined ? parseFloat(plan.storage_price) : this.storagePrice; - const storagePriceValue = storage * storageUnitPrice; + const storagePriceValue = storage * storageUnitPrice * instances; const totalPriceValue = managedServicePrice + storagePriceValue; // Update pricing display @@ -481,6 +567,7 @@ Please contact me with next steps for ordering this configuration.`; vcpus: plan.vcpus, memory: plan.ram, storage: storage, + instances: instances, serviceLevel: serviceLevel, totalPrice: totalPriceValue.toFixed(2) }; diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 2962d66..37c87fa 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -176,8 +176,8 @@
- 1 - 32 + 1 + 32
@@ -189,8 +189,8 @@
- 1 GB - 128 GB + 1 GB + 128 GB
@@ -202,8 +202,21 @@
- 10 GB - 1000 GB + 10 GB + 1000 GB +
+
+ + +
+ + +
+ 1 + 1
@@ -255,15 +268,22 @@
-
+
vCPUs
-
+
Memory
-
+
+ Instances +
+
+
+ +
+
Service Level
diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 3c71b39..23ba569 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -369,6 +369,8 @@ def generate_pricing_data(offering): "sla_price": sla_price, "final_price": final_price, "storage_price": storage_price_data.get(currency, 0), + "ha_replica_min": appcat_price.ha_replica_min, + "ha_replica_max": appcat_price.ha_replica_max, } ) From 01d35a461b4e9d2f46a0bace7db14a782c20c865 Mon Sep 17 00:00:00 2001 From: Tobias Brunner Date: Wed, 4 Jun 2025 17:54:33 +0200 Subject: [PATCH 13/13] allow to disable appcat price calculator --- hub/services/admin/pricing.py | 1 + ...atprice_public_display_enabled_and_more.py | 28 +++++++++++++++++++ hub/services/models/pricing.py | 5 ++++ .../templates/services/offering_detail.html | 4 +-- hub/services/views/offerings.py | 14 +++++++++- 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py diff --git a/hub/services/admin/pricing.py b/hub/services/admin/pricing.py index 7ee0b51..6da4852 100644 --- a/hub/services/admin/pricing.py +++ b/hub/services/admin/pricing.py @@ -326,6 +326,7 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin): "discount_model", "admin_display_base_fees", "admin_display_unit_rates", + "public_display_enabled", ) list_filter = ("variable_unit", "service", "discount_model") search_fields = ("service__name",) diff --git a/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py b/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py new file mode 100644 index 0000000..6089f11 --- /dev/null +++ b/hub/services/migrations/0033_vshnappcatprice_public_display_enabled_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2 on 2025-06-04 15:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("services", "0032_externalpriceplans_service_level"), + ] + + operations = [ + migrations.AddField( + model_name="vshnappcatprice", + name="public_display_enabled", + field=models.BooleanField( + default=True, + help_text="Enable public display of price calculator on offering detail page", + ), + ), + migrations.AlterField( + model_name="externalpriceplans", + name="compare_to", + field=models.ManyToManyField( + blank=True, related_name="external_prices", to="services.computeplan" + ), + ), + ] diff --git a/hub/services/models/pricing.py b/hub/services/models/pricing.py index dc557ea..42b2778 100644 --- a/hub/services/models/pricing.py +++ b/hub/services/models/pricing.py @@ -310,6 +310,11 @@ class VSHNAppCatPrice(models.Model): default=1, help_text="Maximum supported replicas" ) + public_display_enabled = models.BooleanField( + default=True, + help_text="Enable public display of price calculator on offering detail page", + ) + valid_from = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True) diff --git a/hub/services/templates/services/offering_detail.html b/hub/services/templates/services/offering_detail.html index 37c87fa..134dc2f 100644 --- a/hub/services/templates/services/offering_detail.html +++ b/hub/services/templates/services/offering_detail.html @@ -61,7 +61,7 @@ - + @@ -159,7 +159,7 @@
- {% if offering.msp == "VS" and pricing_data_by_group_and_service_level %} + {% if offering.msp == "VS" and price_calculator_enabled and pricing_data_by_group_and_service_level %}

Choose your Plan

diff --git a/hub/services/views/offerings.py b/hub/services/views/offerings.py index 23ba569..4730b4a 100644 --- a/hub/services/views/offerings.py +++ b/hub/services/views/offerings.py @@ -113,14 +113,26 @@ def offering_detail(request, provider_slug, service_slug): return generate_exoscale_marketplace_yaml(offering) pricing_data_by_group_and_service_level = None + price_calculator_enabled = False # Generate pricing data for VSHN offerings if offering.msp == "VS": - pricing_data_by_group_and_service_level = generate_pricing_data(offering) + try: + appcat_price = offering.service.vshn_appcat_price.get() + price_calculator_enabled = appcat_price.public_display_enabled + + # Only generate pricing data if public display is enabled + if price_calculator_enabled: + pricing_data_by_group_and_service_level = generate_pricing_data( + offering + ) + except VSHNAppCatPrice.DoesNotExist: + pass context = { "offering": offering, "pricing_data_by_group_and_service_level": pricing_data_by_group_and_service_level, + "price_calculator_enabled": price_calculator_enabled, } return render(request, "services/offering_detail.html", context)