Compare commits

...

42 commits

Author SHA1 Message Date
6c884b7804
allow to disable redirect
All checks were successful
Build and Deploy / build (push) Successful in 1m11s
Django Tests / test (push) Successful in 1m10s
Build and Deploy / deploy (push) Successful in 4s
2025-07-16 10:18:18 +02:00
a4a0fa4f8b
introduce django compressor 2025-07-15 18:24:28 +02:00
67e1b4cab1
refactor price calculator into multiple files 2025-07-15 17:31:14 +02:00
33e8f2152a
rename instances into replicas 2025-07-11 16:58:30 +02:00
ba64d24c7a
better display of mandatory addons in calculator 2025-07-11 16:52:03 +02:00
2178427cf3
remove optional from addons title 2025-07-11 16:21:20 +02:00
327fb9a3aa
better display of service logos in partner detail page 2025-07-11 15:00:22 +02:00
7428475ffc
remove the word consulting from partners 2025-07-11 14:55:17 +02:00
bcaf97752b
improved logo display for SVG logos 2025-07-11 14:53:37 +02:00
53e87ae1a2
small adjustments to partner listing 2025-07-11 11:27:57 +02:00
4bc6e7d354
add time to datePublished 2025-07-11 11:19:02 +02:00
c123e74c1f
several adjustments to support different partner categories 2025-07-11 11:14:43 +02:00
dd25ea9d10
better category badge on list view 2025-07-11 11:02:14 +02:00
c6b50da971
partner categories 2025-07-11 10:52:44 +02:00
83504f6b7c
support fractional cpu and memory in calc and set better defaults 2025-07-11 10:13:25 +02:00
c0c27cd056
add LD data for articles 2025-07-11 09:51:28 +02:00
08b8175574
dont display special LD data on services for now 2025-07-11 09:41:27 +02:00
48affa7000
only show offer when available 2025-07-11 09:35:57 +02:00
a6d9ee11b0
better wording for price disclaimer 2025-07-11 08:36:53 +02:00
cece57a567
display discount model details in overview 2025-07-10 15:41:15 +02:00
e120d6f6e4
add pricing disclaimer 2025-07-10 15:34:34 +02:00
fdc591624d
price model is documented in the wiki 2025-07-10 14:45:58 +02:00
d3bd126782
redesign related articles cards 2025-07-08 16:29:02 +02:00
166f518db0
rework prose editor configuration 2025-07-08 16:24:28 +02:00
6b689704b0
add support for article specific og image 2025-07-08 16:02:12 +02:00
1d190fe182
less margin in p in li 2025-07-08 15:32:03 +02:00
9b88ce98fa
upgrade prose editor 2025-07-08 15:25:57 +02:00
a2489a7651
add support for svg images in library 2 2025-07-08 15:24:56 +02:00
ff3a09d30c
add support for svg images in library 2025-07-08 15:24:47 +02:00
2c217939b0
nice image selection from library 2 2025-07-08 11:59:43 +02:00
7e46dc71ec
nice image selection from library 2025-07-08 11:59:19 +02:00
7319709749
make related modal work 2025-07-08 11:50:33 +02:00
6a73beca84
add missing migration 2025-07-08 11:45:18 +02:00
272e068a12
remove old image fields - migrated to library 2025-07-08 11:45:13 +02:00
93eb45930a
margin top in article h2 header 2025-07-07 17:36:30 +02:00
b3c1b00ae8
ensure image from gallery is displayed 2025-07-07 17:30:16 +02:00
444bfa339a
better price model description 2025-07-07 17:18:42 +02:00
1c0aafaca1
small improvements to pricelist group view 2025-07-07 14:57:56 +02:00
46d32cb1a0
show active filters as list 2025-07-07 14:32:56 +02:00
381f2f09e6
improve initial page load filter selection 2025-07-07 14:30:05 +02:00
19d9dff83e
show price difference in percentage 2025-07-07 14:02:03 +02:00
89149198cc
move articles menu item to before about 2025-07-07 13:18:07 +02:00
57 changed files with 2996 additions and 2269 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ wheels/
media/
deployment/secret.yaml
*.json
static/

View file

@ -35,6 +35,6 @@ RUN uv sync --frozen \
&& chgrp -R 0 /app \
&& chmod -R g=u /app \
&& chmod g+w /app/config/caddy/Caddyfile \
&& SECRET_KEY= python -m hub collectstatic --noinput
&& SECRET_KEY=dummy python -m hub build_assets --force
CMD ["/usr/local/bin/runhub.sh"]

176
SVG_SUPPORT.md Normal file
View file

@ -0,0 +1,176 @@
# SVG Support in Image Library
## Overview
The Image Library now supports SVG (Scalable Vector Graphics) files alongside traditional raster image formats. This enhancement allows you to upload, manage, and display SVG images with the same ease as JPEG, PNG, and other image formats.
## Supported Formats
The Image Library now supports:
- **Raster Images**: JPEG, PNG, GIF, WebP, BMP, TIFF
- **Vector Images**: SVG
## Features
### 1. SVG File Validation
- SVG files are validated to ensure they contain valid XML structure
- File size limits apply (max 1MB by default)
- MIME type detection for proper handling
### 2. Automatic Dimension Detection
- SVG dimensions are extracted from `width` and `height` attributes
- Falls back to `viewBox` if width/height attributes are missing
- Provides sensible defaults (100x100) if dimensions cannot be determined
### 3. Proper Rendering
- **Template Tags**: SVG images use `<object>` tags for optimal rendering
- **Admin Interface**: SVG thumbnails display correctly in the admin
- **Form Widgets**: SVG previews work in form interfaces
### 4. Template Tag Support
The `image_library_img` template tag automatically handles SVG files:
```html
<!-- This will render an SVG using <object> tag or <img> tag for raster images -->
{% image_library_img "my-svg-logo" css_class="logo" %}
<!-- Output for SVG files -->
<object data="/media/image_library/my-svg-logo.svg" type="image/svg+xml" alt="My SVG Logo" class="logo">
<img src="/media/image_library/my-svg-logo.svg" alt="My SVG Logo" class="logo"/>
</object>
<!-- Output for raster images -->
<img src="/media/image_library/my-raster-image.jpg" alt="My Raster Image" class="logo"/>
```
## Usage
### 1. Uploading SVG Images
1. Go to the Django admin interface
2. Navigate to **Services > Image Library**
3. Click **Add Image**
4. Choose your SVG file in the image field
5. Fill in the required fields (name, alt text, etc.)
6. Save the image
### 2. Using SVG Images in Templates
```html
<!-- Load the template tag -->
{% load image_library %}
<!-- Use SVG images just like any other image -->
{% image_library_img "my-logo" css_class="img-fluid" %}
{% image_library_img "my-icon" css_class="icon" width="24" height="24" %}
<!-- Get SVG URL -->
{% image_library_url "my-logo" %}
```
### 3. Checking if an Image is SVG
In Python code:
```python
from hub.services.models.images import ImageLibrary
image = ImageLibrary.objects.get(slug="my-image")
if image.is_svg():
print("This is an SVG image")
print(f"MIME type: {image.get_mime_type()}")
```
## Migration
### Existing Images
All existing raster images continue to work without any changes. The migration is backward compatible.
### Updating Image Properties
If you want to update image properties for all existing images:
```bash
uv run --extra dev manage.py update_image_properties
```
## Benefits
1. **Scalability**: SVG images scale perfectly at any size
2. **Performance**: SVG files are typically smaller than high-resolution raster images
3. **Flexibility**: SVG images can be styled with CSS
4. **Accessibility**: SVG images provide better accessibility options
5. **Consistency**: All images are managed through the same unified interface
## Technical Details
### Model Changes
- Changed `ImageField` to `FileField` with custom validation
- Added `is_svg()` method to check if file is SVG
- Added `get_mime_type()` method to determine file type
- Enhanced `_update_image_properties()` to handle SVG dimensions
### Validation
- SVG files are validated as valid XML
- File size limits are enforced
- MIME type checking ensures only valid image/SVG files are accepted
### Rendering
- SVG files use `<object>` tags for optimal rendering
- Fallback `<img>` tags are provided for compatibility
- Admin interface properly displays SVG thumbnails
## File Size Considerations
SVG files are typically much smaller than equivalent raster images:
- **Simple logos**: 1-5KB
- **Complex illustrations**: 10-50KB
- **Very complex graphics**: 100KB+
This makes SVG files ideal for:
- Logos and brand assets
- Icons and symbols
- Simple illustrations
- Graphics that need to scale
## Browser Support
SVG support is excellent across all modern browsers:
- Chrome: Full support
- Firefox: Full support
- Safari: Full support
- Edge: Full support
- Internet Explorer 9+: Full support
## Best Practices
1. **Use SVG for**:
- Logos and brand assets
- Icons and symbols
- Simple illustrations
- Graphics that need to scale
2. **Use raster images for**:
- Photographs
- Complex graphics with many colors
- Images from external sources
3. **Optimize SVG files**:
- Remove unnecessary metadata
- Use simple shapes when possible
- Consider using SVG optimization tools
## Troubleshooting
### SVG Not Displaying
- Check that the SVG file is valid XML
- Ensure the file has proper width/height or viewBox attributes
- Verify the file size is under the limit (1MB)
### Wrong Dimensions
- SVG dimensions are extracted from width/height attributes or viewBox
- If dimensions appear wrong, check the SVG source
- Use the `update_image_properties` command to refresh dimensions
### Upload Errors
- Ensure the file is valid SVG format
- Check file size limits
- Verify the file contains valid XML structure

View file

@ -6,12 +6,11 @@ from urllib.parse import urlparse
class PrimaryDomainRedirectMiddleware:
def __init__(self, get_response):
self.get_response = get_response
# Parse the primary hostname from WEBSITE_URL
self.primary_host = urlparse(settings.WEBSITE_URL).netloc
self.disable_redirect = settings.DISABLE_REDIRECT
def __call__(self, request):
# Skip redirects in DEBUG mode
if settings.DEBUG:
if settings.DEBUG or self.disable_redirect:
return self.get_response(request)
# Check if the host is different from the primary host

View file

@ -8,4 +8,5 @@ from .images import *
from .leads import *
from .pricing import *
from .providers import *
from .widgets import *
from .services import *

View file

@ -7,8 +7,8 @@ from django.utils.html import format_html
from django import forms
from django.core.exceptions import ValidationError
from ..models import Article
from .widgets import ImageLibraryWidget
class ArticleAdminForm(forms.ModelForm):
@ -17,6 +17,9 @@ class ArticleAdminForm(forms.ModelForm):
class Meta:
model = Article
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
def clean_title(self):
"""Validate title length"""
@ -66,11 +69,8 @@ class ArticleAdmin(admin.ModelAdmin):
(
"Images",
{
"fields": (
"image_library",
"image",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
"fields": ("image_library", "og_image"),
"description": "Select an image from the Image Library and optionally upload a specific Open Graph image for social sharing.",
},
),
(

View file

@ -72,6 +72,8 @@ class ImageLibraryAdmin(admin.ModelAdmin):
Display small thumbnail in list view.
"""
if obj.image:
# Use img tag for all images in list view to maintain clickability
# SVG files will still display correctly with img tag
return format_html(
'<img src="{}" width="50" height="50" style="object-fit: cover; border-radius: 4px;" />',
obj.image.url,
@ -85,10 +87,23 @@ class ImageLibraryAdmin(admin.ModelAdmin):
Display larger preview in detail view.
"""
if obj.image:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
obj.image.url,
)
if obj.is_svg():
# For SVG files in detail view, use object tag for better rendering
# This is only for display, not for clickable elements
return format_html(
'<div style="pointer-events: none;">'
'<object data="{}" type="image/svg+xml" style="max-width: 300px; max-height: 300px; border-radius: 4px; background: #f5f5f5;">'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />'
"</object>"
"</div>",
obj.image.url,
obj.image.url,
)
else:
return format_html(
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 4px;" />',
obj.image.url,
)
return "No Image"
image_preview.short_description = "Preview"

View file

@ -322,10 +322,26 @@ class DiscountTierInline(admin.TabularInline):
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
"""Admin configuration for ProgressiveDiscountModel"""
list_display = ("name", "description", "active")
list_display = ("name", "description", "active", "admin_display_discount_tiers")
search_fields = ("name", "description")
inlines = [DiscountTierInline]
def admin_display_discount_tiers(self, obj):
"""Display discount tiers in admin list view"""
tiers = obj.tiers.all().order_by("min_units")
if not tiers:
return "No discount tiers"
return format_html(
"<br>".join(
[
f"{tier.min_units}-{tier.max_units if tier.max_units else ''} units: {tier.discount_percent}%"
for tier in tiers
]
)
)
admin_display_discount_tiers.short_description = "Discount Tiers"
@admin.register(VSHNAppCatPrice)
class VSHNAppCatPriceAdmin(admin.ModelAdmin):
@ -350,7 +366,12 @@ class VSHNAppCatPriceAdmin(admin.ModelAdmin):
if not fees:
return "No base fees"
return format_html(
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
"<br>".join(
[
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
for fee in fees
]
)
)
admin_display_base_fees.short_description = "Base Fees"
@ -618,7 +639,12 @@ class VSHNAppCatAddonAdmin(admin.ModelAdmin):
if not fees:
return "No base fees set"
return format_html(
"<br>".join([f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})" for fee in fees])
"<br>".join(
[
f"{fee.amount} {fee.currency} ({fee.get_service_level_display()})"
for fee in fees
]
)
)
elif obj.addon_type == "UR": # Unit Rate
rates = obj.unit_rates.all()

View file

@ -4,9 +4,33 @@ Admin classes for cloud providers and consulting partners
from django.contrib import admin
from django.utils.html import format_html
from django import forms
from adminsortable2.admin import SortableAdminMixin
from ..models import CloudProvider, ConsultingPartner, ServiceOffering
from .widgets import ImageLibraryWidget
class CloudProviderAdminForm(forms.ModelForm):
"""Custom form for CloudProvider admin with image widget"""
class Meta:
model = CloudProvider
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
class ConsultingPartnerAdminForm(forms.ModelForm):
"""Custom form for ConsultingPartner admin with image widget"""
class Meta:
model = ConsultingPartner
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
class OfferingInline(admin.StackedInline):
@ -34,6 +58,8 @@ class OfferingInline(admin.StackedInline):
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
"""Admin configuration for CloudProvider model"""
form = CloudProviderAdminForm
list_display = (
"name",
"slug",
@ -52,11 +78,8 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(
@ -80,8 +103,11 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
"""Admin configuration for ConsultingPartner model"""
form = ConsultingPartnerAdminForm
list_display = (
"name",
"category",
"website",
"logo_preview",
"disable_listing",
@ -89,20 +115,18 @@ class ConsultingPartnerAdmin(SortableAdminMixin, admin.ModelAdmin):
"order",
)
search_fields = ("name", "description")
list_filter = ("category", "is_featured", "disable_listing")
prepopulated_fields = {"slug": ("name",)}
filter_horizontal = ("services", "cloud_providers")
ordering = ("order",)
fieldsets = (
(None, {"fields": ("name", "slug", "description", "order")}),
(None, {"fields": ("name", "slug", "description", "category", "order")}),
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(

View file

@ -4,6 +4,7 @@ Admin classes for services and service offerings
from django.contrib import admin
from django.utils.html import format_html
from django import forms
from ..models import (
Service,
@ -13,6 +14,18 @@ from ..models import (
Plan,
PlanPrice,
)
from .widgets import ImageLibraryWidget
class ServiceAdminForm(forms.ModelForm):
"""Custom form for Service admin with image widget"""
class Meta:
model = Service
fields = "__all__"
widgets = {
"image_library": ImageLibraryWidget(),
}
class ExternalLinkInline(admin.TabularInline):
@ -79,6 +92,8 @@ class OfferingInline(admin.StackedInline):
class ServiceAdmin(admin.ModelAdmin):
"""Admin configuration for Service model"""
form = ServiceAdminForm
list_display = (
"name",
"logo_preview",
@ -98,11 +113,8 @@ class ServiceAdmin(admin.ModelAdmin):
(
"Images",
{
"fields": (
"image_library",
"logo",
), # New image library field and legacy field
"description": "Use the Image Library field for new images. Legacy field will be removed after migration.",
"fields": ("image_library",),
"description": "Select an image from the Image Library.",
},
),
(

View file

@ -0,0 +1,232 @@
"""
Custom widgets for Django admin interface
"""
from django import forms
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.urls import reverse
from django.conf import settings
from ..models import ImageLibrary
class ImageLibraryWidget(forms.Select):
"""Custom widget for selecting images from the library with visual preview"""
def __init__(self, attrs=None):
super().__init__(attrs)
self.attrs.update(
{
"class": "image-library-select",
"style": "display: none;", # Hide the original select
}
)
def render(self, name, value, attrs=None, renderer=None):
"""Render the widget with image previews"""
# Get the original select element
original_select = super().render(name, value, attrs, renderer)
# Get all images from the library
images = ImageLibrary.objects.all().order_by("-uploaded_at")
# Create the visual interface
html_parts = [
'<div class="image-library-widget">',
original_select, # Keep the original select for form submission
'<div class="image-library-grid">',
]
# Add "No image" option
no_image_selected = "selected" if not value else ""
html_parts.append(
f"""
<div class="image-option {no_image_selected}" data-value="">
<div class="image-preview no-image">
<i class="fas fa-ban"></i>
<span>No image</span>
</div>
<div class="image-info">
<span class="image-name">No image selected</span>
</div>
</div>
"""
)
# Add each image as an option
for image in images:
selected = "selected" if str(image.pk) == str(value) else ""
image_url = image.image.url if image.image else ""
# Use img tag for all images in widget to maintain clickability
# SVG files will still display correctly with img tag
preview_html = (
f'<img src="{image_url}" alt="{image.alt_text}" loading="lazy">'
)
html_parts.append(
f"""
<div class="image-option {selected}" data-value="{image.pk}">
<div class="image-preview">
{preview_html}
</div>
<div class="image-info">
<span class="image-name">{image.name}</span>
<span class="image-category">{image.get_category_display()}</span>
<span class="image-size">{image.width}x{image.height}</span>
</div>
</div>
"""
)
html_parts.extend(
[
"</div>",
"</div>",
self._get_styles(),
self._get_javascript(),
]
)
return mark_safe("".join(html_parts))
def _get_styles(self):
"""Return CSS styles for the widget"""
return """
<style>
.image-library-widget {
margin: 10px 0;
}
.image-library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
max-height: 800px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.image-option {
background: white;
border: 2px solid #ddd;
border-radius: 5px;
padding: 10px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.image-option:hover {
border-color: #007cba;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.image-option.selected {
border-color: #007cba;
background: #e3f2fd;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2);
}
.image-preview {
width: 100%;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
border-radius: 3px;
overflow: hidden;
background: #f5f5f5;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: cover;
border-radius: 3px;
}
.image-preview.no-image {
background: #f0f0f0;
color: #666;
flex-direction: column;
}
.image-preview.no-image i {
font-size: 24px;
margin-bottom: 5px;
}
.image-info {
text-align: left;
}
.image-name {
display: block;
font-weight: 600;
font-size: 12px;
color: #333;
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-category {
display: inline-block;
background: #e0e0e0;
color: #666;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
margin-right: 5px;
}
.image-size {
font-size: 10px;
color: #666;
}
</style>
"""
def _get_javascript(self):
"""Return JavaScript for the widget functionality"""
return """
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle image selection
document.querySelectorAll('.image-option').forEach(function(option) {
option.addEventListener('click', function() {
const widget = this.closest('.image-library-widget');
const select = widget.querySelector('.image-library-select');
const value = this.dataset.value;
// Update the hidden select
select.value = value;
// Update visual selection
widget.querySelectorAll('.image-option').forEach(function(opt) {
opt.classList.remove('selected');
});
this.classList.add('selected');
// Trigger change event
select.dispatchEvent(new Event('change'));
});
});
});
</script>
"""
class Media:
css = {
"all": (
"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css",
)
}

View file

@ -40,6 +40,8 @@ class ImageLibraryWidget(forms.Select):
for image in images:
thumbnail_html = ""
if self.show_thumbnails and image.image:
# Use img tag for all images in dropdowns to maintain functionality
# SVG files will still display correctly with img tag
thumbnail_html = format_html(
' <img src="{}" style="width: 20px; height: 20px; object-fit: cover; margin-left: 5px; vertical-align: middle;" />',
image.image.url,
@ -64,16 +66,21 @@ class ImageLibraryWidget(forms.Select):
if value:
try:
image = ImageLibrary.objects.get(pk=value)
# Use img tag for all images in preview for consistency
# Add SVG indicator in the text if it's an SVG file
svg_indicator = " (SVG)" if image.is_svg() else ""
preview_html = format_html(
'<div class="image-preview" style="margin-top: 10px;">'
'<img src="{}" style="max-width: 200px; max-height: 200px; border: 1px solid #ddd; border-radius: 4px;" />'
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}</p>'
'<p style="margin-top: 5px; font-size: 12px; color: #666;">{} - {}x{} - {}{}</p>'
"</div>",
image.image.url,
image.name,
image.width or "?",
image.height or "?",
image.get_file_size_display(),
svg_indicator,
)
except ImageLibrary.DoesNotExist:
pass

View file

@ -0,0 +1,32 @@
from django.core.management.base import BaseCommand
from django.core.management import call_command
class Command(BaseCommand):
help = "Build and compress static assets for production"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Force compression even if files exist",
)
def handle(self, *args, **options):
self.stdout.write("Building static assets...")
# Compress CSS and JS files
self.stdout.write("Compressing CSS and JavaScript...")
call_command(
"compress",
force=options.get("force", False),
verbosity=options.get("verbosity", 1),
)
# Collect all static files
self.stdout.write("Collecting static files...")
call_command(
"collectstatic", interactive=False, verbosity=options.get("verbosity", 1)
)
self.stdout.write(self.style.SUCCESS("Successfully built static assets"))

View file

@ -0,0 +1,40 @@
from django.core.management.base import BaseCommand
from hub.services.models.images import ImageLibrary
class Command(BaseCommand):
help = "Update image properties for existing images in the library"
def handle(self, *args, **options):
"""
Update image properties for all images in the library.
This is especially useful after adding SVG support.
"""
images = ImageLibrary.objects.all()
updated_count = 0
error_count = 0
self.stdout.write(f"Updating properties for {images.count()} images...")
for image in images:
try:
# Force update of image properties
image._update_image_properties()
updated_count += 1
# Show progress
if updated_count % 10 == 0:
self.stdout.write(f"Updated {updated_count} images...")
except Exception as e:
error_count += 1
self.stdout.write(
self.style.ERROR(f"Error updating {image.name}: {str(e)}")
)
self.stdout.write(
self.style.SUCCESS(
f"Successfully updated {updated_count} images. "
f"Errors: {error_count}"
)
)

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2 on 2025-07-08 09:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("services", "0042_fix_image_library_field_name"),
]
operations = [
migrations.RemoveField(
model_name="article",
name="image",
),
migrations.RemoveField(
model_name="cloudprovider",
name="logo",
),
migrations.RemoveField(
model_name="consultingpartner",
name="logo",
),
migrations.RemoveField(
model_name="service",
name="logo",
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 5.2 on 2025-07-08 10:51
import hub.services.models.base
import hub.services.models.images
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0043_remove_article_image_remove_cloudprovider_logo_and_more"),
]
operations = [
migrations.AlterField(
model_name="imagelibrary",
name="image",
field=models.FileField(
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
upload_to=hub.services.models.images.get_image_upload_path,
validators=[hub.services.models.base.validate_image_or_svg],
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2 on 2025-07-08 13:53
import hub.services.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0044_add_svg_support"),
]
operations = [
migrations.AddField(
model_name="article",
name="og_image",
field=models.ImageField(
blank=True,
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
null=True,
upload_to="article_og_images/",
validators=[hub.services.models.base.validate_image_size],
),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2 on 2025-07-11 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("services", "0045_add_og_image_to_article"),
]
operations = [
migrations.AddField(
model_name="consultingpartner",
name="category",
field=models.CharField(
choices=[("CONSULTING", "Consulting"), ("TRAINING", "Training")],
default="CONSULTING",
help_text="Category of the consulting partner",
max_length=20,
),
),
]

View file

@ -2,9 +2,8 @@ from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.contrib.auth.models import User
from django_prose_editor.fields import ProseEditorField
from django.utils import timezone
from .base import validate_image_size
from .base import validate_image_size, get_prose_editor_field
from .services import Service
from .providers import CloudProvider, ConsultingPartner
from .images import ImageReference
@ -16,17 +15,10 @@ class Article(ImageReference):
excerpt = models.TextField(
max_length=500, help_text="Brief description of the article"
)
content = ProseEditorField()
content = get_prose_editor_field()
meta_keywords = models.CharField(
max_length=255, blank=True, help_text="SEO keywords separated by commas"
)
# Original image field - keep temporarily for migration
image = models.ImageField(
upload_to="article_images/",
help_text="Title picture for the article",
null=True,
blank=True,
)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
article_date = models.DateField(
default=timezone.now, help_text="Date of the article publishing"
@ -58,6 +50,15 @@ class Article(ImageReference):
help_text="Link this article to a cloud provider",
)
# Open Graph image for social sharing
og_image = models.ImageField(
upload_to="article_og_images/",
blank=True,
null=True,
validators=[validate_image_size],
help_text="Optional Open Graph image for social sharing (max 1MB). If not provided, the article's main image will be used.",
)
# Publishing controls
is_published = models.BooleanField(
default=False, help_text="Only published articles are visible to users"
@ -92,10 +93,19 @@ class Article(ImageReference):
@property
def get_image(self):
"""Returns the image from library or falls back to legacy image"""
"""Returns the image from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.image
return None
@property
def get_og_image(self):
"""Returns the Open Graph image for social sharing"""
# Use specific OG image if available
if self.og_image:
return self.og_image
# Fall back to main article image
return self.get_image
@property
def related_to(self):

View file

@ -2,6 +2,40 @@ from django.db import models
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
import mimetypes
import xml.etree.ElementTree as ET
# Centralized ProseEditorField configuration
PROSE_EDITOR_CONFIG = {
"extensions": {
"Bold": True,
"Italic": True,
"Strike": True,
"Underline": True,
"HardBreak": True,
"Heading": {"levels": [1, 2, 3, 4, 5, 6]},
"BulletList": True,
"OrderedList": True,
"Blockquote": True,
"Link": True,
"Table": True,
"History": True,
"HTML": True,
"Typographic": True,
},
"sanitize": True,
}
def get_prose_editor_field(**kwargs):
"""
Returns a ProseEditorField with the standard configuration.
Additional kwargs can be passed to override or add field options.
"""
config = PROSE_EDITOR_CONFIG.copy()
config.update(kwargs)
return ProseEditorField(**config)
def validate_image_size(value, mb=1):
@ -10,6 +44,49 @@ def validate_image_size(value, mb=1):
raise ValidationError(f"Maximum file size is {mb} MB")
def validate_image_or_svg(value):
"""
Validate that the uploaded file is either a valid image or SVG file.
"""
# Check file size first
validate_image_size(value)
# Get the file extension and MIME type
filename = value.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
# List of allowed image formats
allowed_image_types = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/tiff",
"image/svg+xml",
]
# Check if it's an SVG file
if filename.endswith(".svg") or mime_type == "image/svg+xml":
try:
# Reset file pointer and read content
value.seek(0)
content = value.read()
value.seek(0) # Reset for later use
# Try to parse as XML to ensure it's valid SVG
ET.fromstring(content)
return # Valid SVG
except ET.ParseError:
raise ValidationError("Invalid SVG file format")
# For non-SVG files, check MIME type
if mime_type not in allowed_image_types:
raise ValidationError(
f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG"
)
class Currency(models.TextChoices):
CHF = "CHF", "Swiss Franc"
EUR = "EUR", "Euro"
@ -29,6 +106,11 @@ class Unit(models.TextChoices):
CPU = "CPU", "vCPU"
class PartnerCategory(models.TextChoices):
CONSULTING = "CONSULTING", "Consulting"
TRAINING = "TRAINING", "Training"
# This should be a relation, but for now this is good enough :TM:
class ManagedServiceProvider(models.TextChoices):
VS = "VS", "VSHN"
@ -43,7 +125,7 @@ class ReusableText(models.Model):
blank=True,
related_name="children",
)
text = ProseEditorField()
text = get_prose_editor_field()
class Meta:
ordering = ["name"]

View file

@ -1,10 +1,10 @@
from django.db import models
from django_prose_editor.fields import ProseEditorField
from .base import get_prose_editor_field
class WebsiteFaq(models.Model):
question = models.CharField(max_length=200)
answer = ProseEditorField()
answer = get_prose_editor_field()
order = models.IntegerField(default=0)
class Meta:

View file

@ -1,12 +1,13 @@
import os
import mimetypes
import xml.etree.ElementTree as ET
from django.db import models
from django.utils import timezone
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.utils.text import slugify
from PIL import Image as PILImage
from .base import validate_image_size
from .base import validate_image_or_svg
def get_image_upload_path(instance, filename):
@ -35,10 +36,10 @@ class ImageLibrary(models.Model):
)
# Image file
image = models.ImageField(
image = models.FileField(
upload_to=get_image_upload_path,
validators=[validate_image_size],
help_text="Upload image file (max 1MB)",
validators=[validate_image_or_svg],
help_text="Upload image file (max 1MB) - supports JPEG, PNG, GIF, WebP, BMP, TIFF, and SVG",
)
# Image properties (automatically populated)
@ -122,14 +123,86 @@ class ImageLibrary(models.Model):
Update image properties like width, height, and file size.
"""
try:
# Get image dimensions
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# Get file size
self.file_size = self.image.size
# Check if it's an SVG file
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
if filename.endswith(".svg") or mime_type == "image/svg+xml":
# For SVG files, try to extract dimensions from the SVG content
try:
with open(self.image.path, "r", encoding="utf-8") as f:
content = f.read()
# Parse the SVG to extract width and height
root = ET.fromstring(content)
# Get width and height attributes
width = root.get("width")
height = root.get("height")
# Extract numeric values if they exist
if width and height:
# Remove units like 'px', 'em', etc. and convert to int
try:
width_val = int(
float(
width.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
height_val = int(
float(
height.replace("px", "")
.replace("em", "")
.replace("pt", "")
)
)
self.width = width_val
self.height = height_val
except (ValueError, TypeError):
# If we can't parse dimensions, try viewBox
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
# Default SVG dimensions if we can't parse
self.width = 100
self.height = 100
else:
# Check for viewBox if width/height attributes don't exist
viewbox = root.get("viewBox")
if viewbox:
try:
viewbox_parts = viewbox.split()
if len(viewbox_parts) >= 4:
self.width = int(float(viewbox_parts[2]))
self.height = int(float(viewbox_parts[3]))
except (ValueError, TypeError):
self.width = 100
self.height = 100
else:
# Default SVG dimensions
self.width = 100
self.height = 100
except (ET.ParseError, FileNotFoundError, UnicodeDecodeError):
# If SVG parsing fails, set default dimensions
self.width = 100
self.height = 100
else:
# For raster images, use PIL
with PILImage.open(self.image.path) as img:
self.width = img.width
self.height = img.height
# Save without calling the full save method to avoid recursion
ImageLibrary.objects.filter(pk=self.pk).update(
width=self.width, height=self.height, file_size=self.file_size
@ -175,6 +248,28 @@ class ImageLibrary(models.Model):
self.usage_count -= 1
self.save(update_fields=["usage_count"])
def is_svg(self):
"""
Check if the uploaded file is an SVG.
"""
if not self.image:
return False
filename = self.image.name.lower()
mime_type, _ = mimetypes.guess_type(filename)
return filename.endswith(".svg") or mime_type == "image/svg+xml"
def get_mime_type(self):
"""
Return the MIME type of the image file.
"""
if not self.image:
return None
mime_type, _ = mimetypes.guess_type(self.image.name)
return mime_type
class ImageReference(models.Model):
"""

View file

@ -1,28 +1,20 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django_prose_editor.fields import ProseEditorField
from .base import validate_image_size
from .base import validate_image_size, get_prose_editor_field, PartnerCategory
from .images import ImageReference
class CloudProvider(ImageReference):
name = models.CharField(max_length=100)
slug = models.SlugField(unique=True)
description = ProseEditorField()
description = get_prose_editor_field()
website = models.URLField()
linkedin = models.URLField(blank=True)
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="cloud_provider_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
order = models.IntegerField(default=0)
is_featured = models.BooleanField(default=False)
disable_listing = models.BooleanField(default=False)
@ -43,29 +35,30 @@ class CloudProvider(ImageReference):
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo
return None
class ConsultingPartner(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = ProseEditorField()
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="partner_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
description = get_prose_editor_field()
website = models.URLField(blank=True)
linkedin = models.URLField(blank=True)
phone = models.CharField(max_length=25, blank=True, null=True)
email = models.EmailField(max_length=254, blank=True, null=True)
address = models.TextField(max_length=250, blank=True, null=True)
# Partner category (hardcoded choices as requested)
category = models.CharField(
max_length=20,
choices=PartnerCategory.choices,
default=PartnerCategory.CONSULTING,
help_text="Category of the partner",
)
services = models.ManyToManyField(
"services.Service", related_name="consulting_partners", blank=True
)
@ -84,7 +77,10 @@ class ConsultingPartner(ImageReference):
ordering = ["order"]
def __str__(self):
return self.name
return f"{self.name} ({self.get_category_display()})"
def get_category_display_badge(self):
return f"Servala {self.get_category_display()} Partner"
def save(self, *args, **kwargs):
if not self.slug:
@ -96,7 +92,7 @@ class ConsultingPartner(ImageReference):
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo
return None

View file

@ -11,6 +11,7 @@ from .base import (
ManagedServiceProvider,
validate_image_size,
Currency,
get_prose_editor_field,
)
from .providers import CloudProvider
from .images import ImageReference
@ -19,17 +20,10 @@ from .images import ImageReference
class Service(ImageReference):
name = models.CharField(max_length=200)
slug = models.SlugField(max_length=250, unique=True)
description = ProseEditorField()
description = get_prose_editor_field()
tagline = models.TextField(max_length=500, blank=True, null=True)
# Original logo field - keep temporarily for migration
logo = models.ImageField(
upload_to="service_logos/",
validators=[validate_image_size],
null=True,
blank=True,
)
categories = models.ManyToManyField(Category, related_name="services")
features = ProseEditorField()
features = get_prose_editor_field()
is_featured = models.BooleanField(default=False)
is_coming_soon = models.BooleanField(default=False)
disable_listing = models.BooleanField(default=False)
@ -62,10 +56,10 @@ class Service(ImageReference):
@property
def get_logo(self):
"""Returns the logo from library or falls back to legacy logo"""
"""Returns the logo from the library"""
if self.image_library and self.image_library.image:
return self.image_library.image
return self.logo
return None
class ServiceOffering(models.Model):
@ -81,7 +75,7 @@ class ServiceOffering(models.Model):
cloud_provider = models.ForeignKey(
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
)
description = ProseEditorField(blank=True, null=True)
description = get_prose_editor_field(blank=True, null=True)
offer_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,
@ -136,7 +130,7 @@ class PlanPrice(models.Model):
class Plan(models.Model):
name = models.CharField(max_length=100)
description = ProseEditorField(blank=True, null=True)
description = get_prose_editor_field(blank=True, null=True)
plan_description = models.ForeignKey(
ReusableText,
on_delete=models.PROTECT,

View file

@ -76,4 +76,34 @@
.category-badge.other {
background-color: #f0f0f0;
color: #666;
}
/* SVG support */
.svg-preview {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 5px;
}
.svg-preview object {
width: 100%;
height: 100%;
}
/* SVG thumbnails in admin */
.image-thumbnail object {
background: #f5f5f5;
border-radius: 4px;
}
.image-preview object {
background: #f5f5f5;
border-radius: 4px;
}
/* Category badges */
.category-badge.svg {
background-color: #f3e8ff;
color: #7c3aed;
}

View file

@ -351,7 +351,8 @@ dl {
ol ol,
ul ul,
ol ul,
ul ol {
ul ol,
li p {
margin-bottom: 0
}
@ -12534,4 +12535,8 @@ a.btn:focus {
.article-content a {
font-weight: bold;
text-decoration: underline;
}
.article-content h2 {
margin-top: 3rem;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,176 @@
/**
* Addon Manager - Handles addon functionality
*/
class AddonManager {
constructor(pricingDataManager) {
this.pricingDataManager = pricingDataManager;
}
// Update addons based on current configuration
updateAddons(domManager) {
const addonsContainer = domManager.get('addonsContainer');
const addonsData = this.pricingDataManager.getAddonsData();
if (!addonsContainer || !addonsData) {
// Hide addons section if no container or data
const addonsSection = document.getElementById('addonsSection');
if (addonsSection) addonsSection.style.display = 'none';
return;
}
const serviceLevel = domManager.getSelectedServiceLevel();
if (!serviceLevel || !addonsData[serviceLevel]) {
// Hide addons section if no service level or no addons for this level
const addonsSection = document.getElementById('addonsSection');
if (addonsSection) addonsSection.style.display = 'none';
return;
}
const addons = addonsData[serviceLevel];
// Clear existing addons
addonsContainer.innerHTML = '';
// Show or hide addons section based on availability
const addonsSection = document.getElementById('addonsSection');
if (addons && addons.length > 0) {
if (addonsSection) addonsSection.style.display = 'block';
} else {
if (addonsSection) addonsSection.style.display = 'none';
return;
}
// Add each addon
addons.forEach(addon => {
const addonElement = document.createElement('div');
addonElement.className = `addon-item mb-2 p-2 border rounded ${addon.is_mandatory ? 'bg-light' : ''}`;
addonElement.innerHTML = `
<div class="form-check">
<input class="form-check-input addon-checkbox"
type="checkbox"
id="addon-${addon.id}"
value="${addon.id}"
data-addon='${JSON.stringify(addon)}'
${addon.is_mandatory ? 'checked disabled' : ''}>
<label class="form-check-label" for="addon-${addon.id}">
<strong>${addon.name}</strong>
<div class="text-muted small">${addon.commercial_description || ''}</div>
<div class="text-primary addon-price-display">
${addon.is_mandatory ? 'Required - ' : ''}CHF <span class="addon-price-value">0.00</span>
</div>
</label>
</div>
`;
addonsContainer.appendChild(addonElement);
// Add event listener for optional addons
if (!addon.is_mandatory) {
const checkbox = addonElement.querySelector('.addon-checkbox');
checkbox.addEventListener('change', () => {
// Update addon prices and recalculate total
this.updateAddonPrices(domManager);
// Trigger pricing update through custom event
window.dispatchEvent(new CustomEvent('addon-changed'));
});
}
});
// Update addon prices
this.updateAddonPrices(domManager);
}
// Update addon prices based on current configuration
updateAddonPrices(domManager, planManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return;
const config = domManager.getCurrentConfiguration();
// Find the current plan data to get variable_unit for addon calculations
const matchedPlan = planManager ? planManager.getCurrentPlan(domManager) : null;
const variableUnit = matchedPlan?.variable_unit || 'CPU';
const units = variableUnit === 'CPU' ? config.cpus : config.memory;
const totalUnits = units * config.instances;
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
const priceElement = checkbox.parentElement.querySelector('.addon-price-value');
let calculatedPrice = 0;
// Calculate addon price based on type
if (addon.addon_type === 'BASE_FEE') {
// Base fee: price per instance
calculatedPrice = parseFloat(addon.price || 0) * config.instances;
} else if (addon.addon_type === 'UNIT_RATE') {
// Unit rate: price per unit (CPU or memory) across all instances
calculatedPrice = parseFloat(addon.price_per_unit || 0) * totalUnits;
}
// Update the display price
if (priceElement) {
priceElement.textContent = calculatedPrice.toFixed(2);
}
// Store the calculated price for later use in total calculations
checkbox.dataset.calculatedPrice = calculatedPrice.toString();
});
}
// Get selected addons with their calculated prices
getSelectedAddons(domManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return { mandatory: [], optional: [] };
const mandatoryAddons = [];
const selectedOptionalAddons = [];
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
if (addon.is_mandatory) {
mandatoryAddons.push({
name: addon.name,
price: calculatedPrice.toFixed(2)
});
} else if (checkbox.checked) {
selectedOptionalAddons.push({
name: addon.name,
price: calculatedPrice.toFixed(2)
});
}
});
return {
mandatory: mandatoryAddons,
optional: selectedOptionalAddons
};
}
// Calculate total optional addon price
calculateOptionalAddonTotal(domManager) {
const addonsContainer = domManager.get('addonsContainer');
if (!addonsContainer) return 0;
let total = 0;
const addonCheckboxes = addonsContainer.querySelectorAll('.addon-checkbox');
addonCheckboxes.forEach(checkbox => {
const addon = JSON.parse(checkbox.dataset.addon);
if (!addon.is_mandatory && checkbox.checked) {
const calculatedPrice = parseFloat(checkbox.dataset.calculatedPrice || 0);
total += calculatedPrice;
}
});
return total;
}
}
// Export for use in other modules
window.AddonManager = AddonManager;

View file

@ -0,0 +1,160 @@
/**
* DOM Manager - Handles DOM element references and basic manipulation
*/
class DOMManager {
constructor() {
this.elements = {};
this.initElements();
}
// Initialize DOM element references
initElements() {
// Calculator controls
this.elements.cpuRange = document.getElementById('cpuRange');
this.elements.memoryRange = document.getElementById('memoryRange');
this.elements.storageRange = document.getElementById('storageRange');
this.elements.instancesRange = document.getElementById('instancesRange');
this.elements.cpuValue = document.getElementById('cpuValue');
this.elements.memoryValue = document.getElementById('memoryValue');
this.elements.storageValue = document.getElementById('storageValue');
this.elements.instancesValue = document.getElementById('instancesValue');
this.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
this.elements.planSelect = document.getElementById('planSelect');
// Addon elements
this.elements.addonsContainer = document.getElementById('addonsContainer');
this.elements.addonPricingContainer = document.getElementById('addonPricingContainer');
this.elements.managedServiceIncludesContainer = document.getElementById('managedServiceIncludesContainer');
// Result display elements
this.elements.planMatchStatus = document.getElementById('planMatchStatus');
this.elements.selectedPlanDetails = document.getElementById('selectedPlanDetails');
this.elements.noMatchFound = document.getElementById('noMatchFound');
// Plan detail elements
this.elements.planGroup = document.getElementById('planGroup');
this.elements.planName = document.getElementById('planName');
this.elements.planDescription = document.getElementById('planDescription');
this.elements.planCpus = document.getElementById('planCpus');
this.elements.planMemory = document.getElementById('planMemory');
this.elements.planInstances = document.getElementById('planInstances');
this.elements.planServiceLevel = document.getElementById('planServiceLevel');
this.elements.managedServicePrice = document.getElementById('managedServicePrice');
this.elements.storagePriceEl = document.getElementById('storagePrice');
this.elements.storageAmount = document.getElementById('storageAmount');
this.elements.totalPrice = document.getElementById('totalPrice');
// Order button
this.elements.orderButton = document.querySelector('a[href="#order-form"]');
// Service level group
this.elements.serviceLevelGroup = document.getElementById('serviceLevelGroup');
}
// Get element by key
get(key) {
return this.elements[key];
}
// Check if element exists
has(key) {
return this.elements[key] && this.elements[key] !== null;
}
// Update slider display values (min/max text below sliders)
updateSliderDisplayValues() {
// Update CPU slider display
if (this.elements.cpuRange) {
const cpuMinDisplay = document.getElementById('cpuMinDisplay');
const cpuMaxDisplay = document.getElementById('cpuMaxDisplay');
if (cpuMinDisplay) cpuMinDisplay.textContent = this.elements.cpuRange.min;
if (cpuMaxDisplay) cpuMaxDisplay.textContent = this.elements.cpuRange.max;
}
// Update Memory slider display
if (this.elements.memoryRange) {
const memoryMinDisplay = document.getElementById('memoryMinDisplay');
const memoryMaxDisplay = document.getElementById('memoryMaxDisplay');
if (memoryMinDisplay) memoryMinDisplay.textContent = this.elements.memoryRange.min;
if (memoryMaxDisplay) memoryMaxDisplay.textContent = this.elements.memoryRange.max;
}
// Update Storage slider display
if (this.elements.storageRange) {
const storageMinDisplay = document.getElementById('storageMinDisplay');
const storageMaxDisplay = document.getElementById('storageMaxDisplay');
if (storageMinDisplay) storageMinDisplay.textContent = this.elements.storageRange.min;
if (storageMaxDisplay) storageMaxDisplay.textContent = this.elements.storageRange.max;
}
// Update Instances slider display
if (this.elements.instancesRange) {
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
if (instancesMinDisplay) instancesMinDisplay.textContent = this.elements.instancesRange.min;
if (instancesMaxDisplay) instancesMaxDisplay.textContent = this.elements.instancesRange.max;
}
}
// Get slider container element by type
getSliderContainer(type) {
switch (type) {
case 'cpu':
return this.elements.cpuRange?.closest('.mb-4');
case 'memory':
return this.elements.memoryRange?.closest('.mb-4');
case 'storage':
return this.elements.storageRange?.closest('.mb-4');
case 'instances':
return this.elements.instancesRange?.closest('.mb-4');
default:
return null;
}
}
// Reset sliders to their default values
resetSlidersToDefaults() {
// Reset CPU slider to default value (0.5 vCPUs)
if (this.elements.cpuRange) {
this.elements.cpuRange.value = '0.5';
if (this.elements.cpuValue) this.elements.cpuValue.textContent = '0.5';
}
// Reset Memory slider to default value (1 GB)
if (this.elements.memoryRange) {
this.elements.memoryRange.value = '1';
if (this.elements.memoryValue) this.elements.memoryValue.textContent = '1';
}
// Reset Storage slider to default value (20 GB)
if (this.elements.storageRange) {
this.elements.storageRange.value = '20';
if (this.elements.storageValue) this.elements.storageValue.textContent = '20';
}
// Reset Instances slider to default value (1)
if (this.elements.instancesRange) {
this.elements.instancesRange.value = '1';
if (this.elements.instancesValue) this.elements.instancesValue.textContent = '1';
}
}
// Get current selected service level
getSelectedServiceLevel() {
return document.querySelector('input[name="serviceLevel"]:checked')?.value;
}
// Get current configuration values
getCurrentConfiguration() {
return {
cpus: parseFloat(this.elements.cpuRange?.value || 0.5),
memory: parseFloat(this.elements.memoryRange?.value || 1),
storage: parseInt(this.elements.storageRange?.value || 20),
instances: parseInt(this.elements.instancesRange?.value || 1),
serviceLevel: this.getSelectedServiceLevel()
};
}
}
// Export for use in other modules
window.DOMManager = DOMManager;

View file

@ -0,0 +1,113 @@
/**
* Order Manager - Handles order form functionality
*/
class OrderManager {
constructor() {
this.selectedConfiguration = null;
}
// Setup order button click handler
setupOrderButton(domManager) {
const orderButton = domManager.get('orderButton');
if (orderButton) {
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,
instances: config.instances,
serviceLevel: config.serviceLevel,
totalPrice: config.totalPrice,
addons: config.addons || []
});
}
}
// Generate human-readable configuration message
generateConfigurationMessage(config) {
let message = `I would like to order the following configuration:
Plan: ${config.planName} (${config.planGroup})
vCPUs: ${config.vcpus}
Memory: ${config.memory} GB
Storage: ${config.storage} GB
Instances: ${config.instances}
Service Level: ${config.serviceLevel}`;
// Add addons to the message if any are selected
if (config.addons && config.addons.length > 0) {
message += '\n\nSelected Add-ons:';
config.addons.forEach(addon => {
message += `\n- ${addon.name}: CHF ${addon.price}`;
});
}
message += `\n\nTotal Monthly Price: CHF ${config.totalPrice}
Please contact me with next steps for ordering this configuration.`;
return message;
}
// Store current configuration for order button
storeConfiguration(plan, config, serviceLevel, totalPrice, addons) {
this.selectedConfiguration = {
planName: plan.compute_plan,
planGroup: plan.groupName,
vcpus: plan.vcpus,
memory: plan.ram,
storage: config.storage,
instances: config.instances,
serviceLevel: serviceLevel,
totalPrice: totalPrice,
addons: addons
};
}
// Get stored configuration
getStoredConfiguration() {
return this.selectedConfiguration;
}
}
// Export for use in other modules
window.OrderManager = OrderManager;

View file

@ -0,0 +1,104 @@
/**
* Plan Manager - Handles plan selection and matching logic
*/
class PlanManager {
constructor(pricingDataManager) {
this.pricingDataManager = pricingDataManager;
}
// Find best matching plan based on requirements
findBestMatchingPlan(cpus, memory, serviceLevel) {
const pricingData = this.pricingDataManager.getPricingData();
if (!pricingData) return null;
let bestMatch = null;
let bestScore = Infinity;
// Iterate through all groups and service levels
Object.keys(pricingData).forEach(groupName => {
const group = pricingData[groupName];
if (group[serviceLevel]) {
group[serviceLevel].forEach(plan => {
const planCpus = parseFloat(plan.vcpus);
const planMemory = parseFloat(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;
}
// Get current plan based on configuration
getCurrentPlan(domManager) {
const config = domManager.getCurrentConfiguration();
const planSelect = domManager.get('planSelect');
if (planSelect?.value) {
return JSON.parse(planSelect.value);
}
return this.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
}
// Populate plan dropdown based on selected service level
populatePlanDropdown(domManager) {
const planSelect = domManager.get('planSelect');
if (!planSelect) return;
const serviceLevel = domManager.getSelectedServiceLevel();
if (!serviceLevel) return;
// Clear existing options
planSelect.innerHTML = '<option value="">Auto-select best matching plan</option>';
// Get plans for the selected service level
const availablePlans = this.pricingDataManager.getPlansForServiceLevel(serviceLevel);
// 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`;
planSelect.appendChild(option);
});
}
// Update sliders to match selected plan
updateSlidersForPlan(plan, domManager) {
const cpuRange = domManager.get('cpuRange');
const memoryRange = domManager.get('memoryRange');
const cpuValue = domManager.get('cpuValue');
const memoryValue = domManager.get('memoryValue');
if (cpuRange && cpuValue) {
cpuRange.value = plan.vcpus;
cpuValue.textContent = plan.vcpus;
}
if (memoryRange && memoryValue) {
memoryRange.value = plan.ram;
memoryValue.textContent = plan.ram;
}
}
}
// Export for use in other modules
window.PlanManager = PlanManager;

View file

@ -0,0 +1,252 @@
/**
* Price Calculator - Main orchestrator class
* Coordinates all the different managers to provide pricing calculation functionality
*/
class PriceCalculator {
constructor() {
// Initialize managers
this.domManager = new DOMManager();
this.currentOffering = this.extractOfferingFromURL();
this.pricingDataManager = new PricingDataManager(this.currentOffering);
this.planManager = new PlanManager(this.pricingDataManager);
this.addonManager = new AddonManager(this.pricingDataManager);
this.uiManager = new UIManager();
this.orderManager = new OrderManager();
// Initialize the calculator
this.init();
}
// Extract offering info from URL
extractOfferingFromURL() {
const pathParts = window.location.pathname.split('/');
if (pathParts.length >= 4 && pathParts[1] === 'offering') {
return {
provider_slug: pathParts[2],
service_slug: pathParts[3]
};
}
return null;
}
// Initialize calculator
async init() {
try {
// Load pricing data and setup calculator
if (this.currentOffering) {
await this.pricingDataManager.loadPricingData();
this.setupEventListeners();
this.setupUI();
this.orderManager.setupOrderButton(this.domManager);
this.updateCalculator();
} else {
console.warn('No current offering found, calculator not initialized');
}
} catch (error) {
console.error('Error initializing price calculator:', error);
this.uiManager.showError(this.domManager, 'Failed to load pricing information');
}
}
// Setup initial UI components
setupUI() {
// Setup service levels based on available data
this.uiManager.setupServiceLevels(this.domManager, this.pricingDataManager);
// Calculate and set slider maximums
this.uiManager.updateSliderMaximums(this.domManager, this.pricingDataManager);
// Populate plan dropdown
this.planManager.populatePlanDropdown(this.domManager);
// Initialize instances slider
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
}
// Setup event listeners for calculator controls
setupEventListeners() {
const cpuRange = this.domManager.get('cpuRange');
const memoryRange = this.domManager.get('memoryRange');
const storageRange = this.domManager.get('storageRange');
const instancesRange = this.domManager.get('instancesRange');
if (!cpuRange || !memoryRange || !storageRange || !instancesRange) return;
// Slider event listeners
cpuRange.addEventListener('input', () => {
this.domManager.get('cpuValue').textContent = cpuRange.value;
this.updatePricing();
});
memoryRange.addEventListener('input', () => {
this.domManager.get('memoryValue').textContent = memoryRange.value;
this.updatePricing();
});
storageRange.addEventListener('input', () => {
this.domManager.get('storageValue').textContent = storageRange.value;
this.updatePricing();
});
instancesRange.addEventListener('input', () => {
this.domManager.get('instancesValue').textContent = instancesRange.value;
this.updatePricing();
});
// Service level change listeners
const serviceLevelInputs = this.domManager.get('serviceLevelInputs');
serviceLevelInputs.forEach(input => {
input.addEventListener('change', () => {
this.uiManager.updateInstancesSlider(this.domManager, this.pricingDataManager);
this.planManager.populatePlanDropdown(this.domManager);
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
});
});
// Plan selection listener
const planSelect = this.domManager.get('planSelect');
if (planSelect) {
planSelect.addEventListener('change', () => {
if (planSelect.value) {
const selectedPlan = JSON.parse(planSelect.value);
// Update sliders to match selected plan
this.planManager.updateSlidersForPlan(selectedPlan, this.domManager);
// Fade out CPU and Memory sliders since plan is manually selected
this.uiManager.fadeOutSliders(this.domManager, ['cpu', 'memory']);
// Update addons for the new configuration
this.addonManager.updateAddons(this.domManager);
// Update pricing with the selected plan
this.updatePricingWithPlan(selectedPlan);
} else {
// Auto-select mode - reset sliders to default values
this.domManager.resetSlidersToDefaults();
// Auto-select mode - fade sliders back in
this.uiManager.fadeInSliders(this.domManager, ['cpu', 'memory']);
// Auto-select mode - update addons and recalculate
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
}
});
}
// Listen for addon changes
window.addEventListener('addon-changed', () => {
this.updatePricing();
});
}
// Update calculator (initial setup)
updateCalculator() {
this.addonManager.updateAddons(this.domManager);
this.updatePricing();
}
// Update pricing with specific plan
updatePricingWithPlan(selectedPlan) {
const config = this.domManager.getCurrentConfiguration();
// Update addon prices first to ensure calculated prices are current
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
this.showPlanDetails(selectedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
}
// Main pricing update function
updatePricing() {
// Update addon prices first to ensure they're current
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
const planSelect = this.domManager.get('planSelect');
// Reset plan selection if in auto-select mode
if (!planSelect?.value) {
const config = this.domManager.getCurrentConfiguration();
if (!config.serviceLevel) {
return;
}
// Find best matching plan
const matchedPlan = this.planManager.findBestMatchingPlan(config.cpus, config.memory, config.serviceLevel);
if (matchedPlan) {
this.showPlanDetails(matchedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Perfect match found!', 'success');
} else {
this.uiManager.showNoMatch(this.domManager);
}
} else {
// Plan is directly selected, update storage pricing
const selectedPlan = JSON.parse(planSelect.value);
const config = this.domManager.getCurrentConfiguration();
// Update addon prices for current configuration
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
this.showPlanDetails(selectedPlan, config.storage, config.instances);
this.uiManager.updateStatusMessage(this.domManager, 'Plan selected directly!', 'success');
}
}
// Show plan details in the UI
showPlanDetails(plan, storage, instances) {
// Get current service level
const serviceLevel = this.domManager.getSelectedServiceLevel() || 'Best Effort';
// Ensure addon prices are calculated with current configuration
this.addonManager.updateAddonPrices(this.domManager, this.planManager);
// Calculate pricing using final price from plan data (which already includes mandatory addons)
const managedServicePricePerInstance = parseFloat(plan.final_price);
// Collect addon information for display and calculation
const addons = this.addonManager.getSelectedAddons(this.domManager);
const optionalAddonTotal = this.addonManager.calculateOptionalAddonTotal(this.domManager);
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.pricingDataManager.getStoragePrice();
const storagePriceValue = storage * storageUnitPrice * instances;
// Total price = managed service price (includes mandatory addons) + storage + optional addons
const totalPriceValue = managedServicePrice + storagePriceValue + optionalAddonTotal;
// Show plan details in UI
this.uiManager.showPlanDetails(
this.domManager,
plan,
storage,
instances,
serviceLevel,
managedServicePrice,
storagePriceValue,
totalPriceValue
);
// Update addon pricing display
this.uiManager.updateAddonPricingDisplay(this.domManager, addons.mandatory, addons.optional);
// Store current configuration for order button
this.orderManager.storeConfiguration(
plan,
{ storage, instances },
serviceLevel,
totalPriceValue.toFixed(2),
[...addons.mandatory, ...addons.optional]
);
}
}
// Export for use in other modules
window.PriceCalculator = PriceCalculator;

View file

@ -0,0 +1,190 @@
/**
* Pricing Data Manager - Handles API calls and data extraction
*/
class PricingDataManager {
constructor(currentOffering) {
this.currentOffering = currentOffering;
this.pricingData = null;
this.storagePrice = null;
this.replicaInfo = null;
this.addonsData = null;
}
// 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: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.pricingData = data.pricing || data;
// Extract addons data from the plans - addons are embedded in each plan
this.extractAddonsData();
// Extract storage price from the first available plan
this.extractStoragePrice();
return this.pricingData;
} catch (error) {
console.error('Error loading pricing data:', error);
throw error;
}
}
// Extract replica information and storage price from pricing data
extractStoragePrice() {
if (!this.pricingData) return;
// 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;
}
}
}
}
// Extract addons data from pricing plans
extractAddonsData() {
if (!this.pricingData) return;
this.addonsData = {};
// Extract addons from the first available plan for each service level
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
const plans = group[serviceLevel];
if (plans.length > 0) {
// Use the first plan's addon data for this service level
const plan = plans[0];
const allAddons = [];
// Add mandatory addons
if (plan.mandatory_addons) {
plan.mandatory_addons.forEach(addon => {
allAddons.push({
...addon,
is_mandatory: true,
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
});
});
}
// Add optional addons
if (plan.optional_addons) {
plan.optional_addons.forEach(addon => {
allAddons.push({
...addon,
is_mandatory: false,
addon_type: addon.addon_type === "Base Fee" ? "BASE_FEE" : "UNIT_RATE"
});
});
}
this.addonsData[serviceLevel] = allAddons;
}
});
});
}
// Get available service levels from pricing data
getAvailableServiceLevels() {
if (!this.pricingData) return new Set();
const availableServiceLevels = new Set();
Object.keys(this.pricingData).forEach(groupName => {
const group = this.pricingData[groupName];
Object.keys(group).forEach(serviceLevel => {
availableServiceLevels.add(serviceLevel);
});
});
return availableServiceLevels;
}
// Get maximum CPU and memory values from all plans
getSliderMaximums() {
if (!this.pricingData) return { maxCpus: 0, maxMemory: 0 };
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;
});
});
});
return { maxCpus, maxMemory };
}
// Get all plans for a specific service level
getPlansForServiceLevel(serviceLevel) {
if (!this.pricingData || !serviceLevel) return [];
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 (parseFloat(a.vcpus) !== parseFloat(b.vcpus)) {
return parseFloat(a.vcpus) - parseFloat(b.vcpus);
}
return parseFloat(a.ram) - parseFloat(b.ram);
});
return availablePlans;
}
// Getters
getPricingData() {
return this.pricingData;
}
getStoragePrice() {
return this.storagePrice;
}
getReplicaInfo() {
return this.replicaInfo;
}
getAddonsData() {
return this.addonsData;
}
}
// Export for use in other modules
window.PricingDataManager = PricingDataManager;

View file

@ -0,0 +1,269 @@
/**
* UI Manager - Handles UI updates and visual feedback
*/
class UIManager {
constructor() {
// Visual feedback states
this.isSlidersFaded = false;
}
// Update status message
updateStatusMessage(domManager, message, type) {
const planMatchStatus = domManager.get('planMatchStatus');
if (!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';
planMatchStatus.innerHTML = `<i class="bi ${iconClass} me-2 ${textClass}"></i><span class="${textClass}">${message}</span>`;
planMatchStatus.className = `alert ${alertClass} mb-3`;
planMatchStatus.style.display = 'block';
}
// Show error message
showError(domManager, message) {
const planMatchStatus = domManager.get('planMatchStatus');
if (planMatchStatus) {
planMatchStatus.innerHTML = `<i class="bi bi-exclamation-triangle me-2 text-danger"></i><span class="text-danger">${message}</span>`;
planMatchStatus.className = 'alert alert-danger mb-3';
planMatchStatus.style.display = 'block';
}
}
// Show no matching plan found
showNoMatch(domManager) {
const planMatchStatus = domManager.get('planMatchStatus');
const selectedPlanDetails = domManager.get('selectedPlanDetails');
const noMatchFound = domManager.get('noMatchFound');
if (planMatchStatus) planMatchStatus.style.display = 'none';
if (selectedPlanDetails) selectedPlanDetails.style.display = 'none';
if (noMatchFound) noMatchFound.style.display = 'block';
}
// Show plan details in the UI
showPlanDetails(domManager, plan, storage, instances, serviceLevel, managedServicePrice, storagePriceValue, totalPriceValue) {
const selectedPlanDetails = domManager.get('selectedPlanDetails');
if (!selectedPlanDetails) return;
// Show plan details section
const planMatchStatus = domManager.get('planMatchStatus');
const noMatchFound = domManager.get('noMatchFound');
if (planMatchStatus) planMatchStatus.style.display = 'block';
selectedPlanDetails.style.display = 'block';
if (noMatchFound) noMatchFound.style.display = 'none';
// Update plan information
const planGroup = domManager.get('planGroup');
const planName = domManager.get('planName');
const planDescription = domManager.get('planDescription');
const planCpus = domManager.get('planCpus');
const planMemory = domManager.get('planMemory');
const planInstances = domManager.get('planInstances');
const planServiceLevel = domManager.get('planServiceLevel');
const managedServicePriceEl = domManager.get('managedServicePrice');
const storagePriceEl = domManager.get('storagePriceEl');
const storageAmount = domManager.get('storageAmount');
const totalPrice = domManager.get('totalPrice');
if (planGroup) planGroup.textContent = plan.groupName;
if (planName) planName.textContent = plan.compute_plan;
if (planDescription) planDescription.textContent = plan.compute_plan_group_description || '';
if (planCpus) planCpus.textContent = plan.vcpus;
if (planMemory) planMemory.textContent = plan.ram + ' GB';
if (planInstances) planInstances.textContent = instances;
if (planServiceLevel) planServiceLevel.textContent = serviceLevel;
// Update pricing display
if (managedServicePriceEl) managedServicePriceEl.textContent = managedServicePrice.toFixed(2);
if (storagePriceEl) storagePriceEl.textContent = storagePriceValue.toFixed(2);
if (storageAmount) storageAmount.textContent = storage;
if (totalPrice) totalPrice.textContent = totalPriceValue.toFixed(2);
}
// Update addon pricing display in the results panel
updateAddonPricingDisplay(domManager, mandatoryAddons, selectedOptionalAddons) {
// Update mandatory addons in the managed service includes container
const managedServiceIncludesContainer = domManager.get('managedServiceIncludesContainer');
if (managedServiceIncludesContainer) {
// Clear existing content
managedServiceIncludesContainer.innerHTML = '';
// Add mandatory addons to the managed service includes section
if (mandatoryAddons && mandatoryAddons.length > 0) {
mandatoryAddons.forEach(addon => {
const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between small text-muted mb-1';
addonRow.innerHTML = `
<span><i class="bi bi-check-circle text-success me-1"></i>${addon.name}</span>
<span>CHF ${addon.price}</span>
`;
managedServiceIncludesContainer.appendChild(addonRow);
});
}
}
// Update optional addons in the addon pricing container
const addonPricingContainer = domManager.get('addonPricingContainer');
if (!addonPricingContainer) return;
// Clear existing addon pricing display
addonPricingContainer.innerHTML = '';
// Add optional addons to pricing breakdown (these are added to total)
if (selectedOptionalAddons && selectedOptionalAddons.length > 0) {
selectedOptionalAddons.forEach(addon => {
const addonRow = document.createElement('div');
addonRow.className = 'd-flex justify-content-between mb-2';
addonRow.innerHTML = `
<span>Add-on: ${addon.name}</span>
<span class="fw-bold">CHF ${addon.price}</span>
`;
addonPricingContainer.appendChild(addonRow);
});
}
}
// Fade out specified sliders when plan is manually selected
fadeOutSliders(domManager, sliderTypes) {
sliderTypes.forEach(type => {
const sliderContainer = domManager.getSliderContainer(type);
if (sliderContainer) {
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
sliderContainer.style.opacity = '0.3';
sliderContainer.style.pointerEvents = 'none';
// Add visual indicator that sliders are disabled
const slider = sliderContainer.querySelector('.form-range');
if (slider) {
slider.style.cursor = 'not-allowed';
}
}
});
this.isSlidersFaded = true;
}
// Fade in specified sliders when auto-select mode is chosen
fadeInSliders(domManager, sliderTypes) {
sliderTypes.forEach(type => {
const sliderContainer = domManager.getSliderContainer(type);
if (sliderContainer) {
sliderContainer.style.transition = 'opacity 0.3s ease-in-out';
sliderContainer.style.opacity = '1';
sliderContainer.style.pointerEvents = 'auto';
// Remove visual indicator
const slider = sliderContainer.querySelector('.form-range');
if (slider) {
slider.style.cursor = 'pointer';
}
}
});
this.isSlidersFaded = false;
}
// Setup service levels dynamically from pricing data
setupServiceLevels(domManager, pricingDataManager) {
const serviceLevelGroup = domManager.get('serviceLevelGroup');
if (!serviceLevelGroup) return;
// Get all available service levels from the pricing data
const availableServiceLevels = pricingDataManager.getAvailableServiceLevels();
// 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;
serviceLevelGroup.appendChild(input);
serviceLevelGroup.appendChild(label);
});
// Update the serviceLevelInputs reference
domManager.elements.serviceLevelInputs = document.querySelectorAll('input[name="serviceLevel"]');
}
// Update slider maximums based on pricing data
updateSliderMaximums(domManager, pricingDataManager) {
const cpuRange = domManager.get('cpuRange');
const memoryRange = domManager.get('memoryRange');
if (!cpuRange || !memoryRange) return;
const { maxCpus, maxMemory } = pricingDataManager.getSliderMaximums();
// Set slider maximums with some padding
if (maxCpus > 0) {
cpuRange.min = "0.25";
cpuRange.max = Math.ceil(maxCpus);
}
if (maxMemory > 0) {
memoryRange.min = "0.25";
memoryRange.max = Math.ceil(maxMemory);
}
// Update display values after changing min/max
domManager.updateSliderDisplayValues();
}
// Update instances slider based on service level and replica info
updateInstancesSlider(domManager, pricingDataManager) {
const instancesRange = domManager.get('instancesRange');
const instancesValue = domManager.get('instancesValue');
const replicaInfo = pricingDataManager.getReplicaInfo();
if (!instancesRange || !replicaInfo) return;
const serviceLevel = domManager.getSelectedServiceLevel();
if (serviceLevel === 'Guaranteed Availability') {
// For GA, min is ha_replica_min
instancesRange.min = replicaInfo.ha_replica_min;
instancesRange.value = Math.max(instancesRange.value, replicaInfo.ha_replica_min);
} else {
// For BE, min is 1
instancesRange.min = 1;
instancesRange.value = Math.max(instancesRange.value, 1);
}
// Set max to ha_replica_max
instancesRange.max = replicaInfo.ha_replica_max;
// Update display value
if (instancesValue) instancesValue.textContent = instancesRange.value;
// Update the min/max display under the slider
const instancesMinDisplay = document.getElementById('instancesMinDisplay');
const instancesMaxDisplay = document.getElementById('instancesMaxDisplay');
if (instancesMinDisplay) instancesMinDisplay.textContent = instancesRange.min;
if (instancesMaxDisplay) instancesMaxDisplay.textContent = instancesRange.max;
}
}
// Export for use in other modules
window.UIManager = UIManager;

View file

@ -55,9 +55,9 @@
<ul class="navbar__menu menu mr-lg-27">
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:homepage' %}">Home</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:service_list' %}">Services</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Provider</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Partner</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:article_list' %}">Articles</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:provider_list' %}">Cloud Providers</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:partner_list' %}">Consulting Partners</a></li>
<li class="menu__item"><a class="menu__item-link" href="{% url 'services:about' %}">About</a></li>
</ul>
<ul class="menu-cta mb-0">

View file

@ -109,6 +109,7 @@
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
{% if provider.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
<img src="{{ provider.get_logo.url }}"
@ -116,6 +117,7 @@
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
{% endif %}
</div>
<h3 class="card__title">
<a href="{{ provider.get_absolute_url }}" class="text-decoration-none clickable-link">{{ provider.name }}</a>
@ -145,9 +147,9 @@
<div class="">
<header class="section__header w-100 d-flex justify-content-between align-items-center">
<div class="section__header-text">
<h2 class="section__header-h2">Consulting Partners</h2>
<h2 class="section__header-h2">Partners</h2>
<div class="section__desc">
<p>Explore all available Consulting Partners on Servala, with new ones added regularly.</p>
<p>Explore all available Partners on Servala, with new ones added regularly.</p>
</div>
</div>
<div class="d-none d-lg-block">
@ -163,6 +165,7 @@
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
{% if partner.get_logo %}
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.get_logo.url }}"
@ -170,6 +173,7 @@
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
{% endif %}
</div>
<h3 class="card__title">
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
@ -212,7 +216,7 @@
<div>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:service_list' %}" role="button">Services</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:provider_list' %}" role="button">Cloud Providers</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Consulting Partners</a>
<a class="btn btn-primary btn-lg mr-md-17 mb-17 mb-md-0 w-100 w-md-auto" href="{% url 'services:partner_list' %}" role="button">Partners</a>
</div>
</header>
</div>

View file

@ -41,7 +41,7 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Service</h5>
{% if article.related_service.logo %}
{% if article.related_service.get_logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
@ -58,13 +58,16 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Partner</h5>
{% if article.related_consulting_partner.logo %}
{% if article.related_consulting_partner.get_logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_consulting_partner.get_logo.url }}" alt="{{ article.related_consulting_partner.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
</div>
{% endif %}
<p class="card-text">{{ article.related_consulting_partner.name }}</p>
<div class="mb-2">
<span class="badge bg-primary">{{ article.related_consulting_partner.get_category_display_badge }}</span>
</div>
<a href="{{ article.related_consulting_partner.get_absolute_url }}" class="btn btn-primary btn-sm">View Partner</a>
</div>
</div>
@ -75,7 +78,7 @@
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Provider</h5>
{% if article.related_cloud_provider.logo %}
{% if article.related_cloud_provider.get_logo %}
<div class="mb-3 d-flex" style="height: 60px;">
<img src="{{ article.related_cloud_provider.get_logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
class="img-fluid" style="max-height: 50px; object-fit: contain;">
@ -97,15 +100,32 @@
<h3>Related Articles</h3>
<div class="row">
{% for related_article in related_articles %}
<div class="col-12 col-md-4 mb-4">
<div class="card h-100 clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
{% if related_article.image %}
<img src="{{ related_article.get_image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
<div class="col-12 col-md-4 mb-30">
<div class="card h-100 d-flex flex-column clickable-card" onclick="cardClicked(event, '{{ related_article.get_absolute_url }}')">
{% if related_article.get_image %}
<div class="d-flex justify-content-between mb-3">
<div class="card__image flex-shrink-0">
<img src="{{ related_article.get_image.url }}" alt="{{ related_article.title }}" class="img-fluid">
</div>
</div>
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ related_article.title }}</h5>
<p class="card-text flex-grow-1">{{ related_article.excerpt|truncatewords:15 }}</p>
<small class="text-muted">{{ related_article.created_at|date:"M d, Y" }}</small>
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<h3 class="card__title">
{{ related_article.title }}
</h3>
<p class="card__subtitle">
<span class="text-muted">
By {{ related_article.author.get_full_name|default:related_article.author.username }}
</span>
<span class="text-muted ms-2">
{{ related_article.article_date|date:"M d, Y" }}
</span>
</p>
</div>
<div class="card__desc flex-grow-1">
<p class="mb-0">{{ related_article.excerpt|truncatewords:15 }}</p>
</div>
</div>
</div>
</div>

View file

@ -145,9 +145,9 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card {% if article.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ article.get_absolute_url }}')">
{% if article.image or article.is_featured %}
{% if article.get_image or article.is_featured %}
<div class="d-flex justify-content-between mb-3">
{% if article.image %}
{% if article.get_image %}
<div class="card__image flex-shrink-0">
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
</div>

View file

@ -77,7 +77,7 @@
<div class="w-lg-34 bg-purple-50 rounded-16 p-24 d-flex flex-column">
<div class="d-flex align-items-center mb-24">
<div class="card__image mb-0">
{% if selected_offering.service.logo %}
{% if selected_offering.service.get_logo %}
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
{% endif %}
</div>

View file

@ -1,12 +1,36 @@
{% extends 'base.html' %}
{% load static %}
{% load compress %}
{% load contact_tags %}
{% load json_ld_tags %}
{% block title %}Managed {{ offering.service.name }} on {{ offering.cloud_provider.name }}{% endblock %}
{% block extra_js %}
<script defer src="{% static "js/price-calculator.js" %}"></script>
{% if debug %}
<!-- Development: Load individual modules for easier debugging -->
<script defer src="{% static 'js/price-calculator.js' %}"></script>
{% else %}
<!-- Production: Load compressed bundle -->
{% compress js %}
<script src="{% static 'js/price-calculator/dom-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/pricing-data-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/plan-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/addon-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/ui-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/order-manager.js' %}"></script>
<script src="{% static 'js/price-calculator/price-calculator.js' %}"></script>
<script>
// Initialize calculator when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Check if we're on a page that needs the price calculator
if (document.getElementById('cpuRange')) {
window.priceCalculator = new PriceCalculator();
}
});
</script>
{% endcompress %}
{% endif %}
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
{% json_ld_structured_data %}
@ -32,7 +56,7 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if offering.service.logo %}
{% if offering.service.get_logo %}
<a href="{{ offering.service.get_absolute_url }}">
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
@ -219,11 +243,11 @@
<div class="mb-4">
<label for="cpuRange" class="form-label d-flex justify-content-between">
<span>vCPUs</span>
<span class="fw-bold" id="cpuValue">2</span>
<span class="fw-bold" id="cpuValue">0.5</span>
</label>
<input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
<input type="range" class="form-range" id="cpuRange" min="0.25" max="32" value="0.5" step="0.25">
<div class="d-flex justify-content-between text-muted small">
<span id="cpuMinDisplay">1</span>
<span id="cpuMinDisplay">0.25</span>
<span id="cpuMaxDisplay">32</span>
</div>
</div>
@ -232,11 +256,11 @@
<div class="mb-4">
<label for="memoryRange" class="form-label d-flex justify-content-between">
<span>Memory (GB)</span>
<span class="fw-bold" id="memoryValue">4</span>
<span class="fw-bold" id="memoryValue">1</span>
</label>
<input type="range" class="form-range" id="memoryRange" min="1" max="128" value="4" step="1">
<input type="range" class="form-range" id="memoryRange" min="0.25" max="128" value="1" step="0.25">
<div class="d-flex justify-content-between text-muted small">
<span id="memoryMinDisplay">1 GB</span>
<span id="memoryMinDisplay">0.25 GB</span>
<span id="memoryMaxDisplay">128 GB</span>
</div>
</div>
@ -254,10 +278,10 @@
</div>
</div>
<!-- Instances Slider -->
<!-- Replicas Slider -->
<div class="mb-4">
<label for="instancesRange" class="form-label d-flex justify-content-between">
<span>Instances</span>
<span>Replicas</span>
<span class="fw-bold" id="instancesValue">1</span>
</label>
<input type="range" class="form-range" id="instancesRange" min="1" max="1" value="1" step="1">
@ -281,7 +305,7 @@
<!-- Addons Section - Hidden by default, shown by JS if addons exist -->
<div class="mb-4" id="addonsSection" style="display: none;">
<label class="form-label">Add-ons (Optional)</label>
<label class="form-label">Add-ons</label>
<div id="addonsContainer">
<!-- Add-ons will be dynamically populated here -->
</div>
@ -332,7 +356,7 @@
<div class="fw-bold" id="planMemory"></div>
</div>
<div class="col-3">
<small class="text-muted">Instances</small>
<small class="text-muted">Replicas</small>
<div class="fw-bold" id="planInstances"></div>
</div>
</div>
@ -348,18 +372,41 @@
<!-- Pricing Breakdown -->
<div class="border-top pt-3">
<div class="d-flex justify-content-between mb-2">
<span>Managed Service (incl. Compute)</span>
<span class="fw-bold">CHF <span id="managedServicePrice">0.00</span></span>
<!-- Managed Service Section -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<span>Managed Service (incl. Compute)</span>
<button class="btn btn-link btn-sm p-0 ms-2 text-muted" type="button" data-bs-toggle="collapse" data-bs-target="#managedServiceIncludes" aria-expanded="false" aria-controls="managedServiceIncludes" title="Show what's included">
<i class="bi bi-info-circle" id="managedServiceToggleIcon"></i>
</button>
</div>
<span class="fw-bold">CHF <span id="managedServicePrice">0.00</span></span>
</div>
<!-- What's included in managed service (collapsible) -->
<div class="collapse" id="managedServiceIncludes">
<div class="ps-3 border-start border-2 border-success-subtle">
<div class="small text-muted mb-2">
<i class="bi bi-check-circle-fill text-success me-1"></i>
<em>Included in managed service price:</em>
</div>
<div id="managedServiceIncludesContainer">
<!-- Required add-ons will be dynamically added here -->
</div>
</div>
</div>
</div>
<!-- Storage - separate billable item -->
<div class="d-flex justify-content-between mb-2">
<span>Storage - <span id="storageAmount">20</span> GB</span>
<span class="fw-bold">CHF <span id="storagePrice">0.00</span></span>
</div>
<!-- Addons Pricing -->
<!-- Optional Addons Pricing -->
<div id="addonPricingContainer">
<!-- Addon pricing will be dynamically added here -->
<!-- Optional addon pricing will be dynamically added here -->
</div>
<hr>
@ -369,7 +416,7 @@
</div>
<small class="text-muted mt-2 d-block">
<i class="bi bi-info-circle me-1"></i>
Monthly pricing based on 30 days (720 hours). Billing is conducted per hour.
Monthly pricing based on 30 days (720 hours). Metering is conducted per hour. Introductory pricing subject to change.
</small>
</div>
</div>

View file

@ -150,7 +150,7 @@
<div class="card__header">
<div class="d-flex align-items-start mb-3">
<div class="me-3">
{% if offering.service.logo %}
{% if offering.service.get_logo %}
<img src="{{ offering.service.get_logo.url }}"
alt="{{ offering.service.name }}"
style="max-height: 50px; max-width: 100px; object-fit: contain;">
@ -163,7 +163,7 @@
</a>
</h3>
<div class="d-flex align-items-center">
{% if offering.cloud_provider.logo %}
{% if offering.cloud_provider.get_logo %}
<a href="{{ offering.get_absolute_url }}" class="me-2">
<img src="{{ offering.cloud_provider.get_logo.url }}"
alt="{{ offering.cloud_provider.name }}"

View file

@ -23,7 +23,7 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if partner.logo %}
{% if partner.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -99,27 +99,6 @@
</ul>
</div>
<!-- Cloud Providers -->
{% if partner.cloud_providers.exists %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Cloud Providers</h3>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for provider in partner.cloud_providers.all %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ provider.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-cloud-fill" viewBox="0 0 16 16">
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383" fill="#9A63EC"/>
</svg>
</span>
<span>{{ provider.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<!-- Related Articles -->
{% if related_articles %}
<div class="mb-40">
@ -153,7 +132,7 @@
<h2 class="fs-50 fw-semibold lh-1 mb-12">{{ partner.name }}</h2>
</header>
<div class="fs-19 text-gray-500">
<button class="btn btn-tertiary btn-sm mr-12">Servala Consulting Partner</button>
<button class="btn btn-tertiary btn-sm mr-12">{{ partner.get_category_display_badge }}</button>
</div>
</div>
@ -168,23 +147,26 @@
<!-- Services -->
{% if services %}
<div class="pt-40">
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">Consulting for Services</h3>
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">
{% if partner.category == 'TRAINING' %}
Training for Services
{% else %}
Consulting for Services
{% endif %}
</h3>
<div class="row">
{% for service in services %}
<div class="col-12 col-md-6 mb-30">
<div class="card h-100 d-flex flex-column">
{% if service.logo %}
<div class="d-flex justify-content-between">
{% if service.logo %}
<div class="card__image flex-shrink-0">
<a href="{{ service.get_absolute_url }}">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
<div class="card__content d-flex flex-column flex-grow-1">
{% if service.get_logo %}
<div class="d-flex align-items-center justify-content-start" style="height: 60px; margin-bottom: 1rem; width: 100%;">
<a href="{{ service.get_absolute_url }}" class="clickable-link" style="display: block; width: 120px; height: 60px;">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo"
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
</a>
</div>
{% endif %}
</div>
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<h3 class="card__title"><a href="{{ service.get_absolute_url }}" class="text-decoration-none">{{ service.name }}</a></h3>
<p class="card__subtitle">

View file

@ -94,6 +94,23 @@
</div>
</div>
<!-- Category Filter -->
<div class="pt-24 mb-24">
<div class="d-flex justify-content-between align-items-center h-33 mb-5px" role="button">
<h3 class="sidebar-title mb-0">Category</h3>
</div>
<div>
<select class="form-select" id="category" name="category" @change="submitForm()">
<option value="">All Categories</option>
{% for category_value, category_label in partner_categories %}
<option value="{{ category_value }}" {% if request.GET.category == category_value %}selected{% endif %}>
{{ category_label }}
</option>
{% endfor %}
</select>
</div>
</div>
<!-- Filter Actions -->
<div class="d-flex gap-2">
<a href="{% url 'services:partner_list' %}" class="btn btn-outline-secondary btn-sm">Clear</a>
@ -110,17 +127,21 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{{ partner.get_absolute_url }}')">
{% if partner.category %}
<div class="d-flex justify-content-end mb-2">
<span class="btn btn-secondary btn-sm">{{ partner.get_category_display_badge }}</span>
</div>
{% endif %}
<div class="card__content d-flex flex-column flex-grow-1">
<div class="card__header">
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3">
<a href="{{ partner.get_absolute_url }}" class="clickable-link">
<img src="{{ partner.get_logo.url }}"
alt="{{ partner.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">
</a>
</div>
{% if partner.get_logo %}
<div class="d-flex align-items-center justify-content-start" style="height: 80px; margin-bottom: 1rem; width: 100%;">
<a href="{{ partner.get_absolute_url }}" class="clickable-link" style="display: block; width: 250px; height: 100px;">
<img src="{{ partner.get_logo.url }}" alt="{{ partner.name }} logo"
style="width: 100%; height: 100%; object-fit: contain; object-position: left center; display: block;">
</a>
</div>
{% endif %}
<h3 class="card__title">
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
</h3>
@ -135,7 +156,9 @@
{% if partner.website %}
<a href="{{ partner.website }}" class="btn btn-primary btn-sm clickable-button" target="_blank">Visit Website</a>
{% endif %}
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">Available Services</a>
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">
{% if partner.category == 'TRAINING' %}Available Trainings{% else %}Available Services{% endif %}
</a>
</div>
</div>
</div>

View file

@ -159,65 +159,11 @@
<div class="col-12">
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
<!-- Pricing Model Explanation -->
<!-- Pricing Model Explanation - Internal Product Manager View -->
<div class="card mb-4">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#pricingExplanation" aria-expanded="false" aria-controls="pricingExplanation" style="cursor: pointer;">
<h5 class="mb-0">
<i class="bi bi-info-circle me-2"></i>How Our Pricing Works
<small class="text-muted ms-2">(Click to expand)</small>
</h5>
</div>
<div class="collapse" id="pricingExplanation">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Price Components</h6>
<ul class="list-unstyled">
<li class="mb-2">
<span class="badge">Compute Plan Price</span>
<span class="ms-2">Base infrastructure cost for CPU, memory, and storage</span>
</li>
<li class="mb-2">
<span class="badge">SLA Base</span>
<span class="ms-2">Fixed cost for the service level agreement</span>
</li>
<li class="mb-2">
<span class="badge">Units × SLA Per Unit</span>
<span class="ms-2">Variable cost based on scale/usage</span>
</li>
<li class="mb-2">
<span class="badge">Mandatory Add-ons</span>
<span class="ms-2">Required additional services (backup, monitoring, etc.)</span>
</li>
</ul>
</div>
<div class="col-md-6">
<h6>Final Price Formula</h6>
<div class="bg-light p-3 rounded">
<code>
<span style="color: #0d6efd;">Compute Plan Price</span> +
<span style="color: #6f42c1;">SLA Base</span> +
<span style="color: #fd7e14;">(Units × SLA Per Unit)</span> +
<span style="color: #dc3545;">Mandatory Add-ons</span> =
<strong style="color: #198754;">Final Price</strong>
</code>
</div>
<p class="mt-3 mb-0">
<small class="text-muted">
This transparent pricing model ensures you understand exactly what you're paying for.
The table below breaks down each component for every service variant we offer.
<br><br>
<strong>Price Comparisons:</strong> When enabled, you'll see:
<br><span class="badge bg-secondary">External Providers</span> - Competitor prices from AWS, Google Cloud, etc.
<br><span class="badge bg-success">Other Servala Providers</span> - Same service specs on different cloud providers within our network
</small>
</p>
</div>
</div>
</div>
</div>
<a href="https://vshnwiki.atlassian.net/wiki/x/BQDYGg" target="_blank">See VSHN Wiki for a detailed explanation</a>
</div>
<!-- Filter Form -->
<div class="card mb-4">
<div class="card-header">
@ -226,16 +172,18 @@
<div class="card-body">
<form method="get" class="row g-3" id="filter-form">
<div class="col-md-3">
<label for="cloud_provider" class="form-label">Cloud Provider</label>
<label for="cloud_provider" class="form-label">Cloud Provider <span class="text-danger">*</span></label>
<select name="cloud_provider" id="cloud_provider" class="form-select filter-select">
<option value="">-- Select Cloud Provider --</option>
{% for provider in all_cloud_providers %}
<option value="{{ provider }}" {% if provider == filter_cloud_provider %}selected{% endif %}>{{ provider }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label for="service" class="form-label">Service</label>
<label for="service" class="form-label">Service <span class="text-danger">*</span></label>
<select name="service" id="service" class="form-select filter-select">
<option value="">-- Select Service --</option>
{% for service in all_services %}
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>{{ service }}</option>
{% endfor %}
@ -295,16 +243,17 @@
{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level or show_discount_details or show_addon_details or show_price_comparison %}
<div class="alert alert-info">
<strong>Active Filters:</strong>
{% if filter_cloud_provider %}<span class="badge me-1">Cloud Provider: {{ filter_cloud_provider }}</span>{% endif %}
{% if filter_service %}<span class="badge me-1">Service: {{ filter_service }}</span>{% endif %}
{% if filter_compute_plan_group %}<span class="badge me-1">Group: {{ filter_compute_plan_group }}</span>{% endif %}
{% if filter_service_level %}<span class="badge me-1">Service Level: {{ filter_service_level }}</span>{% endif %}
{% if show_discount_details %}<span class="badge me-1">Discount Details</span>{% endif %}
{% if show_addon_details %}<span class="badge me-1">Addon Details</span>{% endif %}
{% if show_price_comparison %}<span class="badge me-1">Price Comparisons</span>{% endif %}
<ul class="mt-2 mb-4">
{% if filter_cloud_provider %}<li>Cloud Provider: {{ filter_cloud_provider }}</li>{% endif %}
{% if filter_service %}<li>Service: {{ filter_service }}</li>{% endif %}
{% if filter_compute_plan_group %}<li>Group: {{ filter_compute_plan_group }}</li>{% endif %}
{% if filter_service_level %}<li>Service Level: {{ filter_service_level }}</li>{% endif %}
{% if show_discount_details %}<li>Discount Details</li>{% endif %}
{% if show_addon_details %}<li>Addon Details</li>{% endif %}
{% if show_price_comparison %}<li>Price Comparisons</li>{% endif %}
</ul>
</div>
{% endif %}
{% if pricing_data_by_group_and_service_level %}
{% for group_name, service_levels in pricing_data_by_group_and_service_level.items %}
<div class="mb-5 border rounded p-3">
@ -313,18 +262,11 @@
{# Display group description and node_label from first available plan #}
{% 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 %}
<p class="text-muted mb-2"><strong>Description:</strong> {{ representative_plan.compute_plan_group_description }}</p>
{% endif %}
{% if representative_plan.compute_plan_group_node_label %}
<p class="text-muted mb-3"><strong>Node Label:</strong> <code>{{ representative_plan.compute_plan_group_node_label }}</code></p>
{% endif %}
{% with pricing_data.0 as representative_plan %}
{# Display storage pricing for this cloud provider #}
{% if representative_plan.storage_plans %}
<div class="mb-3">
<p class="text-muted mb-2"><strong>Storage Options:</strong></p>
<p class="text-muted mb-2"><strong>Storage Options</strong></p>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-secondary">
@ -365,30 +307,19 @@
{% if pricing_data %}
{# Display common values for this service level #}
{% with pricing_data.0 as first_row %}
<div class="row mb-3">
<div class="col-md-2">
<strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}
</div>
<div class="col-md-2">
<strong>Service:</strong> {{ first_row.service }}
</div>
<div class="col-md-2">
<strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}
</div>
<div class="col-md-2">
<strong>Variable Unit:</strong> {{ first_row.variable_unit }}
</div>
<div class="col-md-2">
<strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}
</div>
<div class="mb-3">
<ul class="list-unstyled">
<li><strong>Cloud Provider:</strong> {{ first_row.cloud_provider }}</li>
<li><strong>Service:</strong> {{ first_row.service }}</li>
<li><strong>CPU/Memory Ratio:</strong> {{ first_row.cpu_mem_ratio }}</li>
<li><strong>Variable Unit:</strong> {{ first_row.variable_unit }}</li>
<li><strong>Replica Enforce:</strong> {{ first_row.replica_enforce }}</li>
</ul>
</div>
{# Display add-on summary #}
{% if show_addon_details and first_row.mandatory_addons or first_row.optional_addons %}
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Available Add-ons for {{ first_row.service }}</h6>
</div>
<div class="card-body">
{% if first_row.mandatory_addons %}
<div class="mb-3">
@ -682,9 +613,9 @@
<td class="fw-bold">
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
{% if comparison.difference > 0 %}
<span class="badge bg-success ms-1">+{{ comparison.difference|floatformat:2 }}</span>
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
{% elif comparison.difference < 0 %}
<span class="badge bg-danger ms-1">{{ comparison.difference|floatformat:2 }}</span>
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
{% endif %}
</td>
</tr>
@ -730,11 +661,11 @@
<td class="fw-bold">
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
{% if comparison.difference > 0 %}
<span class="badge bg-danger ms-1">+{{ comparison.difference|floatformat:2 }}</span>
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
{% elif comparison.difference < 0 %}
<span class="badge bg-success ms-1">{{ comparison.difference|floatformat:2 }}</span>
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
{% elif comparison.difference == 0 %}
<span class="badge bg-info ms-1">Same</span>
(Same)
{% endif %}
</td>
</tr>
@ -772,7 +703,7 @@
{% else %}
<div class="alert alert-info">
<h4>No pricing data available</h4>
<p>{% if filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
<p>{% if not filter_cloud_provider and not filter_service %}Please select both a <strong>Cloud Provider</strong> and <strong>Service</strong> from the filters above to view pricing data.{% elif not filter_cloud_provider %}Please select a <strong>Cloud Provider</strong> from the filters above.{% elif not filter_service %}Please select a <strong>Service</strong> from the filters above.{% elif filter_cloud_provider or filter_service or filter_compute_plan_group or filter_service_level %}No data matches the selected filters. Try adjusting your filter criteria.{% else %}Please ensure you have active compute plans with prices and VSHNAppCat price configurations.{% endif %}</p>
</div>
{% endif %}
</div>

View file

@ -23,7 +23,7 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if provider.logo %}
{% if provider.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.get_logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -173,9 +173,9 @@
{% for offering in ordered_offerings %}
<div class="col-12 col-md-6 mb-30">
<div class="card h-100 d-flex flex-column">
{% if offering.service.logo or offering.service.is_featured %}
{% if offering.service.get_logo or offering.service.is_featured %}
<div class="d-flex justify-content-between">
{% if offering.service.logo %}
{% if offering.service.get_logo %}
<div class="card__image flex-shrink-0">
<a href="{{ offering.get_absolute_url }}">
<img src="{{ offering.service.get_logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">

View file

@ -98,7 +98,7 @@
<div class="d-flex align-items-start" style="height: 100px; margin-bottom: 1rem;">
<div class="me-3 d-flex align-items-center" style="height: 100%;">
<a href="{{ provider.get_absolute_url }}" class="clickable-link">
{% if provider.logo %}
{% if provider.get_logo %}
<img src="{{ provider.get_logo.url }}"
alt="{{ provider.name }}"
style="max-height: 100px; max-width: 250px; object-fit: contain;">

View file

@ -22,7 +22,7 @@
<div class="pr-lg-6">
<!-- Logo -->
<div class="mb-40 border rounded-4 p-4 d-flex align-items-center justify-content-center" style="min-height: 160px;">
{% if service.logo %}
{% if service.get_logo %}
<img class="img-fluid w-100 w-lg-auto" src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
{% endif %}
</div>
@ -51,26 +51,59 @@
{% endif %}
<!-- Consulting Partners -->
{% if service.consulting_partners.exists %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
<p>If you want to get the most out of your {{ service.name }}, our consulting partners can help you optimize your setup and application:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in service.consulting_partners.all %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% with consulting_partners=service.consulting_partners.all|dictsort:"order" %}
{% regroup consulting_partners by category as partners_by_category %}
{% for category_group in partners_by_category %}
{% if category_group.grouper == "CONSULTING" and category_group.list %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Consulting Partners</h3>
<p>If you want to get the most out of your {{ service.name }} service, our consulting partners can help you optimize your setup and application:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in category_group.list %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6m-5.784 6A2.24 2.24 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.3 6.3 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1zM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
{% endwith %}
<!-- Training Partners -->
{% with training_partners=service.consulting_partners.all|dictsort:"order" %}
{% regroup training_partners by category as partners_by_category %}
{% for category_group in partners_by_category %}
{% if category_group.grouper == "TRAINING" and category_group.list %}
<div class="mb-40">
<h3 class="fw-semibold mb-12">Training Partners</h3>
<p>Looking to upskill your team on {{ service.name }}? Our training partners offer comprehensive courses and workshops:</p>
<ul class="list-unstyled space-y-12 fs-19 ps-0">
{% for partner in category_group.list %}
<li>
<a class="d-flex align-items-center text-gray-500 h-32 lh-32" href="{{ partner.get_absolute_url }}">
<span class="pr-10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-mortarboard-fill" viewBox="0 0 16 16">
<path d="M8.211 2.047a.5.5 0 0 0-.422 0l-7.5 3.5a.5.5 0 0 0 .025.917l7.5 3a.5.5 0 0 0 .372 0L14 7.14V13a1 1 0 0 0-1 1v2h3v-2a1 1 0 0 0-1-1V6.739l.686-.275a.5.5 0 0 0 .025-.917l-7.5-3.5Z" fill="#9A63EC"/>
<path d="M4.176 9.032a.5.5 0 0 0-.656.327l-.5 1.7a.5.5 0 0 0 .294.605l4.5 1.8a.5.5 0 0 0 .372 0l4.5-1.8a.5.5 0 0 0 .294-.605l-.5-1.7a.5.5 0 0 0-.656-.327L8 10.466 4.176 9.032Z" fill="#9A63EC"/>
</svg>
</span>
<span>{{ partner.name }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
{% endwith %}
<!-- External Links -->
{% if service.external_links.exists %}
@ -182,7 +215,7 @@
class="text-decoration-none" style="display: block;">
<div class="card h-100 clickable-card">
<div class="card-body text-center">
{% if offering.cloud_provider.logo %}
{% if offering.cloud_provider.get_logo %}
<div class="mb-3 d-flex align-items-center justify-content-center" style="height: 80px;">
<img src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
class="img-fluid" style="max-height: 60px; object-fit: contain;">

View file

@ -152,9 +152,9 @@
<div class="col-12 col-md-6 col-lg-4 mb-30">
<div class="card {% if service.is_featured %}card-featured{% endif %} h-100 d-flex flex-column clickable-card"
onclick="cardClicked(event, '{% if request.GET.cloud_provider %}{% for offering in service.offerings.all %}{% if offering.cloud_provider.id|stringformat:"i" == request.GET.cloud_provider %}{% url "services:offering_detail" offering.cloud_provider.slug service.slug %}{% endif %}{% endfor %}{% else %}{{ service.get_absolute_url }}{% endif %}')">
{% if service.logo or service.is_featured or service.is_coming_soon %}
{% if service.get_logo or service.is_featured or service.is_coming_soon %}
<div class="d-flex justify-content-between mb-3">
{% if service.logo %}
{% if service.get_logo %}
<div class="card__image flex-shrink-0">
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
</div>

View file

@ -10,6 +10,7 @@ register = template.Library()
def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=None):
"""
Render an image from the image library by slug or ID.
Automatically handles SVG files with proper rendering.
Usage:
{% image_library_img "my-image-slug" css_class="img-fluid" %}
@ -25,24 +26,52 @@ def image_library_img(slug_or_id, css_class="", alt_text="", width=None, height=
# Use provided alt_text or fall back to image's alt_text
final_alt_text = alt_text or image.alt_text
# Build HTML attributes
attrs = {
"src": image.image.url,
"alt": final_alt_text,
}
# Check if it's an SVG file
if image.is_svg():
# For SVG files, use object tag for better rendering
attrs = {
"data": image.image.url,
"type": "image/svg+xml",
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if width:
attrs["width"] = width
if height:
attrs["height"] = height
if height:
attrs["height"] = height
# Build the HTML
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html("<img {}/>", attr_string)
# Build the object tag with img fallback
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html(
'<object {}><img src="{}" alt="{}" class="{}"/></object>',
attr_string,
image.image.url,
final_alt_text,
css_class or "",
)
else:
# For raster images, use img tag
attrs = {
"src": image.image.url,
"alt": final_alt_text,
}
if css_class:
attrs["class"] = css_class
if width:
attrs["width"] = width
if height:
attrs["height"] = height
# Build the HTML
attr_string = " ".join(f'{k}="{v}"' for k, v in attrs.items())
return format_html("<img {}/>", attr_string)
except ImageLibrary.DoesNotExist:
# Return empty string or placeholder if image not found

View file

@ -1,7 +1,9 @@
# hub/services/templatetags/json_ld_tags.py
from datetime import datetime, time
from django import template
from django.urls import resolve, Resolver404
from django.utils.safestring import mark_safe
from django.utils import timezone as django_timezone
import json
register = template.Library()
@ -106,29 +108,38 @@ def json_ld_structured_data(context):
}
elif view_name == "service_detail" and "service" in context:
service = context["service"]
service_url = request.build_absolute_uri()
data = organization_data
data = {
"@context": "https://schema.org",
"@type": "Product",
"name": service.name,
"description": service.description,
"url": service_url,
"category": "Cloud Service",
}
# service = context["service"]
# service_url = request.build_absolute_uri()
# # Check if service has offerings with pricing
# has_offerings = hasattr(service, "offerings") and service.offerings.exists()
# Add image if available
if hasattr(service, "get_logo") and service.get_logo:
data["image"] = request.build_absolute_uri(service.get_logo.url)
# if has_offerings:
# # Use Product type when we have offerings (which provide the required offers data)
# data = {
# "@context": "https://schema.org",
# "@type": "Product",
# "name": service.name,
# "description": service.description,
# "url": service_url,
# "category": "Cloud Service",
# }
# Add offerings if available
if hasattr(service, "offerings") and service.offerings.exists():
data["offers"] = {
"@type": "AggregateOffer",
"availability": "https://schema.org/InStock",
"offerCount": service.offerings.count(),
}
# # Add image if available
# if hasattr(service, "get_logo") and service.get_logo:
# data["image"] = request.build_absolute_uri(service.get_logo.url)
# # Add offerings
# data["offers"] = {
# "@type": "AggregateOffer",
# "availability": "https://schema.org/InStock",
# "offerCount": service.offerings.count(),
# }
# else:
# # Use Organization data when no offerings are available
# # This avoids Google Search Console errors for Product without required fields
# data = organization_data
elif view_name == "provider_detail" and "provider" in context:
provider = context["provider"]
@ -288,6 +299,83 @@ def json_ld_structured_data(context):
"seller": {"@type": "Organization", "name": "VSHN"},
}
elif view_name == "article_list":
data = {
"@context": "https://schema.org",
"@type": "CollectionPage",
"name": "Servala Articles",
"url": f"{base_url}/articles/",
"description": "Read our latest articles about cloud services, best practices, and industry insights.",
"isPartOf": {"@type": "WebSite", "name": "Servala", "url": base_url},
}
elif view_name == "article_detail" and "article" in context:
article = context["article"]
article_url = request.build_absolute_uri()
data = {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.title,
"description": article.excerpt,
"url": article_url,
"author": {
"@type": "Person",
"name": article.author.get_full_name() or article.author.username,
},
"publisher": {
"@type": "Organization",
"name": "Servala",
"logo": {
"@type": "ImageObject",
"url": f"{base_url}/static/img/servala-logo.png",
},
},
}
# Add publication date using article_date field
article_datetime = django_timezone.make_aware(
datetime.combine(article.article_date, time.min)
)
data["datePublished"] = article_datetime.isoformat()
# Add modification date
if article.updated_at:
data["dateModified"] = article.updated_at.isoformat()
# Add image using the model's get_image property or get_og_image
if article.get_og_image:
data["image"] = request.build_absolute_uri(article.get_og_image.url)
elif article.get_image:
data["image"] = request.build_absolute_uri(article.get_image.url)
# Add keywords from meta_keywords field
if article.meta_keywords:
data["keywords"] = article.meta_keywords
# Add main entity of page
data["mainEntityOfPage"] = {
"@type": "WebPage",
"@id": article_url,
}
# Add about field based on related entities
if article.related_service:
data["about"] = {
"@type": "SoftwareApplication",
"name": article.related_service.name,
}
elif article.related_consulting_partner:
data["about"] = {
"@type": "Organization",
"name": article.related_consulting_partner.name,
}
elif article.related_cloud_provider:
data["about"] = {
"@type": "Organization",
"name": article.related_cloud_provider.name,
}
else:
# Default to organization data if no specific page type matches
data = organization_data

View file

@ -63,9 +63,9 @@ def social_meta_tags(context):
article = context["article"]
title = f"Servala - {article.title}"
description = article.excerpt
# Use article image if available, otherwise default
if article.image:
image_url = request.build_absolute_uri(article.image.url)
# Use OG image if available, otherwise fall back to article image, then default
if article.get_og_image:
image_url = request.build_absolute_uri(article.get_og_image.url)
# Determine og:type based on view
og_type = "website" # default

View file

@ -1,6 +1,7 @@
from django.shortcuts import render, get_object_or_404
from django.db.models import Q
from hub.services.models import ConsultingPartner, CloudProvider, Service
from hub.services.models.base import PartnerCategory
def partner_list(request):
@ -8,6 +9,7 @@ def partner_list(request):
search_query = request.GET.get("search", "")
service_id = request.GET.get("service", "")
cloud_provider_id = request.GET.get("cloud_provider", "")
category = request.GET.get("category", "")
# Start with all active partners
partners = ConsultingPartner.objects.filter(disable_listing=False).order_by("order")
@ -24,6 +26,9 @@ def partner_list(request):
if cloud_provider_id:
partners = partners.filter(cloud_providers__id=cloud_provider_id)
if category:
partners = partners.filter(category=category)
# Get available services from filtered partners
available_service_ids = partners.values_list("services__id", flat=True).distinct()
available_services = Service.objects.filter(
@ -68,6 +73,7 @@ def partner_list(request):
),
"available_services": available_services,
"available_cloud_providers": available_cloud_providers,
"partner_categories": PartnerCategory.choices,
}
return render(request, "services/partner_list.html", context)

View file

@ -2,14 +2,19 @@ import re
from django.shortcuts import render
from collections import defaultdict
from hub.services.models.pricing import ComputePlan, StoragePlan, ExternalPricePlans, VSHNAppCatPrice
from hub.services.models.pricing import (
ComputePlan,
StoragePlan,
ExternalPricePlans,
VSHNAppCatPrice,
)
from django.contrib.admin.views.decorators import staff_member_required
from django.db import models
def natural_sort_key(obj):
"""Extract numeric parts for natural sorting (works for any plan name)"""
name = obj.name if hasattr(obj, 'name') else str(obj)
name = obj.name if hasattr(obj, "name") else str(obj)
parts = re.split(r"(\d+)", name)
return [int(part) if part.isdigit() else part for part in parts]
@ -130,18 +135,61 @@ def pricelist(request):
filter_compute_plan_group = request.GET.get("compute_plan_group", "")
filter_service_level = request.GET.get("service_level", "")
# Get filter options for dropdowns first (needed for initial page load)
all_cloud_providers = (
ComputePlan.objects.all()
.values_list("cloud_provider__name", flat=True)
.distinct()
.order_by("cloud_provider__name")
)
all_services = (
VSHNAppCatPrice.objects.values_list("service__name", flat=True)
.distinct()
.order_by("service__name")
)
all_compute_plan_groups = list(
ComputePlan.objects.filter(group__isnull=False)
.values_list("group__name", flat=True)
.distinct()
.order_by("group__name")
)
all_compute_plan_groups.append("No Group") # Add option for plans without groups
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
# Only process pricing data if both cloud provider and service are selected
if not filter_cloud_provider or not filter_service:
context = {
"pricing_data_by_group_and_service_level": {},
"show_discount_details": show_discount_details,
"show_addon_details": show_addon_details,
"show_price_comparison": show_price_comparison,
"filter_cloud_provider": filter_cloud_provider,
"filter_service": filter_service,
"filter_compute_plan_group": filter_compute_plan_group,
"filter_service_level": filter_service_level,
"all_cloud_providers": all_cloud_providers,
"all_services": all_services,
"all_compute_plan_groups": all_compute_plan_groups,
"all_service_levels": all_service_levels,
"show_empty_state": True, # Flag to show empty state message
}
return render(request, "services/pricelist.html", context)
# Fetch all compute plans (active and inactive) with related data
compute_plans_qs = ComputePlan.objects.all()
if filter_cloud_provider:
compute_plans_qs = compute_plans_qs.filter(cloud_provider__name=filter_cloud_provider)
compute_plans_qs = compute_plans_qs.filter(
cloud_provider__name=filter_cloud_provider
)
if filter_compute_plan_group:
if filter_compute_plan_group == "No Group":
compute_plans_qs = compute_plans_qs.filter(group__isnull=True)
else:
compute_plans_qs = compute_plans_qs.filter(group__name=filter_compute_plan_group)
compute_plans_qs = compute_plans_qs.filter(
group__name=filter_compute_plan_group
)
compute_plans = list(
compute_plans_qs
.select_related("cloud_provider", "group")
compute_plans_qs.select_related("cloud_provider", "group")
.prefetch_related("prices")
.order_by("group__order", "group__name", "cloud_provider__name", "name")
)
@ -186,20 +234,30 @@ def pricelist(request):
units = int(plan.vcpus)
else:
continue
base_fee_currencies = set(appcat_price.base_fees.values_list("currency", flat=True))
service_levels = appcat_price.unit_rates.values_list("service_level", flat=True).distinct()
base_fee_currencies = set(
appcat_price.base_fees.values_list("currency", flat=True)
)
service_levels = appcat_price.unit_rates.values_list(
"service_level", flat=True
).distinct()
# Apply service level filter
if filter_service_level:
service_levels = [
sl for sl in service_levels
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl] == filter_service_level
sl
for sl in service_levels
if dict(VSHNAppCatPrice.ServiceLevel.choices)[sl]
== filter_service_level
]
for service_level in service_levels:
unit_rate_currencies = set(
appcat_price.unit_rates.filter(service_level=service_level).values_list("currency", flat=True)
appcat_price.unit_rates.filter(
service_level=service_level
).values_list("currency", flat=True)
)
# Find currencies that exist across all pricing components
matching_currencies = plan_currencies.intersection(base_fee_currencies).intersection(unit_rate_currencies)
matching_currencies = plan_currencies.intersection(
base_fee_currencies
).intersection(unit_rate_currencies)
if not matching_currencies:
continue
for currency in matching_currencies:
@ -217,7 +275,10 @@ def pricelist(request):
compute_plan_price = plan.get_price(currency)
base_fee = appcat_price.get_base_fee(currency, service_level)
unit_rate = appcat_price.get_unit_rate(currency, service_level)
if any(price is None for price in [compute_plan_price, base_fee, unit_rate]):
if any(
price is None
for price in [compute_plan_price, base_fee, unit_rate]
):
continue
# Calculate replica enforcement based on service level
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
@ -228,12 +289,27 @@ def pricelist(request):
standard_sla_price = base_fee + (total_units * unit_rate)
# Apply discount if available
discount_breakdown = None
if appcat_price.discount_model and appcat_price.discount_model.active:
discounted_price = appcat_price.discount_model.calculate_discount(unit_rate, total_units)
if (
appcat_price.discount_model
and appcat_price.discount_model.active
):
discounted_price = (
appcat_price.discount_model.calculate_discount(
unit_rate, total_units
)
)
sla_price = base_fee + discounted_price
discount_savings = standard_sla_price - sla_price
discount_percentage = (discount_savings / standard_sla_price) * 100 if standard_sla_price > 0 else 0
discount_breakdown = appcat_price.discount_model.get_discount_breakdown(unit_rate, total_units)
discount_percentage = (
(discount_savings / standard_sla_price) * 100
if standard_sla_price > 0
else 0
)
discount_breakdown = (
appcat_price.discount_model.get_discount_breakdown(
unit_rate, total_units
)
)
else:
sla_price = standard_sla_price
discounted_price = total_units * unit_rate
@ -259,7 +335,9 @@ def pricelist(request):
if addon.addon_type == "BF": # Base Fee
addon_price = addon.get_price(currency, service_level)
elif addon.addon_type == "UR": # Unit Rate
addon_price_per_unit = addon.get_price(currency, service_level)
addon_price_per_unit = addon.get_price(
currency, service_level
)
if addon_price_per_unit:
addon_price = addon_price_per_unit * total_units
addon_info = {
@ -276,89 +354,131 @@ def pricelist(request):
optional_addons.append(addon_info)
service_price_with_addons = price_calculation["total_price"]
final_price = compute_plan_price + service_price_with_addons
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices).get(service_level, service_level)
service_level_display = dict(
VSHNAppCatPrice.ServiceLevel.choices
).get(service_level, service_level)
# Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
external_comparisons = []
internal_comparisons = []
if show_price_comparison:
external_prices = get_external_price_comparisons(plan, appcat_price, currency, service_level)
external_prices = get_external_price_comparisons(
plan, appcat_price, currency, service_level
)
for ext_price in external_prices:
difference = ext_price.amount - final_price
ratio = ext_price.amount / final_price if final_price > 0 else 0
external_comparisons.append({
"plan_name": ext_price.plan_name,
"provider": ext_price.cloud_provider.name,
"description": ext_price.description,
"amount": ext_price.amount,
"currency": ext_price.currency,
"vcpus": ext_price.vcpus,
"ram": ext_price.ram,
"storage": ext_price.storage,
"replicas": ext_price.replicas,
"difference": difference,
"ratio": ratio,
"source": ext_price.source,
"date_retrieved": ext_price.date_retrieved,
"is_internal": False,
})
internal_price_comparisons = get_internal_cloud_provider_comparisons(plan, appcat_price, currency, service_level)
ratio = (
ext_price.amount / final_price if final_price > 0 else 0
)
external_comparisons.append(
{
"plan_name": ext_price.plan_name,
"provider": ext_price.cloud_provider.name,
"description": ext_price.description,
"amount": ext_price.amount,
"currency": ext_price.currency,
"vcpus": ext_price.vcpus,
"ram": ext_price.ram,
"storage": ext_price.storage,
"replicas": ext_price.replicas,
"difference": difference,
"ratio": ratio,
"source": ext_price.source,
"date_retrieved": ext_price.date_retrieved,
"is_internal": False,
}
)
internal_price_comparisons = (
get_internal_cloud_provider_comparisons(
plan, appcat_price, currency, service_level
)
)
for int_price in internal_price_comparisons:
difference = int_price["final_price"] - final_price
ratio = int_price["final_price"] / final_price if final_price > 0 else 0
internal_comparisons.append({
"plan_name": int_price["plan_name"],
"provider": int_price["provider"],
"description": f"Same specs with {int_price['provider']}",
"amount": int_price["final_price"],
"currency": int_price["currency"],
"vcpus": int_price["vcpus"],
"ram": int_price["ram"],
"group_name": int_price["group_name"],
"compute_plan_price": int_price["compute_plan_price"],
"service_price": int_price["service_price"],
"difference": difference,
"ratio": ratio,
"is_internal": True,
})
ratio = (
int_price["final_price"] / final_price
if final_price > 0
else 0
)
internal_comparisons.append(
{
"plan_name": int_price["plan_name"],
"provider": int_price["provider"],
"description": f"Same specs with {int_price['provider']}",
"amount": int_price["final_price"],
"currency": int_price["currency"],
"vcpus": int_price["vcpus"],
"ram": int_price["ram"],
"group_name": int_price["group_name"],
"compute_plan_price": int_price[
"compute_plan_price"
],
"service_price": int_price["service_price"],
"difference": difference,
"ratio": ratio,
"is_internal": True,
}
)
group_name = plan.group.name if plan.group else "No Group"
# Use prefetched storage plans
storage_plans = storage_plans_by_provider.get(plan.cloud_provider_id, [])
pricing_data_by_group_and_service_level[group_name][service_level_display].append({
"cloud_provider": plan.cloud_provider.name,
"service": appcat_price.service.name,
"compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (plan.group.description if plan.group else ""),
"compute_plan_group_node_label": (plan.group.node_label if plan.group else ""),
"storage_plans": storage_plans,
"vcpus": plan.vcpus,
"ram": plan.ram,
"cpu_mem_ratio": plan.cpu_mem_ratio,
"term": plan.get_term_display(),
"currency": currency,
"compute_plan_price": compute_plan_price,
"variable_unit": appcat_price.get_variable_unit_display(),
"units": units,
"replica_enforce": replica_enforce,
"total_units": total_units,
"service_level": service_level_display,
"sla_base": base_fee,
"sla_per_unit": unit_rate,
"sla_price": service_price_with_addons,
"standard_sla_price": base_sla_price,
"discounted_sla_price": (base_fee + discounted_price if appcat_price.discount_model and appcat_price.discount_model.active else None),
"discount_savings": discount_savings,
"discount_percentage": discount_percentage,
"discount_breakdown": discount_breakdown,
"final_price": final_price,
"discount_model": (appcat_price.discount_model.name if appcat_price.discount_model else None),
"has_discount": bool(appcat_price.discount_model and appcat_price.discount_model.active),
"external_comparisons": external_comparisons,
"internal_comparisons": internal_comparisons,
"mandatory_addons": mandatory_addons,
"optional_addons": optional_addons,
"is_active": plan.active,
})
storage_plans = storage_plans_by_provider.get(
plan.cloud_provider_id, []
)
pricing_data_by_group_and_service_level[group_name][
service_level_display
].append(
{
"cloud_provider": plan.cloud_provider.name,
"service": appcat_price.service.name,
"compute_plan": plan.name,
"compute_plan_group": group_name,
"compute_plan_group_description": (
plan.group.description if plan.group else ""
),
"compute_plan_group_node_label": (
plan.group.node_label if plan.group else ""
),
"storage_plans": storage_plans,
"vcpus": plan.vcpus,
"ram": plan.ram,
"cpu_mem_ratio": plan.cpu_mem_ratio,
"term": plan.get_term_display(),
"currency": currency,
"compute_plan_price": compute_plan_price,
"variable_unit": appcat_price.get_variable_unit_display(),
"units": units,
"replica_enforce": replica_enforce,
"total_units": total_units,
"service_level": service_level_display,
"sla_base": base_fee,
"sla_per_unit": unit_rate,
"sla_price": service_price_with_addons,
"standard_sla_price": base_sla_price,
"discounted_sla_price": (
base_fee + discounted_price
if appcat_price.discount_model
and appcat_price.discount_model.active
else None
),
"discount_savings": discount_savings,
"discount_percentage": discount_percentage,
"discount_breakdown": discount_breakdown,
"final_price": final_price,
"discount_model": (
appcat_price.discount_model.name
if appcat_price.discount_model
else None
),
"has_discount": bool(
appcat_price.discount_model
and appcat_price.discount_model.active
),
"external_comparisons": external_comparisons,
"internal_comparisons": internal_comparisons,
"mandatory_addons": mandatory_addons,
"optional_addons": optional_addons,
"is_active": plan.active,
}
)
# Order groups correctly, placing "No Group" last
ordered_groups_intermediate = {}
all_group_names = list(pricing_data_by_group_and_service_level.keys())
@ -366,7 +486,9 @@ def pricelist(request):
all_group_names.remove("No Group")
all_group_names.append("No Group")
for group_name_key in all_group_names:
ordered_groups_intermediate[group_name_key] = pricing_data_by_group_and_service_level[group_name_key]
ordered_groups_intermediate[group_name_key] = (
pricing_data_by_group_and_service_level[group_name_key]
)
# Convert defaultdicts to regular dicts for the template
final_context_data = {}
for group_key, service_levels_dict in ordered_groups_intermediate.items():
@ -394,11 +516,7 @@ def pricelist(request):
)
all_compute_plan_groups.append("No Group") # Add option for plans without groups
all_service_levels = [choice[1] for choice in VSHNAppCatPrice.ServiceLevel.choices]
# If no filter is specified, select the first available provider/service by default
if not filter_cloud_provider and all_cloud_providers:
filter_cloud_provider = all_cloud_providers[0]
if not filter_service and all_services:
filter_service = all_services[0]
context = {
"pricing_data_by_group_and_service_level": final_context_data,
"show_discount_details": show_discount_details,

View file

@ -59,6 +59,7 @@ CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in HTTPS_HOSTS] + [
# Primary website URL
WEBSITE_URL = env.str("WEBSITE_URL", default="https://servala.com")
DISABLE_REDIRECT = env.bool("DISABLE_REDIRECT", default=False)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
@ -76,6 +77,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.sitemaps",
# 3rd party
"compressor",
"django_prose_editor",
"rest_framework",
"schema_viewer",
@ -186,6 +188,25 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = env.path("STATIC_ROOT", default=BASE_DIR / "static")
# Static files configuration
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
"compressor.finders.CompressorFinder",
]
# Django Compressor settings
COMPRESS_ENABLED = True
COMPRESS_OFFLINE = True # Compress during build, not runtime
COMPRESS_CSS_FILTERS = [
"compressor.filters.css_default.CssAbsoluteFilter",
"compressor.filters.cssmin.rCSSMinFilter",
]
COMPRESS_JS_FILTERS = [
"compressor.filters.jsmin.rJSMinFilter",
]
COMPRESS_OUTPUT_DIR = "CACHE"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
@ -258,9 +279,9 @@ JAZZMIN_SETTINGS = {
"services.VSHNAppCatAddon": "single",
"services.ServiceOffering": "single",
"services.Plan": "single",
"services.ImageLibrary": "single",
},
"related_modal_active": True,
}
IMPORT_EXPORT_FORMATS = [CSV]
X_FRAME_OPTIONS = "SAMEORIGIN"

View file

@ -7,10 +7,11 @@ requires-python = ">=3.13"
dependencies = [
"django>=5.2",
"django-admin-sortable2>=2.2.4",
"django-compressor>=4.5.1",
"django-import-export>=4.3.7",
"django-jazzmin>=3.0.1",
"django-nested-admin>=4.1.1",
"django-prose-editor[sanitize]>=0.10.3",
"django-prose-editor[sanitize]>=0.15.0",
"django-schema-viewer>=0.5.2",
"djangorestframework>=3.15.2",
"environs[django]~=14.0",

55
uv.lock generated
View file

@ -81,6 +81,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/66/c3/e804b1f04546c1060e566f35177c346590820a95bfb981d1f6360b419437/django_admin_sortable2-2.2.4-py3-none-any.whl", hash = "sha256:406c5b6d6e84ad982cc6e53c3f34b5db5f0a3f34891126af90c9fb2c372f53d5", size = 90816, upload-time = "2024-11-15T09:43:13.665Z" },
]
[[package]]
name = "django-appconf"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/a9/dcf95ff3fa0620b6818fc02276fbbb8926e7f286039b6d015e56e8b7af39/django-appconf-1.1.0.tar.gz", hash = "sha256:9fcead372f82a0f21ee189434e7ae9c007cbb29af1118c18251720f3d06243e4", size = 15986, upload-time = "2025-02-13T16:09:40.258Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/9e/f3a899991e4aaae4b69c1aa187ba4a32e34742475c91eb13010ee7fbe9db/django_appconf-1.1.0-py3-none-any.whl", hash = "sha256:7abd5a163ff57557f216e84d3ce9dac36c37ffce1ab9a044d3d53b7c943dd10f", size = 6389, upload-time = "2025-02-13T16:09:39.133Z" },
]
[[package]]
name = "django-browser-reload"
version = "1.17.0"
@ -103,6 +115,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/90/01755e4a42558b763f7021e9369aa6aa94c2ede7313deed56cb7483834ab/django_cache_url-3.4.5-py2.py3-none-any.whl", hash = "sha256:5f350759978483ab85dc0e3e17b3d53eed3394a28148f6bf0f53d11d0feb5b3c", size = 4760, upload-time = "2023-12-04T17:19:44.355Z" },
]
[[package]]
name = "django-compressor"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-appconf" },
{ name = "rcssmin" },
{ name = "rjsmin" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/30/a9994277ae05082ba5df22c5678a87082253a034927c8d9915c3bf3b8c36/django_compressor-4.5.1.tar.gz", hash = "sha256:c1d8a48a2ee4d8b7f23c411eb9c97e2d88db18a18ba1c9e8178d5f5b8366a822", size = 124734, upload-time = "2024-07-22T09:56:47.554Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/d9/ac374a1f7a432230cdf4d2ffbe957fd0d4d5d6426bf4d5c17f382b0801c4/django_compressor-4.5.1-py2.py3-none-any.whl", hash = "sha256:87741edee4e7f24f3e0b8072d94a990cfb010cb2ca7cc443944da8e193cdea65", size = 145465, upload-time = "2024-07-22T09:56:45.822Z" },
]
[[package]]
name = "django-import-export"
version = "4.3.7"
@ -131,14 +158,14 @@ wheels = [
[[package]]
name = "django-js-asset"
version = "3.0.1"
version = "3.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/02/5d3b57d2ad3c1819aaf921fa6b9d7fbc54ec5d81871a7fb6f4f0b146dd4e/django_js_asset-3.0.1.tar.gz", hash = "sha256:5ad51814edf38d28e6280e6ecad50ee40551363a2f6a6bfe93577dee793dc378", size = 7701, upload-time = "2024-12-17T17:16:33.559Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/91/c63f136f553ec24fc46ccf20ac7292a8df04815b383975b6f3f7f0060217/django_js_asset-3.1.2.tar.gz", hash = "sha256:1fc7584199ed1941ed7c8e7b87ca5524bb0f2ba941561d2a104e88ee9f07bedd", size = 9471, upload-time = "2025-03-04T15:22:49.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/d1/be003d14c880c100d8713b2ca0a286728e6e8b47e50d25e2fab31adc9632/django_js_asset-3.0.1-py3-none-any.whl", hash = "sha256:0b7ee73c45ca65cccbcc2f60cbe8fbc87ff133b543c282cb64fe6c13d7ca4c10", size = 4283, upload-time = "2024-12-17T17:16:34.818Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cf/b208767db5e56b5189829f753eec6a14ee75d074922dc2bd19220b22a34d/django_js_asset-3.1.2-py3-none-any.whl", hash = "sha256:b5ffe376aebbd73b7af886d675ac9f43ca63b39540190fa8409c9f8e79145f68", size = 5905, upload-time = "2025-03-04T15:22:51.152Z" },
]
[[package]]
@ -155,15 +182,15 @@ wheels = [
[[package]]
name = "django-prose-editor"
version = "0.10.3"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-js-asset" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/2d/28aeb5d89b96b0850f286f50d1bb51259d3425242035fb9f893073350b22/django_prose_editor-0.10.3.tar.gz", hash = "sha256:4ecef8cbd0286065ec54e18e15419495c80404a408860cad2189180724f93d49", size = 260180, upload-time = "2025-01-21T18:21:41.748Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/96/d5d3b2e3ac0a7040f36082a1f8b90ac9d7886ed0a656c32eafae2ee7da6b/django_prose_editor-0.15.2.tar.gz", hash = "sha256:b178707d853a1572e91fe0dbb34e62cf2001a41b152f881b2e2161b26d5ee7a7", size = 853807, upload-time = "2025-07-08T09:19:47.776Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/fd/922e6f3e9827c31a9bc00751ac02f0798c5beeae4994a97dbb978a1a55a2/django_prose_editor-0.10.3-py3-none-any.whl", hash = "sha256:776f0eb86791e72278f55abc98e426620f9bff5554a46c513069f8c089d80960", size = 261340, upload-time = "2025-01-21T18:21:45.122Z" },
{ url = "https://files.pythonhosted.org/packages/14/de/812ec0668f38e4ea6717fe468292bd6f9acf16120ff4894789a06e42c789/django_prose_editor-0.15.2-py3-none-any.whl", hash = "sha256:95daa9ed16f684bb72515eed8038789d373b16e193d57fab8c9dde7bf6abd79b", size = 872231, upload-time = "2025-07-08T09:19:50.282Z" },
]
[package.optional-dependencies]
@ -351,6 +378,18 @@ wheels = [
{ 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 = "rcssmin"
version = "1.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/26/f38d49c21d933e3e4320ed31c6025c381dbd973e9936edd0af52ce521534/rcssmin-1.1.2.tar.gz", hash = "sha256:bc75eb75bd6d345c0c51fd80fc487ddd6f9fd409dd7861b3fe98dee85018e1e9", size = 582213, upload-time = "2023-10-03T19:57:48.536Z" }
[[package]]
name = "rjsmin"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/1c/c0355e8b8b8aca9c0d43519d2a7c473940deae0297ff8544eff359d7f715/rjsmin-1.2.2.tar.gz", hash = "sha256:8c1bcd821143fecf23242012b55e13610840a839cd467b358f16359010d62dae", size = 420634, upload-time = "2023-10-05T07:19:30.857Z" }
[[package]]
name = "servala-fe"
version = "0.1.0"
@ -358,6 +397,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-admin-sortable2" },
{ name = "django-compressor" },
{ name = "django-import-export" },
{ name = "django-jazzmin" },
{ name = "django-nested-admin" },
@ -381,10 +421,11 @@ requires-dist = [
{ name = "django", specifier = ">=5.2" },
{ name = "django-admin-sortable2", specifier = ">=2.2.4" },
{ name = "django-browser-reload", marker = "extra == 'dev'", specifier = "~=1.13" },
{ name = "django-compressor", specifier = ">=4.5.1" },
{ name = "django-import-export", specifier = ">=4.3.7" },
{ name = "django-jazzmin", specifier = ">=3.0.1" },
{ name = "django-nested-admin", specifier = ">=4.1.1" },
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.15.0" },
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
{ name = "djangorestframework", specifier = ">=3.15.2" },
{ name = "environs", extras = ["django"], specifier = "~=14.0" },