Compare commits
No commits in common. "main" and "service-addons" have entirely different histories.
main
...
service-ad
81 changed files with 2433 additions and 6042 deletions
|
@ -2,7 +2,7 @@ name: Django Tests
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: ["*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
@ -31,4 +31,4 @@ jobs:
|
|||
-w /app \
|
||||
-e SECRET_KEY=dummysecretkey \
|
||||
website:test \
|
||||
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'
|
||||
sh -c 'uv run --extra dev manage.py migrate --noinput && uv run --extra dev manage.py test hub.services.tests --verbosity=2'
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,4 +15,3 @@ wheels/
|
|||
media/
|
||||
deployment/secret.yaml
|
||||
*.json
|
||||
static/
|
||||
|
|
|
@ -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=dummy python -m hub build_assets --force
|
||||
&& SECRET_KEY= python -m hub collectstatic --noinput
|
||||
|
||||
CMD ["/usr/local/bin/runhub.sh"]
|
|
@ -1,81 +0,0 @@
|
|||
# Image Library Migration Status
|
||||
|
||||
## ✅ COMPLETED (First Production Rollout) - UPDATED
|
||||
|
||||
### Models Updated
|
||||
- **Article**: Now inherits from `ImageReference`, with `image_library` field for new images and original `image` field temporarily
|
||||
- **CloudProvider**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
- **ConsultingPartner**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
- **Service**: Now inherits from `ImageReference`, with `image_library` field for new images and original `logo` field temporarily
|
||||
|
||||
### New Properties Added
|
||||
- `Article.get_image()` - Returns image from library or falls back to original field
|
||||
- `CloudProvider.get_logo()` - Returns logo from library or falls back to original field
|
||||
- `ConsultingPartner.get_logo()` - Returns logo from library or falls back to original field
|
||||
- `Service.get_logo()` - Returns logo from library or falls back to original field
|
||||
|
||||
### Templates Updated
|
||||
- ✅ `pages/homepage.html` - Updated service, provider, and partner image references
|
||||
- ✅ `services/article_list.html` - Updated article image references
|
||||
- ✅ `services/article_detail.html` - Updated related service/provider/partner logos
|
||||
- ✅ `services/offering_list.html` - Updated service and provider logos
|
||||
- ✅ `services/offering_detail.html` - Updated service and provider logos
|
||||
- ✅ `services/lead_form.html` - Updated service logo
|
||||
- ✅ `services/partner_detail.html` - Updated partner and service logos
|
||||
- ✅ `services/partner_list.html` - Updated partner logos
|
||||
- ✅ `services/provider_list.html` - Updated provider logos
|
||||
- ✅ `services/provider_detail.html` - Updated provider and service logos
|
||||
- ✅ `services/service_detail.html` - Updated service and provider logos
|
||||
|
||||
### Admin Interface Updated
|
||||
- ✅ `ArticleAdmin` - Updated image_preview to use get_image property
|
||||
- ✅ `ServiceAdmin` - Updated logo_preview to use get_logo property
|
||||
- ✅ `CloudProviderAdmin` - Updated logo_preview to use get_logo property
|
||||
- ✅ `ConsultingPartnerAdmin` - Updated logo_preview to use get_logo property
|
||||
|
||||
### JSON-LD Template Tags Updated
|
||||
- ✅ Updated structured data generation to use new image properties
|
||||
- ✅ Updated logo references for services, providers, and partners
|
||||
|
||||
### Database Migration
|
||||
- ✅ Migration `0041_add_image_library_references` successfully applied
|
||||
- ✅ Migration `0042_fix_image_library_field_name` successfully applied
|
||||
- ✅ All models now have `image_library` foreign key fields to ImageLibrary
|
||||
- ✅ Original image fields preserved for backward compatibility
|
||||
- ✅ Fixed field name conflicts using `%(class)s_references` related_name pattern
|
||||
|
||||
### Admin Interface Enhanced
|
||||
- ✅ **ArticleAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **ServiceAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **CloudProviderAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ **ConsultingPartnerAdmin**: Added fieldsets with `image_library` field visible in "Images" section
|
||||
- ✅ All admin interfaces show both new and legacy fields during transition
|
||||
- ✅ Clear descriptions guide users to use Image Library for new images
|
||||
|
||||
## Current Status
|
||||
The system is now ready for production with dual image support:
|
||||
- **New images**: Can be added through the Image Library
|
||||
- **Legacy images**: Still work through the original fields
|
||||
- **Templates**: Use the new `get_image/get_logo` properties that automatically fall back
|
||||
|
||||
## Next Steps (Future Cleanup)
|
||||
1. **Data Migration**: Create script to migrate existing images to ImageLibrary
|
||||
2. **Admin Updates**: Update admin interfaces to use ImageLibrary selection
|
||||
3. **Template Validation**: Add null checks to remaining templates
|
||||
4. **Field Removal**: Remove legacy image fields after migration is complete
|
||||
5. **Storage Cleanup**: Remove old image files from media directories
|
||||
|
||||
## Benefits Achieved
|
||||
- ✅ Centralized image management through ImageLibrary
|
||||
- ✅ Usage tracking for images
|
||||
- ✅ Backward compatibility maintained
|
||||
- ✅ Enhanced admin experience ready
|
||||
- ✅ Consistent image handling across all models
|
||||
- ✅ Proper fallback mechanisms in place
|
||||
|
||||
## Safety Measures
|
||||
- ✅ Original image fields preserved
|
||||
- ✅ Gradual migration approach
|
||||
- ✅ Fallback properties ensure no broken images
|
||||
- ✅ Database migration tested and applied
|
||||
- ✅ Admin interface maintains functionality
|
176
SVG_SUPPORT.md
176
SVG_SUPPORT.md
|
@ -1,176 +0,0 @@
|
|||
# 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
|
|
@ -6,11 +6,12 @@ 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):
|
||||
if settings.DEBUG or self.disable_redirect:
|
||||
# Skip redirects in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
return self.get_response(request)
|
||||
|
||||
# Check if the host is different from the primary host
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
from .articles import *
|
||||
from .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
from .widgets import *
|
||||
from .services import *
|
||||
|
|
|
@ -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,9 +17,6 @@ class ArticleAdminForm(forms.ModelForm):
|
|||
class Meta:
|
||||
model = Article
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"image_library": ImageLibraryWidget(),
|
||||
}
|
||||
|
||||
def clean_title(self):
|
||||
"""Validate title length"""
|
||||
|
@ -48,7 +45,7 @@ class ArticleAdmin(admin.ModelAdmin):
|
|||
"image_preview",
|
||||
"is_published",
|
||||
"is_featured",
|
||||
"article_date",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"is_published",
|
||||
|
@ -57,51 +54,18 @@ class ArticleAdmin(admin.ModelAdmin):
|
|||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
"article_date",
|
||||
"created_at",
|
||||
)
|
||||
search_fields = ("title", "excerpt", "content", "meta_keywords")
|
||||
prepopulated_fields = {"slug": ("title",)}
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
ordering = ("-article_date",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("title", "slug", "excerpt", "content", "meta_keywords")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library", "og_image"),
|
||||
"description": "Select an image from the Image Library and optionally upload a specific Open Graph image for social sharing.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Publishing",
|
||||
{"fields": ("author", "article_date", "is_published", "is_featured")},
|
||||
),
|
||||
(
|
||||
"Relations",
|
||||
{
|
||||
"fields": (
|
||||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Display image preview in admin list view"""
|
||||
image = obj.get_image
|
||||
if image:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', image.url)
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.image.url
|
||||
)
|
||||
return "No image"
|
||||
|
||||
image_preview.short_description = "Image"
|
||||
|
|
|
@ -1,130 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
|
||||
@admin.register(ImageLibrary)
|
||||
class ImageLibraryAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for the Image Library.
|
||||
"""
|
||||
|
||||
list_display = [
|
||||
"image_thumbnail",
|
||||
"name",
|
||||
"category",
|
||||
"get_dimensions",
|
||||
"get_file_size_display",
|
||||
"usage_count",
|
||||
"uploaded_by",
|
||||
"uploaded_at",
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
"category",
|
||||
"uploaded_at",
|
||||
"uploaded_by",
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
"description",
|
||||
"alt_text",
|
||||
"tags",
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
"width",
|
||||
"height",
|
||||
"file_size",
|
||||
"usage_count",
|
||||
"uploaded_at",
|
||||
"updated_at",
|
||||
"image_preview",
|
||||
]
|
||||
|
||||
prepopulated_fields = {"slug": ("name",)}
|
||||
|
||||
fieldsets = (
|
||||
("Image Information", {"fields": ("name", "slug", "description", "alt_text")}),
|
||||
("Image File", {"fields": ("image", "image_preview")}),
|
||||
("Categorization", {"fields": ("category", "tags")}),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("width", "height", "file_size", "usage_count"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{
|
||||
"fields": ("uploaded_by", "uploaded_at", "updated_at"),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def image_thumbnail(self, obj):
|
||||
"""
|
||||
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,
|
||||
)
|
||||
return "No Image"
|
||||
|
||||
image_thumbnail.short_description = "Thumbnail"
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""
|
||||
Display larger preview in detail view.
|
||||
"""
|
||||
if obj.image:
|
||||
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"
|
||||
|
||||
def get_dimensions(self, obj):
|
||||
"""
|
||||
Display image dimensions.
|
||||
"""
|
||||
if obj.width and obj.height:
|
||||
return f"{obj.width} × {obj.height}"
|
||||
return "Unknown"
|
||||
|
||||
get_dimensions.short_description = "Dimensions"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""
|
||||
Set uploaded_by field to current user if not already set.
|
||||
"""
|
||||
if not change: # Only set on creation
|
||||
obj.uploaded_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
class Media:
|
||||
css = {"all": ("admin/css/image_library.css",)}
|
|
@ -289,7 +289,7 @@ class VSHNAppCatBaseFeeInline(admin.TabularInline):
|
|||
|
||||
model = VSHNAppCatBaseFee
|
||||
extra = 1
|
||||
fields = ("currency", "service_level", "amount")
|
||||
fields = ("currency", "amount")
|
||||
|
||||
|
||||
class VSHNAppCatUnitRateInline(admin.TabularInline):
|
||||
|
@ -322,26 +322,10 @@ class DiscountTierInline(admin.TabularInline):
|
|||
class ProgressiveDiscountModelAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ProgressiveDiscountModel"""
|
||||
|
||||
list_display = ("name", "description", "active", "admin_display_discount_tiers")
|
||||
list_display = ("name", "description", "active")
|
||||
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):
|
||||
|
@ -366,12 +350,7 @@ 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}" for fee in fees])
|
||||
)
|
||||
|
||||
admin_display_base_fees.short_description = "Base Fees"
|
||||
|
@ -582,7 +561,7 @@ class VSHNAppCatAddonBaseFeeInline(admin.TabularInline):
|
|||
|
||||
model = VSHNAppCatAddonBaseFee
|
||||
extra = 1
|
||||
fields = ("currency", "service_level", "amount")
|
||||
fields = ("currency", "amount")
|
||||
|
||||
|
||||
class VSHNAppCatAddonUnitRateInline(admin.TabularInline):
|
||||
|
@ -639,12 +618,7 @@ 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}" for fee in fees])
|
||||
)
|
||||
elif obj.addon_type == "UR": # Unit Rate
|
||||
rates = obj.unit_rates.all()
|
||||
|
|
|
@ -4,33 +4,9 @@ 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):
|
||||
|
@ -58,8 +34,6 @@ class OfferingInline(admin.StackedInline):
|
|||
class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
"""Admin configuration for CloudProvider model"""
|
||||
|
||||
form = CloudProviderAdminForm
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"slug",
|
||||
|
@ -73,27 +47,12 @@ class CloudProviderAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|||
inlines = [OfferingInline]
|
||||
ordering = ("order",)
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "description", "order")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Contact Information",
|
||||
{"fields": ("website", "linkedin", "phone", "email", "address")},
|
||||
),
|
||||
("Settings", {"fields": ("is_featured", "disable_listing")}),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
@ -103,11 +62,8 @@ 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",
|
||||
|
@ -115,36 +71,16 @@ 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", "category", "order")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Contact Information",
|
||||
{"fields": ("website", "linkedin", "phone", "email", "address")},
|
||||
),
|
||||
(
|
||||
"Relations",
|
||||
{"fields": ("services", "cloud_providers"), "classes": ("collapse",)},
|
||||
),
|
||||
("Settings", {"fields": ("is_featured", "disable_listing")}),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
|
|
@ -4,28 +4,8 @@ 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,
|
||||
ServiceOffering,
|
||||
ExternalLink,
|
||||
ExternalLinkOffering,
|
||||
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(),
|
||||
}
|
||||
from ..models import Service, ServiceOffering, ExternalLink, ExternalLinkOffering, Plan
|
||||
|
||||
|
||||
class ExternalLinkInline(admin.TabularInline):
|
||||
|
@ -46,25 +26,14 @@ class ExternalLinkOfferingInline(admin.TabularInline):
|
|||
ordering = ("order", "description")
|
||||
|
||||
|
||||
class PlanPriceInline(admin.TabularInline):
|
||||
"""Inline admin for PlanPrice model"""
|
||||
|
||||
model = PlanPrice
|
||||
extra = 1
|
||||
fields = ("currency", "amount")
|
||||
ordering = ("currency",)
|
||||
|
||||
|
||||
class PlanInline(admin.StackedInline):
|
||||
"""Inline admin for Plan model with sortable ordering"""
|
||||
"""Inline admin for Plan model"""
|
||||
|
||||
model = Plan
|
||||
extra = 1
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "description", "plan_description")}),
|
||||
("Display Options", {"fields": ("is_best", "order")}),
|
||||
(None, {"fields": ("name", "description", "pricing", "plan_description")}),
|
||||
)
|
||||
show_change_link = True
|
||||
|
||||
|
||||
class OfferingInline(admin.StackedInline):
|
||||
|
@ -92,8 +61,6 @@ class OfferingInline(admin.StackedInline):
|
|||
class ServiceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Service model"""
|
||||
|
||||
form = ServiceAdminForm
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"logo_preview",
|
||||
|
@ -108,34 +75,12 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
filter_horizontal = ("categories",)
|
||||
inlines = [ExternalLinkInline, OfferingInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "description", "tagline")}),
|
||||
(
|
||||
"Images",
|
||||
{
|
||||
"fields": ("image_library",),
|
||||
"description": "Select an image from the Image Library.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Configuration",
|
||||
{
|
||||
"fields": (
|
||||
"categories",
|
||||
"features",
|
||||
"is_featured",
|
||||
"is_coming_soon",
|
||||
"disable_listing",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def logo_preview(self, obj):
|
||||
"""Display logo preview in admin list view"""
|
||||
logo = obj.get_logo
|
||||
if logo:
|
||||
return format_html('<img src="{}" style="max-height: 50px;"/>', logo.url)
|
||||
if obj.logo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 50px;"/>', obj.logo.url
|
||||
)
|
||||
return "No logo"
|
||||
|
||||
logo_preview.short_description = "Logo"
|
||||
|
@ -157,59 +102,7 @@ class ServiceAdmin(admin.ModelAdmin):
|
|||
class ServiceOfferingAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for ServiceOffering model"""
|
||||
|
||||
list_display = ("service", "cloud_provider", "plan_count", "total_prices")
|
||||
list_display = ("service", "cloud_provider")
|
||||
list_filter = ("service", "cloud_provider")
|
||||
search_fields = ("service__name", "cloud_provider__name", "description")
|
||||
inlines = [ExternalLinkOfferingInline, PlanInline]
|
||||
|
||||
def plan_count(self, obj):
|
||||
"""Display number of plans for this offering"""
|
||||
return obj.plans.count()
|
||||
|
||||
plan_count.short_description = "Plans"
|
||||
|
||||
def total_prices(self, obj):
|
||||
"""Display total number of plan prices for this offering"""
|
||||
total = sum(plan.plan_prices.count() for plan in obj.plans.all())
|
||||
return f"{total} prices"
|
||||
|
||||
total_prices.short_description = "Total Prices"
|
||||
|
||||
|
||||
@admin.register(Plan)
|
||||
class PlanAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for Plan model with sortable ordering"""
|
||||
|
||||
list_display = ("name", "offering", "is_best", "price_summary", "order")
|
||||
list_filter = ("offering__service", "offering__cloud_provider", "is_best")
|
||||
search_fields = ("name", "description", "offering__service__name")
|
||||
list_editable = ("is_best",)
|
||||
inlines = [PlanPriceInline]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "offering", "description", "plan_description")}),
|
||||
("Display Options", {"fields": ("is_best", "order")}),
|
||||
)
|
||||
|
||||
def price_summary(self, obj):
|
||||
"""Display a summary of prices for this plan"""
|
||||
prices = obj.plan_prices.all()
|
||||
if prices:
|
||||
price_strs = [f"{price.amount} {price.currency}" for price in prices]
|
||||
return ", ".join(price_strs)
|
||||
return "No prices set"
|
||||
|
||||
price_summary.short_description = "Prices"
|
||||
|
||||
|
||||
@admin.register(PlanPrice)
|
||||
class PlanPriceAdmin(admin.ModelAdmin):
|
||||
"""Admin configuration for PlanPrice model"""
|
||||
|
||||
list_display = ("plan", "currency", "amount")
|
||||
list_filter = (
|
||||
"currency",
|
||||
"plan__offering__service",
|
||||
"plan__offering__cloud_provider",
|
||||
)
|
||||
search_fields = ("plan__name", "plan__offering__service__name")
|
||||
ordering = ("plan__offering__service__name", "plan__name", "currency")
|
||||
|
|
|
@ -1,232 +0,0 @@
|
|||
"""
|
||||
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",
|
||||
)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
from django import forms
|
||||
from ..models import Lead
|
||||
from .models import Lead, Plan
|
||||
|
||||
|
||||
class LeadForm(forms.ModelForm):
|
|
@ -1,2 +0,0 @@
|
|||
from .lead import LeadForm
|
||||
from .image_library import ImageLibraryField, ImageLibraryWidget
|
|
@ -1,156 +0,0 @@
|
|||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
|
||||
class ImageLibraryWidget(forms.Select):
|
||||
"""
|
||||
Custom widget for selecting images from the library with thumbnails.
|
||||
"""
|
||||
|
||||
def __init__(self, attrs=None, choices=(), show_thumbnails=True):
|
||||
self.show_thumbnails = show_thumbnails
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def format_value(self, value):
|
||||
"""
|
||||
Format the selected value for display.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
"""
|
||||
Render the widget with thumbnails.
|
||||
"""
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
|
||||
# Add CSS class for styling
|
||||
attrs["class"] = attrs.get("class", "") + " image-library-select"
|
||||
|
||||
# Get all images for the select options
|
||||
images = ImageLibrary.objects.all().order_by("name")
|
||||
|
||||
# Build choices with thumbnails
|
||||
choices = [("", "--- Select an image ---")]
|
||||
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,
|
||||
)
|
||||
|
||||
choice_text = (
|
||||
f"{image.name} ({image.get_category_display()}){thumbnail_html}"
|
||||
)
|
||||
choices.append((image.pk, choice_text))
|
||||
|
||||
# Build the select element
|
||||
select_html = format_html(
|
||||
'<select name="{}" id="{}"{}>{}</select>',
|
||||
name,
|
||||
attrs.get("id", ""),
|
||||
self._build_attrs_string(attrs),
|
||||
self._build_options(choices, value),
|
||||
)
|
||||
|
||||
# Add preview area
|
||||
preview_html = ""
|
||||
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>'
|
||||
"</div>",
|
||||
image.image.url,
|
||||
image.name,
|
||||
image.width or "?",
|
||||
image.height or "?",
|
||||
image.get_file_size_display(),
|
||||
svg_indicator,
|
||||
)
|
||||
except ImageLibrary.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Add JavaScript for preview updates
|
||||
js_html = format_html(
|
||||
"<script>"
|
||||
'document.addEventListener("DOMContentLoaded", function() {{'
|
||||
' const select = document.getElementById("{}");\n'
|
||||
' const previewDiv = select.parentNode.querySelector(".image-preview");\n'
|
||||
' select.addEventListener("change", function() {{'
|
||||
" const imageId = this.value;\n"
|
||||
" if (imageId) {{"
|
||||
' fetch("/admin/services/imagelibrary/" + imageId + "/preview/")'
|
||||
" .then(response => response.json())"
|
||||
" .then(data => {{"
|
||||
" if (previewDiv) {{"
|
||||
" previewDiv.innerHTML = data.html;\n"
|
||||
" }}"
|
||||
" }});\n"
|
||||
" }} else {{"
|
||||
" if (previewDiv) {{"
|
||||
' previewDiv.innerHTML = "";\n'
|
||||
" }}"
|
||||
" }}"
|
||||
" }});\n"
|
||||
"}});\n"
|
||||
"</script>",
|
||||
attrs.get("id", ""),
|
||||
)
|
||||
|
||||
return mark_safe(select_html + preview_html + js_html)
|
||||
|
||||
def _build_attrs_string(self, attrs):
|
||||
"""
|
||||
Build HTML attributes string.
|
||||
"""
|
||||
attr_parts = []
|
||||
for key, value in attrs.items():
|
||||
if key != "id": # id is handled separately
|
||||
attr_parts.append(f'{key}="{value}"')
|
||||
return " " + " ".join(attr_parts) if attr_parts else ""
|
||||
|
||||
def _build_options(self, choices, selected_value):
|
||||
"""
|
||||
Build option elements for the select.
|
||||
"""
|
||||
options = []
|
||||
for value, text in choices:
|
||||
selected = "selected" if str(value) == str(selected_value) else ""
|
||||
options.append(f'<option value="{value}" {selected}>{text}</option>')
|
||||
return "".join(options)
|
||||
|
||||
|
||||
class ImageLibraryField(forms.ModelChoiceField):
|
||||
"""
|
||||
Custom form field for selecting images from the library.
|
||||
"""
|
||||
|
||||
def __init__(self, queryset=None, widget=None, show_thumbnails=True, **kwargs):
|
||||
if queryset is None:
|
||||
queryset = ImageLibrary.objects.all()
|
||||
|
||||
if widget is None:
|
||||
widget = ImageLibraryWidget(show_thumbnails=show_thumbnails)
|
||||
|
||||
super().__init__(queryset=queryset, widget=widget, **kwargs)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
"""
|
||||
Return the label for an image instance.
|
||||
"""
|
||||
return f"{obj.name} ({obj.get_category_display()})"
|
|
@ -1,32 +0,0 @@
|
|||
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"))
|
|
@ -1,293 +0,0 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.text import slugify
|
||||
from hub.services.models import (
|
||||
ImageLibrary,
|
||||
Service,
|
||||
CloudProvider,
|
||||
ConsultingPartner,
|
||||
Article,
|
||||
)
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrate existing images to the Image Library"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be migrated without actually doing it",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force migration even if images already exist in library",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Main command handler to migrate existing images to the library.
|
||||
"""
|
||||
dry_run = options["dry_run"]
|
||||
force = options["force"]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Starting image migration {"(DRY RUN)" if dry_run else ""}'
|
||||
)
|
||||
)
|
||||
|
||||
# Migrate different types of images
|
||||
self.migrate_service_logos(dry_run, force)
|
||||
self.migrate_cloud_provider_logos(dry_run, force)
|
||||
self.migrate_partner_logos(dry_run, force)
|
||||
self.migrate_article_images(dry_run, force)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Image migration completed {"(DRY RUN)" if dry_run else ""}'
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_service_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate service logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating service logos...")
|
||||
|
||||
services = Service.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for service in services:
|
||||
if not service.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{service.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {service.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {service.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{service.name} Logo",
|
||||
slug=slugify(f"{service.name}-logo"),
|
||||
description=f"Logo for {service.name} service",
|
||||
alt_text=f"{service.name} logo",
|
||||
category="logo",
|
||||
tags=f"service, logo, {service.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if service.logo and os.path.exists(service.logo.path):
|
||||
with open(service.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(service.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {service.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {service.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_cloud_provider_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate cloud provider logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating cloud provider logos...")
|
||||
|
||||
providers = CloudProvider.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for provider in providers:
|
||||
if not provider.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{provider.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {provider.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {provider.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{provider.name} Logo",
|
||||
slug=slugify(f"{provider.name}-logo"),
|
||||
description=f"Logo for {provider.name} cloud provider",
|
||||
alt_text=f"{provider.name} logo",
|
||||
category="logo",
|
||||
tags=f"cloud, provider, logo, {provider.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if provider.logo and os.path.exists(provider.logo.path):
|
||||
with open(provider.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(provider.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {provider.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {provider.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_partner_logos(self, dry_run, force):
|
||||
"""
|
||||
Migrate consulting partner logos to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating consulting partner logos...")
|
||||
|
||||
partners = ConsultingPartner.objects.filter(logo__isnull=False).exclude(logo="")
|
||||
|
||||
for partner in partners:
|
||||
if not partner.logo:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{partner.name} Logo"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {partner.name} logo (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {partner.name} logo")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{partner.name} Logo",
|
||||
slug=slugify(f"{partner.name}-logo"),
|
||||
description=f"Logo for {partner.name} consulting partner",
|
||||
alt_text=f"{partner.name} logo",
|
||||
category="logo",
|
||||
tags=f"consulting, partner, logo, {partner.name.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if partner.logo and os.path.exists(partner.logo.path):
|
||||
with open(partner.logo.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(partner.logo.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {partner.name} logo")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {partner.name} logo (file not found)"
|
||||
)
|
||||
)
|
||||
|
||||
def migrate_article_images(self, dry_run, force):
|
||||
"""
|
||||
Migrate article images to the image library.
|
||||
"""
|
||||
self.stdout.write("Migrating article images...")
|
||||
|
||||
articles = Article.objects.filter(image__isnull=False).exclude(image="")
|
||||
|
||||
for article in articles:
|
||||
if not article.image:
|
||||
continue
|
||||
|
||||
# Check if image already exists in library
|
||||
existing_image = ImageLibrary.objects.filter(
|
||||
name=f"{article.title} Image"
|
||||
).first()
|
||||
|
||||
if existing_image and not force:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" - Skipping {article.title} image (already exists)"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Would migrate: {article.title} image")
|
||||
)
|
||||
continue
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=f"{article.title} Image",
|
||||
slug=slugify(f"{article.title}-image"),
|
||||
description=f"Feature image for article: {article.title}",
|
||||
alt_text=f"{article.title} feature image",
|
||||
category="article",
|
||||
tags=f"article, {article.title.lower()}",
|
||||
)
|
||||
|
||||
# Copy the image file
|
||||
if article.image and os.path.exists(article.image.path):
|
||||
with open(article.image.path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(article.image.name),
|
||||
ContentFile(f.read()),
|
||||
save=True,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f" - Migrated: {article.title} image")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f" - Failed to migrate: {article.title} image (file not found)"
|
||||
)
|
||||
)
|
|
@ -1,40 +0,0 @@
|
|||
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}"
|
||||
)
|
||||
)
|
|
@ -1,79 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-06-20 13:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0035_alter_article_image_vshnappcataddon_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="vshnappcataddonbasefee",
|
||||
options={
|
||||
"ordering": ["currency", "service_level"],
|
||||
"verbose_name": "Addon Base Fee",
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="vshnappcatbasefee",
|
||||
options={
|
||||
"ordering": ["currency", "service_level"],
|
||||
"verbose_name": "Service Base Fee",
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="vshnappcataddonbasefee",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="vshnappcatbasefee",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcataddonbasefee",
|
||||
name="service_level",
|
||||
field=models.CharField(
|
||||
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
|
||||
default="BE",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="vshnappcatbasefee",
|
||||
name="service_level",
|
||||
field=models.CharField(
|
||||
choices=[("BE", "Best Effort"), ("GA", "Guaranteed Availability")],
|
||||
default="BE",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="vshnappcataddonbasefee",
|
||||
name="amount",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency and service level, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="vshnappcatbasefee",
|
||||
name="amount",
|
||||
field=models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency and service level, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="vshnappcataddonbasefee",
|
||||
unique_together={("addon", "currency", "service_level")},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="vshnappcatbasefee",
|
||||
unique_together={("vshn_appcat_price_config", "currency", "service_level")},
|
||||
),
|
||||
]
|
|
@ -1,63 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-06-23 07:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0036_alter_vshnappcataddonbasefee_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="plan",
|
||||
name="pricing",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlanPrice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"currency",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("CHF", "Swiss Franc"),
|
||||
("EUR", "Euro"),
|
||||
("USD", "US Dollar"),
|
||||
],
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
(
|
||||
"amount",
|
||||
models.DecimalField(
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
max_digits=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"plan",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="plan_prices",
|
||||
to="services.plan",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["currency"],
|
||||
"unique_together": {("plan", "currency")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,32 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-06-23 10:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0037_remove_plan_pricing_planprice"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="plan",
|
||||
options={"ordering": ["order", "name"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="plan",
|
||||
name="is_best",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Mark this plan as the best/recommended option"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="plan",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,22 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 13:48
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0038_add_plan_ordering_and_best"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="article_date",
|
||||
field=models.DateField(
|
||||
default=django.utils.timezone.now,
|
||||
help_text="Date of the article publishing",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,144 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 14:19
|
||||
|
||||
import django.db.models.deletion
|
||||
import hub.services.models.base
|
||||
import hub.services.models.images
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0039_article_article_date"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ImageLibrary",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Descriptive name for the image", max_length=200
|
||||
),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
help_text="URL-friendly version of the name",
|
||||
max_length=250,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Optional description of the image"
|
||||
),
|
||||
),
|
||||
(
|
||||
"alt_text",
|
||||
models.CharField(
|
||||
help_text="Alternative text for accessibility", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
help_text="Upload image file (max 1MB)",
|
||||
upload_to=hub.services.models.images.get_image_upload_path,
|
||||
validators=[hub.services.models.base.validate_image_size],
|
||||
),
|
||||
),
|
||||
(
|
||||
"width",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Image width in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Image height in pixels", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"file_size",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="File size in bytes", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"category",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("logo", "Logo"),
|
||||
("article", "Article Image"),
|
||||
("banner", "Banner"),
|
||||
("icon", "Icon"),
|
||||
("screenshot", "Screenshot"),
|
||||
("photo", "Photo"),
|
||||
("other", "Other"),
|
||||
],
|
||||
default="other",
|
||||
help_text="Category of the image",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Comma-separated tags for searching",
|
||||
max_length=500,
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Date and time when image was uploaded",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="Date and time when image was last updated",
|
||||
),
|
||||
),
|
||||
(
|
||||
"usage_count",
|
||||
models.PositiveIntegerField(
|
||||
default=0, help_text="Number of times this image is referenced"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who uploaded the image",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Image",
|
||||
"verbose_name_plural": "Image Library",
|
||||
"ordering": ["-uploaded_at"],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,57 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 15:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0040_add_image_library"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="cloudprovider",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="consultingpartner",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="image",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="article",
|
||||
name="image",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
help_text="Title picture for the article",
|
||||
null=True,
|
||||
upload_to="article_images/",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,74 +0,0 @@
|
|||
# Generated by Django 5.2 on 2025-07-04 15:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("services", "0041_add_image_library_references"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="cloudprovider",
|
||||
name="image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="consultingpartner",
|
||||
name="image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="service",
|
||||
name="image",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="article",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="cloudprovider",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="consultingpartner",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="service",
|
||||
name="image_library",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_references",
|
||||
to="services.imagelibrary",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,29 +0,0 @@
|
|||
# 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",
|
||||
),
|
||||
]
|
|
@ -1,24 +0,0 @@
|
|||
# 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],
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,25 +0,0 @@
|
|||
# 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],
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,23 +0,0 @@
|
|||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,7 +1,6 @@
|
|||
from .articles import *
|
||||
from .base import *
|
||||
from .content import *
|
||||
from .images import *
|
||||
from .leads import *
|
||||
from .pricing import *
|
||||
from .providers import *
|
||||
|
|
|
@ -2,27 +2,27 @@ 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.utils import timezone
|
||||
from .base import validate_image_size, get_prose_editor_field
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
from .base import validate_image_size
|
||||
from .services import Service
|
||||
from .providers import CloudProvider, ConsultingPartner
|
||||
from .images import ImageReference
|
||||
|
||||
|
||||
class Article(ImageReference):
|
||||
class Article(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
excerpt = models.TextField(
|
||||
max_length=500, help_text="Brief description of the article"
|
||||
)
|
||||
content = get_prose_editor_field()
|
||||
content = ProseEditorField()
|
||||
meta_keywords = models.CharField(
|
||||
max_length=255, blank=True, help_text="SEO keywords separated by commas"
|
||||
)
|
||||
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"
|
||||
image = models.ImageField(
|
||||
upload_to="article_images/",
|
||||
help_text="Title picture for the article",
|
||||
)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="articles")
|
||||
|
||||
# Relations to other models
|
||||
related_service = models.ForeignKey(
|
||||
|
@ -50,15 +50,6 @@ 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"
|
||||
|
@ -91,22 +82,6 @@ class Article(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:article_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_image(self):
|
||||
"""Returns the image from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.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):
|
||||
"""Returns a string describing what this article is related to"""
|
||||
|
|
|
@ -2,89 +2,12 @@ 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):
|
||||
def validate_image_size(value):
|
||||
filesize = value.size
|
||||
if filesize > mb * 1024 * 1024:
|
||||
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"
|
||||
)
|
||||
if filesize > 1 * 1024 * 1024: # 1MB
|
||||
raise ValidationError("Maximum file size is 1MB")
|
||||
|
||||
|
||||
class Currency(models.TextChoices):
|
||||
|
@ -106,11 +29,6 @@ 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"
|
||||
|
@ -125,7 +43,7 @@ class ReusableText(models.Model):
|
|||
blank=True,
|
||||
related_name="children",
|
||||
)
|
||||
text = get_prose_editor_field()
|
||||
text = ProseEditorField()
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
from django.db import models
|
||||
from .base import get_prose_editor_field
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
|
||||
class WebsiteFaq(models.Model):
|
||||
question = models.CharField(max_length=200)
|
||||
answer = get_prose_editor_field()
|
||||
answer = ProseEditorField()
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
|
|
|
@ -1,320 +0,0 @@
|
|||
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_or_svg
|
||||
|
||||
|
||||
def get_image_upload_path(instance, filename):
|
||||
"""
|
||||
Generate upload path for images based on the image library structure.
|
||||
"""
|
||||
return f"image_library/{filename}"
|
||||
|
||||
|
||||
class ImageLibrary(models.Model):
|
||||
"""
|
||||
Generic image library model that can be referenced by other models
|
||||
to avoid duplicate uploads and provide centralized image management.
|
||||
"""
|
||||
|
||||
# Image metadata
|
||||
name = models.CharField(max_length=200, help_text="Descriptive name for the image")
|
||||
slug = models.SlugField(
|
||||
max_length=250, unique=True, help_text="URL-friendly version of the name"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True, help_text="Optional description of the image"
|
||||
)
|
||||
alt_text = models.CharField(
|
||||
max_length=255, help_text="Alternative text for accessibility"
|
||||
)
|
||||
|
||||
# Image file
|
||||
image = models.FileField(
|
||||
upload_to=get_image_upload_path,
|
||||
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)
|
||||
width = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="Image width in pixels"
|
||||
)
|
||||
height = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="Image height in pixels"
|
||||
)
|
||||
file_size = models.PositiveIntegerField(
|
||||
null=True, blank=True, help_text="File size in bytes"
|
||||
)
|
||||
|
||||
# Categorization
|
||||
CATEGORY_CHOICES = [
|
||||
("logo", "Logo"),
|
||||
("article", "Article Image"),
|
||||
("banner", "Banner"),
|
||||
("icon", "Icon"),
|
||||
("screenshot", "Screenshot"),
|
||||
("photo", "Photo"),
|
||||
("other", "Other"),
|
||||
]
|
||||
category = models.CharField(
|
||||
max_length=20,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default="other",
|
||||
help_text="Category of the image",
|
||||
)
|
||||
|
||||
# Tags for easier searching
|
||||
tags = models.CharField(
|
||||
max_length=500, blank=True, help_text="Comma-separated tags for searching"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
uploaded_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="User who uploaded the image",
|
||||
)
|
||||
uploaded_at = models.DateTimeField(
|
||||
auto_now_add=True, help_text="Date and time when image was uploaded"
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True, help_text="Date and time when image was last updated"
|
||||
)
|
||||
|
||||
# Usage tracking
|
||||
usage_count = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of times this image is referenced"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-uploaded_at"]
|
||||
verbose_name = "Image"
|
||||
verbose_name_plural = "Image Library"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to automatically populate image properties and slug.
|
||||
"""
|
||||
# Generate slug if not provided
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Save the model first to get the image file
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update image properties if image exists
|
||||
if self.image:
|
||||
self._update_image_properties()
|
||||
|
||||
def _update_image_properties(self):
|
||||
"""
|
||||
Update image properties like width, height, and file size.
|
||||
"""
|
||||
try:
|
||||
# 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
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the save
|
||||
print(f"Error updating image properties: {e}")
|
||||
|
||||
def get_file_size_display(self):
|
||||
"""
|
||||
Return human-readable file size.
|
||||
"""
|
||||
if not self.file_size:
|
||||
return "Unknown"
|
||||
|
||||
size = self.file_size
|
||||
for unit in ["B", "KB", "MB", "GB"]:
|
||||
if size < 1024.0:
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024.0
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
def get_tags_list(self):
|
||||
"""
|
||||
Return tags as a list.
|
||||
"""
|
||||
if not self.tags:
|
||||
return []
|
||||
return [tag.strip() for tag in self.tags.split(",") if tag.strip()]
|
||||
|
||||
def increment_usage(self):
|
||||
"""
|
||||
Increment usage count when image is referenced.
|
||||
"""
|
||||
self.usage_count += 1
|
||||
self.save(update_fields=["usage_count"])
|
||||
|
||||
def decrement_usage(self):
|
||||
"""
|
||||
Decrement usage count when reference is removed.
|
||||
"""
|
||||
if self.usage_count > 0:
|
||||
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):
|
||||
"""
|
||||
Abstract base class for models that want to reference images from the library.
|
||||
This helps track usage and provides a consistent interface.
|
||||
"""
|
||||
|
||||
image_library = models.ForeignKey(
|
||||
ImageLibrary,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Select an image from the library",
|
||||
related_name="%(class)s_references",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Override save to update usage count.
|
||||
"""
|
||||
# Track if image changed
|
||||
old_image = None
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = self.__class__.objects.get(pk=self.pk)
|
||||
old_image = old_instance.image_library
|
||||
except self.__class__.DoesNotExist:
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Update usage counts
|
||||
if old_image and old_image != self.image_library:
|
||||
old_image.decrement_usage()
|
||||
|
||||
if self.image_library and self.image_library != old_image:
|
||||
self.image_library.increment_usage()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""
|
||||
Override delete to update usage count.
|
||||
"""
|
||||
if self.image_library:
|
||||
self.image_library.decrement_usage()
|
||||
super().delete(*args, **kwargs)
|
|
@ -250,6 +250,29 @@ class DiscountTier(models.Model):
|
|||
return f"{self.discount_model.name}: {self.min_units}+ units → {self.discount_percent}% discount"
|
||||
|
||||
|
||||
class VSHNAppCatBaseFee(models.Model):
|
||||
vshn_appcat_price_config = models.ForeignKey(
|
||||
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Service Base Fee"
|
||||
unique_together = ("vshn_appcat_price_config", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class VSHNAppCatPrice(models.Model):
|
||||
class VariableUnit(models.TextChoices):
|
||||
RAM = "RAM", "Memory (RAM)"
|
||||
|
@ -302,6 +325,12 @@ class VSHNAppCatPrice(models.Model):
|
|||
def __str__(self):
|
||||
return f"{self.service.name} - {self.get_variable_unit_display()} based pricing"
|
||||
|
||||
def get_base_fee(self, currency_code: str):
|
||||
try:
|
||||
return self.base_fees.get(currency=currency_code).amount
|
||||
except VSHNAppCatBaseFee.DoesNotExist:
|
||||
return None
|
||||
|
||||
def get_unit_rate(self, currency_code: str, service_level: str):
|
||||
try:
|
||||
return self.unit_rates.get(
|
||||
|
@ -317,7 +346,7 @@ class VSHNAppCatPrice(models.Model):
|
|||
number_of_units: int,
|
||||
addon_ids=None,
|
||||
):
|
||||
base_fee = self.get_base_fee(currency_code, service_level)
|
||||
base_fee = self.get_base_fee(currency_code)
|
||||
unit_rate = self.get_unit_rate(currency_code, service_level)
|
||||
|
||||
if base_fee is None or unit_rate is None:
|
||||
|
@ -351,7 +380,7 @@ class VSHNAppCatPrice(models.Model):
|
|||
for addon in addons:
|
||||
addon_price = 0
|
||||
if addon.addon_type == VSHNAppCatAddon.AddonType.BASE_FEE:
|
||||
addon_price_value = addon.get_price(currency_code, service_level)
|
||||
addon_price_value = addon.get_price(currency_code)
|
||||
if addon_price_value:
|
||||
addon_price = addon_price_value
|
||||
elif addon.addon_type == VSHNAppCatAddon.AddonType.UNIT_RATE:
|
||||
|
@ -379,15 +408,6 @@ class VSHNAppCatPrice(models.Model):
|
|||
"addon_breakdown": addon_breakdown,
|
||||
}
|
||||
|
||||
def get_base_fee(self, currency_code: str, service_level: str):
|
||||
"""
|
||||
Get the base fee for the given currency and service level.
|
||||
"""
|
||||
try:
|
||||
return self.base_fees.get(currency=currency_code, service_level=service_level).amount
|
||||
except VSHNAppCatBaseFee.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class VSHNAppCatUnitRate(models.Model):
|
||||
vshn_appcat_price_config = models.ForeignKey(
|
||||
|
@ -457,16 +477,10 @@ class VSHNAppCatAddon(models.Model):
|
|||
return f"{self.vshn_appcat_price_config.service.name} - {self.name}"
|
||||
|
||||
def get_price(self, currency_code: str, service_level: str = None):
|
||||
"""
|
||||
Get the price for this addon in the specified currency and service level.
|
||||
For base fee addons, service_level is required and used.
|
||||
For unit rate addons, service_level is required and used.
|
||||
"""
|
||||
"""Get the price for this addon in the specified currency and service level"""
|
||||
try:
|
||||
if self.addon_type == self.AddonType.BASE_FEE:
|
||||
if not service_level:
|
||||
raise ValueError("Service level is required for base fee addons")
|
||||
return self.base_fees.get(currency=currency_code, service_level=service_level).amount
|
||||
return self.base_fees.get(currency=currency_code).amount
|
||||
elif self.addon_type == self.AddonType.UNIT_RATE:
|
||||
if not service_level:
|
||||
raise ValueError("Service level is required for unit rate addons")
|
||||
|
@ -481,9 +495,8 @@ class VSHNAppCatAddon(models.Model):
|
|||
|
||||
|
||||
class VSHNAppCatAddonBaseFee(models.Model):
|
||||
"""
|
||||
Base fee for an addon (fixed amount regardless of units), specified per currency and service level.
|
||||
"""
|
||||
"""Base fee for an addon (fixed amount regardless of units)"""
|
||||
|
||||
addon = models.ForeignKey(
|
||||
VSHNAppCatAddon, on_delete=models.CASCADE, related_name="base_fees"
|
||||
)
|
||||
|
@ -491,27 +504,19 @@ class VSHNAppCatAddonBaseFee(models.Model):
|
|||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
service_level = models.CharField(
|
||||
max_length=2,
|
||||
choices=VSHNAppCatPrice.ServiceLevel.choices,
|
||||
default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency and service level, excl. VAT",
|
||||
help_text="Base fee in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Addon Base Fee"
|
||||
unique_together = ("addon", "currency", "service_level")
|
||||
ordering = ["currency", "service_level"]
|
||||
unique_together = ("addon", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.addon.name} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})"
|
||||
|
||||
def get_service_level_display(self):
|
||||
return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level)
|
||||
return f"{self.addon.name} Base Fee - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class VSHNAppCatAddonUnitRate(models.Model):
|
||||
|
@ -543,40 +548,6 @@ class VSHNAppCatAddonUnitRate(models.Model):
|
|||
return f"{self.addon.name} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class VSHNAppCatBaseFee(models.Model):
|
||||
"""
|
||||
Base fee for a service, specified per currency and service level.
|
||||
"""
|
||||
vshn_appcat_price_config = models.ForeignKey(
|
||||
"VSHNAppCatPrice", on_delete=models.CASCADE, related_name="base_fees"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
service_level = models.CharField(
|
||||
max_length=2,
|
||||
choices=VSHNAppCatPrice.ServiceLevel.choices,
|
||||
default=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Base fee in the specified currency and service level, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Service Base Fee"
|
||||
unique_together = ("vshn_appcat_price_config", "currency", "service_level")
|
||||
ordering = ["currency", "service_level"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.vshn_appcat_price_config.service.name} Base Fee - {self.amount} {self.currency} ({self.get_service_level_display()})"
|
||||
|
||||
def get_service_level_display(self):
|
||||
return dict(VSHNAppCatPrice.ServiceLevel.choices).get(self.service_level, self.service_level)
|
||||
|
||||
|
||||
class ExternalPricePlans(models.Model):
|
||||
plan_name = models.CharField()
|
||||
description = models.CharField(max_length=200, blank=True, null=True)
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
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, get_prose_editor_field, PartnerCategory
|
||||
from .images import ImageReference
|
||||
from .base import validate_image_size
|
||||
|
||||
|
||||
class CloudProvider(ImageReference):
|
||||
class CloudProvider(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
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)
|
||||
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)
|
||||
|
@ -33,32 +39,23 @@ class CloudProvider(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:provider_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
||||
|
||||
class ConsultingPartner(ImageReference):
|
||||
class ConsultingPartner(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
logo = models.ImageField(
|
||||
upload_to="partner_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
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
|
||||
)
|
||||
|
@ -77,10 +74,7 @@ class ConsultingPartner(ImageReference):
|
|||
ordering = ["order"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
def get_category_display_badge(self):
|
||||
return f"Servala {self.get_category_display()} Partner"
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
|
@ -89,10 +83,3 @@ class ConsultingPartner(ImageReference):
|
|||
|
||||
def get_absolute_url(self):
|
||||
return reverse("services:partner_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
|
|
@ -5,25 +5,23 @@ from django.urls import reverse
|
|||
from django.utils.text import slugify
|
||||
from django_prose_editor.fields import ProseEditorField
|
||||
|
||||
from .base import (
|
||||
Category,
|
||||
ReusableText,
|
||||
ManagedServiceProvider,
|
||||
validate_image_size,
|
||||
Currency,
|
||||
get_prose_editor_field,
|
||||
)
|
||||
from .base import Category, ReusableText, ManagedServiceProvider, validate_image_size
|
||||
from .providers import CloudProvider
|
||||
from .images import ImageReference
|
||||
|
||||
|
||||
class Service(ImageReference):
|
||||
class Service(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=250, unique=True)
|
||||
description = get_prose_editor_field()
|
||||
description = ProseEditorField()
|
||||
tagline = models.TextField(max_length=500, blank=True, null=True)
|
||||
logo = models.ImageField(
|
||||
upload_to="service_logos/",
|
||||
validators=[validate_image_size],
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
categories = models.ManyToManyField(Category, related_name="services")
|
||||
features = get_prose_editor_field()
|
||||
features = ProseEditorField()
|
||||
is_featured = models.BooleanField(default=False)
|
||||
is_coming_soon = models.BooleanField(default=False)
|
||||
disable_listing = models.BooleanField(default=False)
|
||||
|
@ -54,13 +52,6 @@ class Service(ImageReference):
|
|||
def get_absolute_url(self):
|
||||
return reverse("services:service_detail", kwargs={"slug": self.slug})
|
||||
|
||||
@property
|
||||
def get_logo(self):
|
||||
"""Returns the logo from the library"""
|
||||
if self.image_library and self.image_library.image:
|
||||
return self.image_library.image
|
||||
return None
|
||||
|
||||
|
||||
class ServiceOffering(models.Model):
|
||||
service = models.ForeignKey(
|
||||
|
@ -75,7 +66,7 @@ class ServiceOffering(models.Model):
|
|||
cloud_provider = models.ForeignKey(
|
||||
CloudProvider, on_delete=models.CASCADE, related_name="offerings"
|
||||
)
|
||||
description = get_prose_editor_field(blank=True, null=True)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
offer_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -106,31 +97,10 @@ class ServiceOffering(models.Model):
|
|||
)
|
||||
|
||||
|
||||
class PlanPrice(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
"Plan", on_delete=models.CASCADE, related_name="plan_prices"
|
||||
)
|
||||
currency = models.CharField(
|
||||
max_length=3,
|
||||
choices=Currency.choices,
|
||||
)
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
help_text="Price in the specified currency, excl. VAT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("plan", "currency")
|
||||
ordering = ["currency"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.plan.name} - {self.amount} {self.currency}"
|
||||
|
||||
|
||||
class Plan(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
description = get_prose_editor_field(blank=True, null=True)
|
||||
description = ProseEditorField(blank=True, null=True)
|
||||
pricing = ProseEditorField(blank=True, null=True)
|
||||
plan_description = models.ForeignKey(
|
||||
ReusableText,
|
||||
on_delete=models.PROTECT,
|
||||
|
@ -142,48 +112,16 @@ class Plan(models.Model):
|
|||
ServiceOffering, on_delete=models.CASCADE, related_name="plans"
|
||||
)
|
||||
|
||||
# Ordering and highlighting fields
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order of this plan in the offering (lower numbers appear first)",
|
||||
)
|
||||
is_best = models.BooleanField(
|
||||
default=False, help_text="Mark this plan as the best/recommended option"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["order", "name"]
|
||||
ordering = ["name"]
|
||||
unique_together = [["offering", "name"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.offering} - {self.name}"
|
||||
|
||||
def clean(self):
|
||||
# Ensure only one plan per offering can be marked as "best"
|
||||
if self.is_best:
|
||||
existing_best = Plan.objects.filter(
|
||||
offering=self.offering, is_best=True
|
||||
).exclude(pk=self.pk)
|
||||
if existing_best.exists():
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
raise ValidationError(
|
||||
"Only one plan per offering can be marked as the best option."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_price(self, currency_code: str):
|
||||
price_obj = PlanPrice.objects.filter(plan=self, currency=currency_code).first()
|
||||
if price_obj:
|
||||
return price_obj.amount
|
||||
return None
|
||||
|
||||
|
||||
class ExternalLinkOffering(models.Model):
|
||||
offering = models.ForeignKey(
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
/* CSS for Image Library Admin */
|
||||
|
||||
/* Thumbnail styling in list view */
|
||||
.image-thumbnail {
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Preview styling in detail view */
|
||||
.image-preview {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Form styling */
|
||||
.image-library-form .form-row {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.image-library-form .help {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
/* Usage count styling */
|
||||
.usage-count {
|
||||
font-weight: bold;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.usage-count.high {
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category-badge.logo {
|
||||
background-color: #e8f4f8;
|
||||
color: #2c6e92;
|
||||
}
|
||||
|
||||
.category-badge.article {
|
||||
background-color: #f0f8e8;
|
||||
color: #5a7c3a;
|
||||
}
|
||||
|
||||
.category-badge.banner {
|
||||
background-color: #fef4e8;
|
||||
color: #d2691e;
|
||||
}
|
||||
|
||||
.category-badge.icon {
|
||||
background-color: #f8e8f8;
|
||||
color: #8b4c8b;
|
||||
}
|
||||
|
||||
.category-badge.screenshot {
|
||||
background-color: #e8f8f4;
|
||||
color: #3a7c5a;
|
||||
}
|
||||
|
||||
.category-badge.photo {
|
||||
background-color: #f4e8f8;
|
||||
color: #923c92;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
|
@ -33,37 +33,4 @@
|
|||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Subtle styling for the best plan */
|
||||
.card.border-success.border-2 {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.1) !important;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card.border-success.border-2:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(25, 135, 84, 0.15) !important;
|
||||
}
|
||||
|
||||
/* Best choice badge styling */
|
||||
.badge.bg-success {
|
||||
border: 2px solid white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.151);
|
||||
color: rgb(255, 255, 255);
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
/* Subtle enhancement for best plan button */
|
||||
.btn-success.shadow {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-success.shadow:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(25, 135, 84, 0.2) !important;
|
||||
}
|
|
@ -351,8 +351,7 @@ dl {
|
|||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol,
|
||||
li p {
|
||||
ul ol {
|
||||
margin-bottom: 0
|
||||
}
|
||||
|
||||
|
@ -12535,8 +12534,4 @@ 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: 22 KiB After Width: | Height: | Size: 32 KiB |
File diff suppressed because it is too large
Load diff
|
@ -1,176 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,160 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,113 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,252 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,190 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -1,269 +0,0 @@
|
|||
/**
|
||||
* 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;
|
|
@ -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">
|
||||
|
|
|
@ -48,15 +48,9 @@
|
|||
<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="{{ service.get_absolute_url }}" class="clickable-link">
|
||||
{% if service.get_logo %}
|
||||
<img src="{{ service.get_logo.url }}"
|
||||
<img src="{{ service.logo.url }}"
|
||||
alt="{{ service.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
{% else %}
|
||||
<div class="text-muted" style="height: 100px; width: 250px; display: flex; align-items: center; justify-content: center; border: 1px solid #dee2e6; border-radius: 0.375rem;">
|
||||
{{ service.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -109,15 +103,13 @@
|
|||
<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 }}"
|
||||
<img src="{{ provider.logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
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>
|
||||
|
@ -147,9 +139,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">Partners</h2>
|
||||
<h2 class="section__header-h2">Consulting Partners</h2>
|
||||
<div class="section__desc">
|
||||
<p>Explore all available Partners on Servala, with new ones added regularly.</p>
|
||||
<p>Explore all available Consulting Partners on Servala, with new ones added regularly.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-lg-block">
|
||||
|
@ -165,15 +157,13 @@
|
|||
<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 }}"
|
||||
<img src="{{ partner.logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
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>
|
||||
|
@ -216,7 +206,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">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">Consulting Partners</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
|
|
@ -16,13 +16,25 @@
|
|||
<div class="d-flex justify-content-center align-items-center gap-3 text-sm">
|
||||
<span>By {{ article.author.get_full_name|default:article.author.username }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ article.article_date|date:"M d, Y" }}</span>
|
||||
<span>{{ article.created_at|date:"M d, Y" }}</span>
|
||||
{% if article.updated_at != article.created_at %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if article.image %}
|
||||
<section class="section py-0">
|
||||
<div class="container-xl mx-auto">
|
||||
<div class="article-hero-image">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid w-100" style="max-height: 400px; object-fit: cover;">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="section">
|
||||
<div class="container-xl mx-auto px-3 px-lg-0 pt-60 pt-lg-80 pb-40">
|
||||
<div class="row">
|
||||
|
@ -41,9 +53,9 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Service</h5>
|
||||
{% if article.related_service.get_logo %}
|
||||
{% if article.related_service.logo %}
|
||||
<div class="mb-3 d-flex" style="height: 60px;">
|
||||
<img src="{{ article.related_service.get_logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
<img src="{{ article.related_service.logo.url }}" alt="{{ article.related_service.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -58,16 +70,13 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Partner</h5>
|
||||
{% if article.related_consulting_partner.get_logo %}
|
||||
{% if article.related_consulting_partner.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"
|
||||
<img src="{{ article.related_consulting_partner.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>
|
||||
|
@ -78,9 +87,9 @@
|
|||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Provider</h5>
|
||||
{% if article.related_cloud_provider.get_logo %}
|
||||
{% if article.related_cloud_provider.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"
|
||||
<img src="{{ article.related_cloud_provider.logo.url }}" alt="{{ article.related_cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 50px; object-fit: contain;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -100,32 +109,15 @@
|
|||
<h3>Related Articles</h3>
|
||||
<div class="row">
|
||||
{% for related_article in related_articles %}
|
||||
<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>
|
||||
<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.image.url }}" class="card-img-top mb-2" alt="{{ related_article.title }}" style="height: 200px; object-fit: cover;">
|
||||
{% endif %}
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -145,11 +145,11 @@
|
|||
<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.get_image or article.is_featured %}
|
||||
{% if article.image or article.is_featured %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if article.get_image %}
|
||||
{% if article.image %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ article.get_image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
<img src="{{ article.image.url }}" alt="{{ article.title }}" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if article.is_featured %}
|
||||
|
@ -169,7 +169,7 @@
|
|||
By {{ article.author.get_full_name|default:article.author.username }}
|
||||
</span>
|
||||
<span class="text-muted ms-2">
|
||||
{{ article.article_date|date:"M d, Y" }}
|
||||
{{ article.created_at|date:"M d, Y" }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -77,8 +77,8 @@
|
|||
<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.get_logo %}
|
||||
<img class="img-fluid" src="{{ selected_offering.service.get_logo.url }}" alt="Service Logo">
|
||||
{% if selected_offering.service.logo %}
|
||||
<img class="img-fluid" src="{{ selected_offering.service.logo.url }}" alt="Service Logo">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__header ps-16">
|
||||
|
|
|
@ -1,39 +1,12 @@
|
|||
{% 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 %}
|
||||
{% 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 %}
|
||||
<script defer src="{% static "js/price-calculator.js" %}"></script>
|
||||
<link rel="stylesheet" type="text/css" href='{% static "css/price-calculator.css" %}'>
|
||||
|
||||
{% json_ld_structured_data %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -56,9 +29,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
<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.get_logo %}
|
||||
{% if offering.service.logo %}
|
||||
<a href="{{ offering.service.get_absolute_url }}">
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.get_logo.url }}"
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ offering.service.logo.url }}"
|
||||
alt="{{ offering.service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -74,7 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
<div class="mb-40">
|
||||
<h3 class="fw-semibold mb-12">Runs on</h3>
|
||||
<a href="{{ offering.cloud_provider.get_absolute_url }}">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.get_logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
<img class="img-fluid" src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo" style="max-height: 40px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
@ -243,11 +216,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
<div class="mb-4">
|
||||
<label for="cpuRange" class="form-label d-flex justify-content-between">
|
||||
<span>vCPUs</span>
|
||||
<span class="fw-bold" id="cpuValue">0.5</span>
|
||||
<span class="fw-bold" id="cpuValue">2</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="cpuRange" min="0.25" max="32" value="0.5" step="0.25">
|
||||
<input type="range" class="form-range" id="cpuRange" min="1" max="32" value="2" step="1">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="cpuMinDisplay">0.25</span>
|
||||
<span id="cpuMinDisplay">1</span>
|
||||
<span id="cpuMaxDisplay">32</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -256,11 +229,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
<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">1</span>
|
||||
<span class="fw-bold" id="memoryValue">4</span>
|
||||
</label>
|
||||
<input type="range" class="form-range" id="memoryRange" min="0.25" max="128" value="1" step="0.25">
|
||||
<input type="range" class="form-range" id="memoryRange" min="1" max="128" value="4" step="1">
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="memoryMinDisplay">0.25 GB</span>
|
||||
<span id="memoryMinDisplay">1 GB</span>
|
||||
<span id="memoryMaxDisplay">128 GB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -278,10 +251,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replicas Slider -->
|
||||
<!-- Instances Slider -->
|
||||
<div class="mb-4">
|
||||
<label for="instancesRange" class="form-label d-flex justify-content-between">
|
||||
<span>Replicas</span>
|
||||
<span>Instances</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">
|
||||
|
@ -305,7 +278,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
<!-- 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</label>
|
||||
<label class="form-label">Add-ons (Optional)</label>
|
||||
<div id="addonsContainer">
|
||||
<!-- Add-ons will be dynamically populated here -->
|
||||
</div>
|
||||
|
@ -356,7 +329,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
<div class="fw-bold" id="planMemory"></div>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<small class="text-muted">Replicas</small>
|
||||
<small class="text-muted">Instances</small>
|
||||
<div class="fw-bold" id="planInstances"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -372,41 +345,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
<!-- Pricing Breakdown -->
|
||||
<div class="border-top pt-3">
|
||||
<!-- 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 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>
|
||||
</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>
|
||||
|
||||
<!-- Optional Addons Pricing -->
|
||||
<!-- Addons Pricing -->
|
||||
<div id="addonPricingContainer">
|
||||
<!-- Optional addon pricing will be dynamically added here -->
|
||||
<!-- Addon pricing will be dynamically added here -->
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
@ -416,7 +366,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</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). Metering is conducted per hour. Introductory pricing subject to change.
|
||||
Monthly pricing based on 30 days (720 hours). Billing is conducted per hour.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -450,102 +400,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
{% elif offering.plans.all %}
|
||||
<!-- Traditional Plans -->
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Choose your Plan</h3>
|
||||
<div class="bg-light rounded-4 p-4 mb-4">
|
||||
<div class="row">
|
||||
{% for plan in offering.plans.all %}
|
||||
<div class="col-12 {% if offering.plans.all|length == 1 %}col-lg-8 mx-auto{% elif offering.plans.all|length == 2 %}col-lg-6{% else %}col-lg-4{% endif %} mb-4">
|
||||
<div class="card h-100 {% if plan.is_best %}border-success border-2 shadow-sm{% else %}border-primary shadow-sm{% endif %} position-relative">
|
||||
{% if plan.is_best %}
|
||||
<!-- Best Plan Badge -->
|
||||
<div class="position-absolute top-0 start-50 translate-middle" style="z-index: 10;">
|
||||
<span class="badge bg-success px-3 py-2 fs-6 fw-bold shadow-sm text-nowrap">
|
||||
<i class="bi bi-star-fill me-1"></i>Best Choice
|
||||
</span>
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12">Available Plans</h3>
|
||||
<div class="row">
|
||||
{% for plan in offering.plans.all %}
|
||||
<div class="col-12 col-lg-6 {% if not forloop.last %}mb-20 mb-lg-0{% endif %}">
|
||||
<div class="bg-purple-50 rounded-16 border-all p-24">
|
||||
<div class="bg-white border-all rounded-7 p-20 mb-20">
|
||||
<h3 class="text-purple fs-22 fw-semibold lh-1-7 mb-0">{{ plan.name }}</h3>
|
||||
{% if plan.plan_description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body pt-3 d-flex flex-column">
|
||||
<h5 class="card-title {% if plan.is_best %}text-success{% else %}text-primary{% endif %} mb-3 fw-bold">
|
||||
<i class="bi bi-{% if plan.is_best %}award{% else %}box{% endif %} me-2"></i>{{ plan.name }}
|
||||
</h5>
|
||||
|
||||
<!-- Plan Description -->
|
||||
{% if plan.plan_description %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Description</small>
|
||||
<div class="text-dark">
|
||||
{{ plan.plan_description.text|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if plan.description %}
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Details</small>
|
||||
<div class="text-dark">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pricing Information -->
|
||||
{% if plan.plan_prices.exists %}
|
||||
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex flex-column">
|
||||
<div class="mb-2">
|
||||
<small class="{% if plan.is_best %}text-success fw-semibold{% else %}text-muted{% endif %}">Pricing</small>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span>Monthly Price</span>
|
||||
<div class="text-end">
|
||||
{% for price in plan.plan_prices.all %}
|
||||
<div class="fs-5 fw-bold {% if plan.is_best %}text-success{% else %}text-primary{% endif %}">{{ price.currency }} {{ price.amount }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Prices exclude VAT. Monthly pricing based on 30 days.
|
||||
</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="{% if plan.is_best %}border-top border-success{% else %}border-top{% endif %} pt-3 mt-3 flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<div class="text-center text-muted">
|
||||
<i class="bi bi-envelope me-2"></i>Contact us for pricing details
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Plan Action Button -->
|
||||
<div class="text-center mt-auto pt-3">
|
||||
<a href="#plan-order-form" class="btn {% if plan.is_best %}btn-success btn-lg px-4 py-2 shadow{% else %}btn-primary btn-lg px-4 py-2{% endif %} fw-semibold w-100" data-plan-id="{{ plan.id }}" data-plan-name="{{ plan.name }}" onclick="selectPlan(this)">
|
||||
<i class="bi bi-{% if plan.is_best %}star-fill{% else %}cart{% endif %} me-2"></i>Select Plan
|
||||
</a>
|
||||
</div>
|
||||
{% if plan.description %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.description|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if plan.pricing %}
|
||||
<div class="text-black mb-20">
|
||||
{{ plan.pricing|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan Order Forms -->
|
||||
<div id="plan-order-form" class="pt-40" style="scroll-margin-top: 30px;">
|
||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">Order Your Plan</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
||||
{% empty %}
|
||||
<div class="col-12" id="interest" style="scroll-margin-top: 30px;">
|
||||
<div class="alert alert-info">
|
||||
<p>No plans available yet.</p>
|
||||
<h4 class="mb-3">I'm interested in this offering</h4>
|
||||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No Plans Available -->
|
||||
|
@ -555,6 +443,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
{% embedded_contact_form source="Offering Interest" service=offering.service offering_id=offering.id %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if offering.plans.exists and not pricing_data_by_group_and_service_level %}
|
||||
<div id="form" class="pt-40">
|
||||
<h4 class="fs-22 fw-semibold lh-1 mb-12">I'm interested in a plan</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% embedded_contact_form source="Plan Order" service=offering.service offering_id=offering.id choices=offering.plans.all choice_label="Select a Plan" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -150,8 +150,8 @@
|
|||
<div class="card__header">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<div class="me-3">
|
||||
{% if offering.service.get_logo %}
|
||||
<img src="{{ offering.service.get_logo.url }}"
|
||||
{% if offering.service.logo %}
|
||||
<img src="{{ offering.service.logo.url }}"
|
||||
alt="{{ offering.service.name }}"
|
||||
style="max-height: 50px; max-width: 100px; object-fit: contain;">
|
||||
{% endif %}
|
||||
|
@ -163,9 +163,9 @@
|
|||
</a>
|
||||
</h3>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if offering.cloud_provider.get_logo %}
|
||||
{% if offering.cloud_provider.logo %}
|
||||
<a href="{{ offering.get_absolute_url }}" class="me-2">
|
||||
<img src="{{ offering.cloud_provider.get_logo.url }}"
|
||||
<img src="{{ offering.cloud_provider.logo.url }}"
|
||||
alt="{{ offering.cloud_provider.name }}"
|
||||
style="max-height: 30px; max-width: 100px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -23,8 +23,8 @@
|
|||
<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.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;">
|
||||
{% if partner.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ partner.logo.url }}" alt="{{ partner.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -99,6 +99,27 @@
|
|||
</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">
|
||||
|
@ -132,7 +153,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">{{ partner.get_category_display_badge }}</button>
|
||||
<button class="btn btn-tertiary btn-sm mr-12">Servala Consulting Partner</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -147,26 +168,23 @@
|
|||
<!-- Services -->
|
||||
{% if services %}
|
||||
<div class="pt-40">
|
||||
<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>
|
||||
<h3 class="fs-24 fw-semibold lh-1 mb-12" id="services" style="scroll-margin-top: 100px;">Consulting for Services</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">
|
||||
<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;">
|
||||
{% 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.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</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">
|
||||
|
|
|
@ -94,23 +94,6 @@
|
|||
</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>
|
||||
|
@ -127,21 +110,17 @@
|
|||
<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">
|
||||
{% 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 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.logo.url }}"
|
||||
alt="{{ partner.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3 class="card__title">
|
||||
<a href="{{ partner.get_absolute_url }}" class="text-decoration-none clickable-link">{{ partner.name }}</a>
|
||||
</h3>
|
||||
|
@ -156,9 +135,7 @@
|
|||
{% 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">
|
||||
{% if partner.category == 'TRAINING' %}Available Trainings{% else %}Available Services{% endif %}
|
||||
</a>
|
||||
<a href="{{ partner.get_absolute_url }}#services" class="btn btn-primary btn-sm clickable-button">Available Services</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -51,41 +51,6 @@
|
|||
|
||||
.servala-row {
|
||||
border-bottom: 2px solid #007bff;
|
||||
border-left: 4px solid #007bff;
|
||||
background-color: rgba(13, 110, 253, 0.03);
|
||||
}
|
||||
|
||||
.internal-comparison-row {
|
||||
background-color: rgba(25, 135, 84, 0.08) !important;
|
||||
border-left: 4px solid #198754;
|
||||
border-top: 1px solid #198754;
|
||||
}
|
||||
|
||||
.external-comparison-row {
|
||||
background-color: rgba(108, 117, 125, 0.08) !important;
|
||||
border-left: 4px solid #6c757d;
|
||||
border-top: 1px solid #6c757d;
|
||||
}
|
||||
|
||||
/* Group comparison rows visually */
|
||||
.comparison-group {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Add visual connection between main row and comparisons */
|
||||
.has-comparisons {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.has-comparisons::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, #007bff, transparent);
|
||||
}
|
||||
|
||||
/* Price calculation breakdown styling */
|
||||
|
@ -159,11 +124,61 @@
|
|||
<div class="col-12">
|
||||
<h1 class="mb-4">Complete Price List - All Service Variants</h1>
|
||||
|
||||
<!-- Pricing Model Explanation - Internal Product Manager View -->
|
||||
<!-- Pricing Model Explanation -->
|
||||
<div class="card mb-4">
|
||||
<a href="https://vshnwiki.atlassian.net/wiki/x/BQDYGg" target="_blank">See VSHN Wiki for a detailed explanation</a>
|
||||
<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" style="background-color: #0d6efd;">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" style="background-color: #6f42c1;">SLA Base</span>
|
||||
<span class="ms-2">Fixed cost for the service level agreement</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="badge" style="background-color: #fd7e14;">Units × SLA Per Unit</span>
|
||||
<span class="ms-2">Variable cost based on scale/usage</span>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<span class="badge" style="background-color: #dc3545;">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.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filter Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
|
@ -172,20 +187,24 @@
|
|||
<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 <span class="text-danger">*</span></label>
|
||||
<label for="cloud_provider" class="form-label">Cloud Provider</label>
|
||||
<select name="cloud_provider" id="cloud_provider" class="form-select filter-select">
|
||||
<option value="">-- Select Cloud Provider --</option>
|
||||
<option value="">All Providers</option>
|
||||
{% for provider in all_cloud_providers %}
|
||||
<option value="{{ provider }}" {% if provider == filter_cloud_provider %}selected{% endif %}>{{ provider }}</option>
|
||||
<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 <span class="text-danger">*</span></label>
|
||||
<label for="service" class="form-label">Service</label>
|
||||
<select name="service" id="service" class="form-select filter-select">
|
||||
<option value="">-- Select Service --</option>
|
||||
<option value="">All Services</option>
|
||||
{% for service in all_services %}
|
||||
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>{{ service }}</option>
|
||||
<option value="{{ service }}" {% if service == filter_service %}selected{% endif %}>
|
||||
{{ service }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -227,7 +246,7 @@
|
|||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="price_comparison" value="true" id="price_comparison" {% if show_price_comparison %}checked{% endif %}>
|
||||
<label class="form-check-label" for="price_comparison">
|
||||
Show price comparisons (external providers + internal cloud providers)
|
||||
Show external price comparisons
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -243,17 +262,16 @@
|
|||
{% 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>
|
||||
<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>
|
||||
{% 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 bg-secondary me-1">Discount Details</span>{% endif %}
|
||||
{% if show_addon_details %}<span class="badge bg-info me-1">Addon Details</span>{% endif %}
|
||||
{% if show_price_comparison %}<span class="badge bg-warning me-1">Price Comparison</span>{% endif %}
|
||||
</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">
|
||||
|
@ -262,11 +280,18 @@
|
|||
{# 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 %}
|
||||
{% 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 %}
|
||||
|
||||
{# 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">
|
||||
|
@ -307,19 +332,30 @@
|
|||
{% if pricing_data %}
|
||||
{# Display common values for this service level #}
|
||||
{% with pricing_data.0 as first_row %}
|
||||
<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 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>
|
||||
|
||||
{# 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">
|
||||
|
@ -394,7 +430,7 @@
|
|||
<th rowspan="2">Discount Details</th>
|
||||
{% endif %}
|
||||
{% if show_price_comparison %}
|
||||
<th rowspan="2">Price Comparisons</th>
|
||||
<th rowspan="2">External Comparisons</th>
|
||||
{% endif %}
|
||||
<th rowspan="2" class="final-price-header">Final Price</th>
|
||||
</tr>
|
||||
|
@ -408,13 +444,8 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in pricing_data %}
|
||||
<tr class="servala-row {% if not row.is_active %}text-muted opacity-50{% endif %} {% if show_price_comparison and row.external_comparisons or row.internal_comparisons %}has-comparisons{% endif %}">
|
||||
<td>
|
||||
{{ row.compute_plan }}
|
||||
{% if not row.is_active %}
|
||||
<span class="badge bg-secondary ms-1" title="This compute plan is not active and not available for new public offerings.">Inactive plan</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<tr class="servala-row">
|
||||
<td>{{ row.compute_plan }}</td>
|
||||
<td>{{ row.cloud_provider }}</td>
|
||||
<td>{{ row.vcpus }}</td>
|
||||
<td>{{ row.ram }}</td>
|
||||
|
@ -535,39 +566,16 @@
|
|||
{% endif %}
|
||||
{% if show_price_comparison %}
|
||||
<td>
|
||||
{% if row.external_comparisons or row.internal_comparisons %}
|
||||
<div class="text-center">
|
||||
{% if row.external_comparisons %}
|
||||
<div class="mb-1">
|
||||
<span class="badge">{{ row.external_comparisons|length }} External</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if row.internal_comparisons %}
|
||||
<div class="mb-1">
|
||||
<span class="badge">{{ row.internal_comparisons|length }} Internal</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<small class="text-muted">See rows below</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">No comparisons</span>
|
||||
{% endif %}
|
||||
<span class="badge">-</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="final-price-cell fw-bold">{{ row.final_price|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% if show_price_comparison and row.external_comparisons %}
|
||||
{% for comparison in row.external_comparisons %}
|
||||
<tr class="comparison-row external-comparison-row">
|
||||
<td class="text-muted">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-arrow-return-right me-2 text-secondary"></i>
|
||||
{{ comparison.plan_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
<span class="badge">{{ comparison.provider }}</span>
|
||||
</td>
|
||||
<tr class="table-light comparison-row">
|
||||
<td class="text-muted">{{ comparison.plan_name }}</td>
|
||||
<td class="text-muted">{{ comparison.provider }}</td>
|
||||
<td class="text-muted">
|
||||
{% if comparison.vcpus %}{{ comparison.vcpus }}{% else %}-{% endif %}
|
||||
</td>
|
||||
|
@ -577,11 +585,11 @@
|
|||
<td class="text-muted">{{ row.term }}</td>
|
||||
<td class="text-muted">{{ comparison.currency }}</td>
|
||||
<!-- Price breakdown columns for external comparisons -->
|
||||
<td class="text-muted text-center">-</td>
|
||||
<td class="text-muted text-center">-</td>
|
||||
<td class="text-muted text-center">-</td>
|
||||
<td class="text-muted text-center">-</td>
|
||||
<td class="text-muted text-center">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
<td class="text-muted">-</td>
|
||||
{% if show_addon_details %}
|
||||
<td class="text-muted">-</td>
|
||||
{% endif %}
|
||||
|
@ -591,11 +599,7 @@
|
|||
{% endif %}
|
||||
<td>
|
||||
<small>
|
||||
{% if comparison.source %}
|
||||
<a href="{{ comparison.source }}" target="_blank" class="text-decoration-none">
|
||||
<i class="bi bi-link-45deg"></i> External Source
|
||||
</a><br>
|
||||
{% endif %}
|
||||
<span class="badge bg-secondary">{% if comparison.source %}<span class="text-muted"><a href="{{ comparison.source }}" target="_blank">{{ comparison.provider }}</a></span>{% else %}{{ comparison.provider }}{% endif %}</span><br>
|
||||
{% if comparison.description %}
|
||||
<span class="text-muted">{{ comparison.description }}</span><br>
|
||||
{% endif %}
|
||||
|
@ -606,77 +610,21 @@
|
|||
<span class="text-muted">Replicas: {{ comparison.replicas }}</span><br>
|
||||
{% endif %}
|
||||
{% if comparison.ratio %}
|
||||
<span class="text-muted">Ratio: {{ comparison.ratio|floatformat:2 }}x</span>
|
||||
<span class="text-muted">Price ratio: {{ comparison.ratio|floatformat:2 }}x</span><br>
|
||||
{% endif %}
|
||||
</small>
|
||||
</td>
|
||||
<td class="fw-bold">
|
||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||
{% if comparison.difference > 0 %}
|
||||
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-success ms-1">+{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% elif comparison.difference < 0 %}
|
||||
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
|
||||
<span class="badge bg-danger ms-1">{{ comparison.difference|floatformat:2 }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if show_price_comparison and row.internal_comparisons %}
|
||||
{% for comparison in row.internal_comparisons %}
|
||||
<tr class="comparison-row internal-comparison-row">
|
||||
<td class="text-muted">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-arrow-return-right me-2 text-success"></i>
|
||||
{{ comparison.plan_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
<span class="badge">{{ comparison.provider }}</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ comparison.vcpus }}</td>
|
||||
<td class="text-muted">{{ comparison.ram }}</td>
|
||||
<td class="text-muted">{{ row.term }}</td>
|
||||
<td class="text-muted">{{ comparison.currency }}</td>
|
||||
<!-- Price breakdown columns for internal comparisons -->
|
||||
<td class="text-center text-success">{{ comparison.compute_plan_price|floatformat:2 }}</td>
|
||||
<td class="text-muted text-center"><small>Same</small></td>
|
||||
<td class="text-muted text-center"><small>Same</small></td>
|
||||
<td class="text-muted text-center"><small>Same</small></td>
|
||||
<td class="text-center text-success">{{ comparison.service_price|floatformat:2 }}</td>
|
||||
{% if show_addon_details %}
|
||||
<td class="text-muted"><small>Same as above</small></td>
|
||||
{% endif %}
|
||||
{% if show_discount_details %}
|
||||
<td class="text-muted"><small>Same</small></td>
|
||||
<td class="text-muted"><small>Same</small></td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<small>
|
||||
<span class="text-success"><i class="bi bi-check-circle"></i> Servala Network</span><br>
|
||||
<span class="text-muted">{{ comparison.description }}</span><br>
|
||||
<span class="text-muted">Group: {{ comparison.group_name }}</span><br>
|
||||
<span class="text-muted">Ratio: {{ comparison.ratio|floatformat:2 }}x</span>
|
||||
</small>
|
||||
</td>
|
||||
<td class="fw-bold">
|
||||
{{ comparison.amount|floatformat:2 }} {{ comparison.currency }}
|
||||
{% if comparison.difference > 0 %}
|
||||
(+{{ comparison.difference|floatformat:2 }}, +{% widthratio comparison.difference row.final_price 100 %}%)
|
||||
{% elif comparison.difference < 0 %}
|
||||
({{ comparison.difference|floatformat:2 }}, {% widthratio comparison.difference row.final_price 100 %}%)
|
||||
{% elif comparison.difference == 0 %}
|
||||
(Same)
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<!-- Add visual separator after comparison rows -->
|
||||
{% if show_price_comparison and row.external_comparisons or row.internal_comparisons %}
|
||||
<tr class="comparison-group">
|
||||
<td colspan="{% if show_addon_details and show_discount_details %}14{% elif show_addon_details or show_discount_details %}13{% else %}12{% endif %}"></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -703,7 +651,7 @@
|
|||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<h4>No pricing data available</h4>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
|
@ -23,8 +23,8 @@
|
|||
<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.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;">
|
||||
{% if provider.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ provider.logo.url }}" alt="{{ provider.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -173,12 +173,12 @@
|
|||
{% 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.get_logo or offering.service.is_featured %}
|
||||
{% if offering.service.logo or offering.service.is_featured %}
|
||||
<div class="d-flex justify-content-between">
|
||||
{% if offering.service.get_logo %}
|
||||
{% if offering.service.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">
|
||||
<img src="{{ offering.service.logo.url }}" alt="{{ offering.service.name }} logo" class="img-fluid">
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -98,8 +98,8 @@
|
|||
<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.get_logo %}
|
||||
<img src="{{ provider.get_logo.url }}"
|
||||
{% if provider.logo %}
|
||||
<img src="{{ provider.logo.url }}"
|
||||
alt="{{ provider.name }}"
|
||||
style="max-height: 100px; max-width: 250px; object-fit: contain;">
|
||||
</a>
|
||||
|
|
|
@ -22,8 +22,8 @@
|
|||
<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.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;">
|
||||
{% if service.logo %}
|
||||
<img class="img-fluid w-100 w-lg-auto" src="{{ service.logo.url }}" alt="{{ service.name }} logo" style="max-height: 120px; object-fit: contain;">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -51,59 +51,26 @@
|
|||
{% endif %}
|
||||
|
||||
<!-- Consulting Partners -->
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
|
||||
<!-- External Links -->
|
||||
{% if service.external_links.exists %}
|
||||
|
@ -215,9 +182,9 @@
|
|||
class="text-decoration-none" style="display: block;">
|
||||
<div class="card h-100 clickable-card">
|
||||
<div class="card-body text-center">
|
||||
{% if offering.cloud_provider.get_logo %}
|
||||
{% if offering.cloud_provider.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"
|
||||
<img src="{{ offering.cloud_provider.logo.url }}" alt="{{ offering.cloud_provider.name }} logo"
|
||||
class="img-fluid" style="max-height: 60px; object-fit: contain;">
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -152,11 +152,11 @@
|
|||
<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.get_logo or service.is_featured or service.is_coming_soon %}
|
||||
{% if service.logo or service.is_featured or service.is_coming_soon %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if service.get_logo %}
|
||||
{% if service.logo %}
|
||||
<div class="card__image flex-shrink-0">
|
||||
<img src="{{ service.get_logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
<img src="{{ service.logo.url }}" alt="{{ service.name }} logo" class="img-fluid">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if service.is_featured %}
|
||||
|
|
|
@ -1,141 +0,0 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.html import format_html
|
||||
from ..models.images import ImageLibrary
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
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" %}
|
||||
{% image_library_img image_id css_class="logo" width="100" height="100" %}
|
||||
"""
|
||||
try:
|
||||
# Try to get by slug first, then by ID
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
# Use provided alt_text or fall back to image's alt_text
|
||||
final_alt_text = alt_text or image.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 width:
|
||||
attrs["width"] = width
|
||||
|
||||
if height:
|
||||
attrs["height"] = height
|
||||
|
||||
# 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
|
||||
return format_html(
|
||||
'<img src="/static/images/placeholder.png" alt="Image not found" class="{}"/>',
|
||||
css_class,
|
||||
)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def image_library_url(slug_or_id):
|
||||
"""
|
||||
Get the URL of an image from the image library.
|
||||
|
||||
Usage:
|
||||
{% image_library_url "my-image-slug" %}
|
||||
{% image_library_url image_id %}
|
||||
"""
|
||||
try:
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
return image.image.url
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return "/static/images/placeholder.png"
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def image_library_info(slug_or_id):
|
||||
"""
|
||||
Get information about an image from the image library.
|
||||
|
||||
Usage:
|
||||
{% image_library_info "my-image-slug" as img_info %}
|
||||
{{ img_info.name }} - {{ img_info.width }}x{{ img_info.height }}
|
||||
"""
|
||||
try:
|
||||
if isinstance(slug_or_id, str):
|
||||
image = ImageLibrary.objects.get(slug=slug_or_id)
|
||||
else:
|
||||
image = ImageLibrary.objects.get(pk=slug_or_id)
|
||||
|
||||
return {
|
||||
"name": image.name,
|
||||
"alt_text": image.alt_text,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"file_size": image.get_file_size_display(),
|
||||
"category": image.get_category_display(),
|
||||
"tags": image.get_tags_list(),
|
||||
"url": image.image.url,
|
||||
}
|
||||
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return {
|
||||
"name": "Image not found",
|
||||
"alt_text": "Image not found",
|
||||
"width": None,
|
||||
"height": None,
|
||||
"file_size": "Unknown",
|
||||
"category": "Unknown",
|
||||
"tags": [],
|
||||
"url": "/static/images/placeholder.png",
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
from datetime import datetime, time
|
||||
# hub/services/templatetags/json_ld_tags.py
|
||||
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()
|
||||
|
@ -108,38 +106,29 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
elif view_name == "service_detail" and "service" in context:
|
||||
data = organization_data
|
||||
service = context["service"]
|
||||
service_url = request.build_absolute_uri()
|
||||
|
||||
# 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()
|
||||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": service.name,
|
||||
"description": service.description,
|
||||
"url": service_url,
|
||||
"category": "Cloud Service",
|
||||
}
|
||||
|
||||
# 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 image if available
|
||||
if hasattr(service, "logo") and service.logo:
|
||||
data["image"] = request.build_absolute_uri(service.logo.url)
|
||||
|
||||
# # 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
|
||||
# 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(),
|
||||
}
|
||||
|
||||
elif view_name == "provider_detail" and "provider" in context:
|
||||
provider = context["provider"]
|
||||
|
@ -154,8 +143,8 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(provider, "get_logo") and provider.get_logo:
|
||||
data["logo"] = request.build_absolute_uri(provider.get_logo.url)
|
||||
if hasattr(provider, "logo") and provider.logo:
|
||||
data["logo"] = request.build_absolute_uri(provider.logo.url)
|
||||
|
||||
# Add contact information if available
|
||||
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
|
||||
|
@ -190,8 +179,8 @@ def json_ld_structured_data(context):
|
|||
}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(partner, "get_logo") and partner.get_logo:
|
||||
data["logo"] = request.build_absolute_uri(partner.get_logo.url)
|
||||
if hasattr(partner, "logo") and partner.logo:
|
||||
data["logo"] = request.build_absolute_uri(partner.logo.url)
|
||||
|
||||
# Add contact information if available
|
||||
contact_point = {"@type": "ContactPoint", "contactType": "Customer Support"}
|
||||
|
@ -220,7 +209,7 @@ def json_ld_structured_data(context):
|
|||
data = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Product",
|
||||
"name": f"Managed {offering.service.name} on {offering.cloud_provider.name}",
|
||||
"name": f"{offering.service.name} on {offering.cloud_provider.name}",
|
||||
"description": offering.description or offering.service.description,
|
||||
"url": offering_url,
|
||||
"category": "Cloud Service",
|
||||
|
@ -230,150 +219,22 @@ def json_ld_structured_data(context):
|
|||
data["brand"] = {"@type": "Brand", "name": offering.service.name}
|
||||
|
||||
# Add image if available
|
||||
if hasattr(offering.service, "get_logo") and offering.service.get_logo:
|
||||
data["image"] = request.build_absolute_uri(offering.service.get_logo.url)
|
||||
if hasattr(offering.service, "logo") and offering.service.logo:
|
||||
data["image"] = request.build_absolute_uri(offering.service.logo.url)
|
||||
|
||||
# Add offers if available
|
||||
if hasattr(offering, "plans") and offering.plans.exists():
|
||||
# Get all plans with pricing
|
||||
plans_with_prices = offering.plans.filter(
|
||||
plan_prices__isnull=False
|
||||
).distinct()
|
||||
|
||||
if plans_with_prices.exists():
|
||||
# Create individual offers for each plan
|
||||
offers = []
|
||||
all_prices = []
|
||||
|
||||
for plan in plans_with_prices:
|
||||
plan_prices = plan.plan_prices.all()
|
||||
if plan_prices.exists():
|
||||
first_price = plan_prices.first()
|
||||
all_prices.extend([p.amount for p in plan_prices])
|
||||
|
||||
offer = {
|
||||
"@type": "Offer",
|
||||
"name": plan.name,
|
||||
"price": str(first_price.amount),
|
||||
"priceCurrency": first_price.currency,
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": offering_url + "#plan-order-form",
|
||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||
}
|
||||
offers.append(offer)
|
||||
|
||||
# Add aggregate offer with all individual offers
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": len(offers),
|
||||
"offers": offers,
|
||||
"seller": {"@type": "Organization", "name": "VSHN"},
|
||||
}
|
||||
|
||||
# Add lowPrice, highPrice and priceCurrency if we have prices
|
||||
if all_prices:
|
||||
data["offers"]["lowPrice"] = str(min(all_prices))
|
||||
data["offers"]["highPrice"] = str(max(all_prices))
|
||||
# Use the currency from the first plan's first price
|
||||
first_plan_with_prices = plans_with_prices.first()
|
||||
first_currency = first_plan_with_prices.plan_prices.first().currency
|
||||
data["offers"]["priceCurrency"] = first_currency
|
||||
|
||||
# Note: aggregateRating and review fields are not included as this is a B2B
|
||||
# service marketplace without a review system. These could be added in the future
|
||||
# if customer reviews/ratings are implemented.
|
||||
# Example structure for future implementation:
|
||||
# if hasattr(offering, 'reviews') and offering.reviews.exists():
|
||||
# data["aggregateRating"] = {
|
||||
# "@type": "AggregateRating",
|
||||
# "ratingValue": "4.5",
|
||||
# "reviewCount": "10"
|
||||
# }
|
||||
else:
|
||||
# No pricing available, just basic offer info
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": offering.plans.count(),
|
||||
"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",
|
||||
data["offers"] = {
|
||||
"@type": "AggregateOffer",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"offerCount": offering.plans.count(),
|
||||
"seller": {
|
||||
"@type": "Organization",
|
||||
"name": offering.cloud_provider.name,
|
||||
"url": request.build_absolute_uri(
|
||||
offering.cloud_provider.get_absolute_url()
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# 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:
|
||||
|
|
|
@ -63,9 +63,9 @@ def social_meta_tags(context):
|
|||
article = context["article"]
|
||||
title = f"Servala - {article.title}"
|
||||
description = article.excerpt
|
||||
# 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)
|
||||
# Use article image if available, otherwise default
|
||||
if article.image:
|
||||
image_url = request.build_absolute_uri(article.image.url)
|
||||
|
||||
# Determine og:type based on view
|
||||
og_type = "website" # default
|
||||
|
|
|
@ -303,15 +303,14 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
base_fee = VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
retrieved_fee = self.price_config.get_base_fee(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
|
||||
retrieved_fee = self.price_config.get_base_fee(Currency.CHF)
|
||||
self.assertEqual(retrieved_fee, Decimal("50.00"))
|
||||
|
||||
# Test non-existent currency
|
||||
non_existent_fee = self.price_config.get_base_fee(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
|
||||
non_existent_fee = self.price_config.get_base_fee(Currency.EUR)
|
||||
self.assertIsNone(non_existent_fee)
|
||||
|
||||
def test_unit_rate_creation_and_retrieval(self):
|
||||
|
@ -340,7 +339,6 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -366,7 +364,6 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -395,7 +392,6 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -417,7 +413,6 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -436,7 +431,6 @@ class VSHNAppCatPriceTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -479,7 +473,6 @@ class VSHNAppCatAddonTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=self.price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -502,11 +495,11 @@ class VSHNAppCatAddonTestCase(TestCase):
|
|||
|
||||
# Create base fee for addon
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
|
||||
addon=addon, currency=Currency.CHF, amount=Decimal("25.00")
|
||||
)
|
||||
|
||||
# Test get_price method
|
||||
price = addon.get_price(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED)
|
||||
price = addon.get_price(Currency.CHF)
|
||||
self.assertEqual(price, Decimal("25.00"))
|
||||
|
||||
def test_addon_unit_rate_type(self):
|
||||
|
@ -560,7 +553,7 @@ class VSHNAppCatAddonTestCase(TestCase):
|
|||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=mandatory_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
|
||||
addon=mandatory_addon, currency=Currency.CHF, amount=Decimal("25.00")
|
||||
)
|
||||
|
||||
# Create mandatory unit rate addon
|
||||
|
@ -601,7 +594,7 @@ class VSHNAppCatAddonTestCase(TestCase):
|
|||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=optional_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
|
||||
addon=optional_addon, currency=Currency.CHF, amount=Decimal("15.00")
|
||||
)
|
||||
|
||||
# Calculate price with selected addon
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from decimal import Decimal
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
|
@ -9,12 +10,16 @@ from ..models.services import Service
|
|||
from ..models.pricing import (
|
||||
ComputePlan,
|
||||
ComputePlanPrice,
|
||||
StoragePlan,
|
||||
StoragePlanPrice,
|
||||
ProgressiveDiscountModel,
|
||||
DiscountTier,
|
||||
VSHNAppCatPrice,
|
||||
VSHNAppCatBaseFee,
|
||||
VSHNAppCatUnitRate,
|
||||
VSHNAppCatAddon,
|
||||
VSHNAppCatAddonBaseFee,
|
||||
VSHNAppCatAddonUnitRate,
|
||||
ExternalPricePlans,
|
||||
)
|
||||
|
||||
|
@ -122,7 +127,6 @@ class PricingEdgeCasesTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("50.00"),
|
||||
)
|
||||
|
||||
|
@ -158,8 +162,7 @@ class PricingEdgeCasesTestCase(TestCase):
|
|||
)
|
||||
|
||||
# Should return None when price doesn't exist
|
||||
# For BASE_FEE addons, service_level is required
|
||||
price = addon.get_price(Currency.CHF, service_level="standard")
|
||||
price = addon.get_price(Currency.CHF)
|
||||
self.assertIsNone(price)
|
||||
|
||||
def test_compute_plan_with_validity_dates(self):
|
||||
|
@ -205,7 +208,6 @@ class PricingEdgeCasesTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=price_config,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("0.01"), # Very small base fee
|
||||
)
|
||||
|
||||
|
|
|
@ -204,10 +204,7 @@ class PricingIntegrationTestCase(TestCase):
|
|||
|
||||
for currency, amount in base_fees:
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, amount=amount
|
||||
)
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount
|
||||
vshn_appcat_price_config=appcat_price, currency=currency, amount=amount
|
||||
)
|
||||
|
||||
# Set up unit rates for different service levels and currencies
|
||||
|
@ -240,7 +237,7 @@ class PricingIntegrationTestCase(TestCase):
|
|||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
|
||||
addon=backup_addon, currency=Currency.CHF, amount=Decimal("15.00")
|
||||
)
|
||||
|
||||
# Create optional addon (monitoring)
|
||||
|
@ -320,7 +317,6 @@ class PricingIntegrationTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.USD,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("30.00"),
|
||||
)
|
||||
|
||||
|
@ -394,7 +390,6 @@ class PricingIntegrationTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=redis_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("20.00"),
|
||||
)
|
||||
|
||||
|
@ -459,7 +454,6 @@ class PricingIntegrationTestCase(TestCase):
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("40.00"), # Base fee for managed service
|
||||
)
|
||||
|
||||
|
@ -480,7 +474,7 @@ class PricingIntegrationTestCase(TestCase):
|
|||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
|
||||
addon=backup_addon, currency=Currency.CHF, amount=Decimal("25.00")
|
||||
)
|
||||
|
||||
monitoring_addon = VSHNAppCatAddon.objects.create(
|
||||
|
@ -507,541 +501,7 @@ class PricingIntegrationTestCase(TestCase):
|
|||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00")
|
||||
)
|
||||
|
||||
# Calculate final price with all selected addons
|
||||
result = appcat_price.calculate_final_price(
|
||||
Currency.CHF,
|
||||
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
16, # 16 GiB RAM
|
||||
addon_ids=[monitoring_addon.id, ssl_addon.id],
|
||||
)
|
||||
|
||||
# Expected calculation:
|
||||
# Base fee: 40.00
|
||||
# RAM cost: First 8 at 6.00 = 48.00, Next 8 at 5.40 (10% discount) = 43.20
|
||||
# RAM total: 91.20
|
||||
# Mandatory backup: 25.00
|
||||
# Optional monitoring: 0.75 * 16 = 12.00
|
||||
# Optional SSL: 18.00
|
||||
# Total: 40.00 + 91.20 + 25.00 + 12.00 + 18.00 = 186.20
|
||||
|
||||
self.assertEqual(result["total_price"], Decimal("186.20"))
|
||||
self.assertEqual(result["addon_total"], Decimal("55.00"))
|
||||
self.assertEqual(len(result["addon_breakdown"]), 3)
|
||||
|
||||
# Verify addon breakdown details
|
||||
addon_names = [addon["name"] for addon in result["addon_breakdown"]]
|
||||
self.assertIn("Enterprise Backup", addon_names)
|
||||
self.assertIn("Advanced Monitoring", addon_names)
|
||||
from decimal import Decimal
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models.base import Currency, Term, Unit
|
||||
from ..models.providers import CloudProvider
|
||||
from ..models.services import Service, Category
|
||||
from ..models.pricing import (
|
||||
ComputePlan,
|
||||
ComputePlanPrice,
|
||||
ComputePlanGroup,
|
||||
StoragePlan,
|
||||
StoragePlanPrice,
|
||||
ProgressiveDiscountModel,
|
||||
DiscountTier,
|
||||
VSHNAppCatPrice,
|
||||
VSHNAppCatBaseFee,
|
||||
VSHNAppCatUnitRate,
|
||||
VSHNAppCatAddon,
|
||||
VSHNAppCatAddonBaseFee,
|
||||
VSHNAppCatAddonUnitRate,
|
||||
ExternalPricePlans,
|
||||
)
|
||||
|
||||
|
||||
class PricingIntegrationTestCase(TestCase):
|
||||
"""Integration tests for pricing models working together"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data for integration tests"""
|
||||
# Create cloud provider
|
||||
self.cloud_provider = CloudProvider.objects.create(
|
||||
name="VSHN Cloud",
|
||||
slug="vshn-cloud",
|
||||
description="Swiss cloud provider",
|
||||
website="https://vshn.ch",
|
||||
is_featured=True,
|
||||
)
|
||||
|
||||
# Create service category
|
||||
self.database_category = Category.objects.create(
|
||||
name="Databases", slug="databases", description="Database services"
|
||||
)
|
||||
|
||||
# Create database service
|
||||
self.postgresql_service = Service.objects.create(
|
||||
name="PostgreSQL",
|
||||
slug="postgresql",
|
||||
description="Managed PostgreSQL database service",
|
||||
tagline="Reliable, scalable PostgreSQL",
|
||||
features="High availability, automated backups, monitoring",
|
||||
is_featured=True,
|
||||
)
|
||||
self.postgresql_service.categories.add(self.database_category)
|
||||
|
||||
# Create compute plan group
|
||||
self.standard_group = ComputePlanGroup.objects.create(
|
||||
name="Standard",
|
||||
description="Standard compute plans",
|
||||
node_label="standard",
|
||||
order=1,
|
||||
)
|
||||
|
||||
# Create multiple compute plans
|
||||
self.small_plan = ComputePlan.objects.create(
|
||||
name="Small",
|
||||
vcpus=1.0,
|
||||
ram=2.0,
|
||||
cpu_mem_ratio=0.5,
|
||||
cloud_provider=self.cloud_provider,
|
||||
group=self.standard_group,
|
||||
term=Term.MTH,
|
||||
active=True,
|
||||
)
|
||||
|
||||
self.medium_plan = ComputePlan.objects.create(
|
||||
name="Medium",
|
||||
vcpus=2.0,
|
||||
ram=4.0,
|
||||
cpu_mem_ratio=0.5,
|
||||
cloud_provider=self.cloud_provider,
|
||||
group=self.standard_group,
|
||||
term=Term.MTH,
|
||||
active=True,
|
||||
)
|
||||
|
||||
self.large_plan = ComputePlan.objects.create(
|
||||
name="Large",
|
||||
vcpus=4.0,
|
||||
ram=8.0,
|
||||
cpu_mem_ratio=0.5,
|
||||
cloud_provider=self.cloud_provider,
|
||||
group=self.standard_group,
|
||||
term=Term.MTH,
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create storage plan
|
||||
self.ssd_storage = StoragePlan.objects.create(
|
||||
name="SSD Storage",
|
||||
cloud_provider=self.cloud_provider,
|
||||
term=Term.MTH,
|
||||
unit=Unit.GIB,
|
||||
)
|
||||
|
||||
# Create progressive discount model for AppCat
|
||||
self.ram_discount_model = ProgressiveDiscountModel.objects.create(
|
||||
name="RAM Volume Discount",
|
||||
description="Progressive discount for RAM usage",
|
||||
active=True,
|
||||
)
|
||||
|
||||
# Create discount tiers
|
||||
DiscountTier.objects.create(
|
||||
discount_model=self.ram_discount_model,
|
||||
min_units=0,
|
||||
max_units=8,
|
||||
discount_percent=Decimal("0.00"), # 0-7 GiB: no discount
|
||||
)
|
||||
|
||||
DiscountTier.objects.create(
|
||||
discount_model=self.ram_discount_model,
|
||||
min_units=8,
|
||||
max_units=32,
|
||||
discount_percent=Decimal("10.00"), # 8-31 GiB: 10% discount
|
||||
)
|
||||
|
||||
DiscountTier.objects.create(
|
||||
discount_model=self.ram_discount_model,
|
||||
min_units=32,
|
||||
max_units=None,
|
||||
discount_percent=Decimal("20.00"), # 32+ GiB: 20% discount
|
||||
)
|
||||
|
||||
def test_complete_pricing_setup(self):
|
||||
"""Test complete pricing setup for all models"""
|
||||
# Set up compute plan prices
|
||||
ComputePlanPrice.objects.create(
|
||||
compute_plan=self.small_plan, currency=Currency.CHF, amount=Decimal("50.00")
|
||||
)
|
||||
|
||||
ComputePlanPrice.objects.create(
|
||||
compute_plan=self.medium_plan,
|
||||
currency=Currency.CHF,
|
||||
amount=Decimal("100.00"),
|
||||
)
|
||||
|
||||
ComputePlanPrice.objects.create(
|
||||
compute_plan=self.large_plan,
|
||||
currency=Currency.CHF,
|
||||
amount=Decimal("200.00"),
|
||||
)
|
||||
|
||||
# Set up storage pricing
|
||||
StoragePlanPrice.objects.create(
|
||||
storage_plan=self.ssd_storage,
|
||||
currency=Currency.CHF,
|
||||
amount=Decimal("0.20"), # 0.20 CHF per GiB
|
||||
)
|
||||
|
||||
# Verify all prices are retrievable
|
||||
self.assertEqual(self.small_plan.get_price(Currency.CHF), Decimal("50.00"))
|
||||
self.assertEqual(self.medium_plan.get_price(Currency.CHF), Decimal("100.00"))
|
||||
self.assertEqual(self.large_plan.get_price(Currency.CHF), Decimal("200.00"))
|
||||
self.assertEqual(self.ssd_storage.get_price(Currency.CHF), Decimal("0.20"))
|
||||
|
||||
def test_multi_currency_pricing(self):
|
||||
"""Test pricing in multiple currencies"""
|
||||
# Set up prices in CHF, EUR, and USD
|
||||
currencies_and_rates = [
|
||||
(Currency.CHF, Decimal("100.00")),
|
||||
(Currency.EUR, Decimal("95.00")),
|
||||
(Currency.USD, Decimal("110.00")),
|
||||
]
|
||||
|
||||
for currency, amount in currencies_and_rates:
|
||||
ComputePlanPrice.objects.create(
|
||||
compute_plan=self.medium_plan, currency=currency, amount=amount
|
||||
)
|
||||
|
||||
# Verify all currencies are available
|
||||
for currency, expected_amount in currencies_and_rates:
|
||||
self.assertEqual(self.medium_plan.get_price(currency), expected_amount)
|
||||
|
||||
def test_appcat_service_with_complete_pricing(self):
|
||||
"""Test complete AppCat service pricing with all features"""
|
||||
# Create AppCat price configuration
|
||||
appcat_price = VSHNAppCatPrice.objects.create(
|
||||
service=self.postgresql_service,
|
||||
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
|
||||
term=Term.MTH,
|
||||
discount_model=self.ram_discount_model,
|
||||
ha_replica_min=1,
|
||||
ha_replica_max=3,
|
||||
public_display_enabled=True,
|
||||
)
|
||||
|
||||
# Set up base fees for different currencies
|
||||
base_fees = [
|
||||
(Currency.CHF, Decimal("25.00")),
|
||||
(Currency.EUR, Decimal("22.50")),
|
||||
(Currency.USD, Decimal("27.50")),
|
||||
]
|
||||
|
||||
for currency, amount in base_fees:
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price, currency=currency, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=amount
|
||||
)
|
||||
|
||||
# Set up unit rates for different service levels and currencies
|
||||
unit_rates = [
|
||||
(Currency.CHF, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.5000")),
|
||||
(Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.0000")),
|
||||
(Currency.EUR, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.2000")),
|
||||
(Currency.EUR, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("4.5000")),
|
||||
(Currency.USD, VSHNAppCatPrice.ServiceLevel.BEST_EFFORT, Decimal("3.8000")),
|
||||
(Currency.USD, VSHNAppCatPrice.ServiceLevel.GUARANTEED, Decimal("5.5000")),
|
||||
]
|
||||
|
||||
for currency, service_level, amount in unit_rates:
|
||||
VSHNAppCatUnitRate.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=currency,
|
||||
service_level=service_level,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
# Create mandatory addon (backup)
|
||||
backup_addon = VSHNAppCatAddon.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
name="Automated Backup",
|
||||
description="Daily automated backups with 30-day retention",
|
||||
commercial_description="Never lose your data with automated daily backups",
|
||||
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
|
||||
mandatory=True,
|
||||
order=1,
|
||||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("15.00")
|
||||
)
|
||||
|
||||
# Create optional addon (monitoring)
|
||||
monitoring_addon = VSHNAppCatAddon.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
name="Advanced Monitoring",
|
||||
description="Detailed monitoring with custom alerts",
|
||||
commercial_description="Get insights into your database performance",
|
||||
addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE,
|
||||
mandatory=False,
|
||||
order=2,
|
||||
)
|
||||
|
||||
VSHNAppCatAddonUnitRate.objects.create(
|
||||
addon=monitoring_addon,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("0.5000"),
|
||||
)
|
||||
|
||||
# Test price calculation scenarios
|
||||
|
||||
# Scenario 1: Small setup (4 GiB RAM, no discount)
|
||||
result_small = appcat_price.calculate_final_price(
|
||||
Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 4
|
||||
)
|
||||
|
||||
# Base: 25 + (5 * 4) = 45
|
||||
# Mandatory backup: 15
|
||||
# Total: 60
|
||||
self.assertEqual(result_small["total_price"], Decimal("60.00"))
|
||||
self.assertEqual(result_small["addon_total"], Decimal("15.00"))
|
||||
self.assertEqual(len(result_small["addon_breakdown"]), 1)
|
||||
|
||||
# Scenario 2: Medium setup (16 GiB RAM, partial discount)
|
||||
result_medium = appcat_price.calculate_final_price(
|
||||
Currency.CHF, VSHNAppCatPrice.ServiceLevel.GUARANTEED, 16
|
||||
)
|
||||
|
||||
# First 8 GiB at full rate: 5 * 8 = 40
|
||||
# Next 8 GiB at 90% (10% discount): 5 * 0.9 * 8 = 36
|
||||
# Unit cost: 76
|
||||
# Base: 25 + 76 = 101
|
||||
# Mandatory backup: 15
|
||||
# Total: 116
|
||||
self.assertEqual(result_medium["total_price"], Decimal("116.00"))
|
||||
|
||||
# Scenario 3: Large setup with optional addon (40 GiB RAM, full discount tiers)
|
||||
result_large = appcat_price.calculate_final_price(
|
||||
Currency.CHF,
|
||||
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
40,
|
||||
addon_ids=[monitoring_addon.id],
|
||||
)
|
||||
|
||||
# First 8 GiB at full rate: 5 * 8 = 40
|
||||
# Next 24 GiB at 90% (10% discount): 5 * 0.9 * 24 = 108
|
||||
# Next 8 GiB at 80% (20% discount): 5 * 0.8 * 8 = 32
|
||||
# Unit cost: 180
|
||||
# Base: 25 + 180 = 205
|
||||
# Mandatory backup: 15
|
||||
# Optional monitoring: 0.5 * 40 = 20
|
||||
# Total: 240
|
||||
self.assertEqual(result_large["total_price"], Decimal("240.00"))
|
||||
self.assertEqual(result_large["addon_total"], Decimal("35.00"))
|
||||
self.assertEqual(len(result_large["addon_breakdown"]), 2)
|
||||
|
||||
def test_external_price_comparison_integration(self):
|
||||
"""Test external price comparison with internal pricing"""
|
||||
# Set up internal pricing
|
||||
appcat_price = VSHNAppCatPrice.objects.create(
|
||||
service=self.postgresql_service,
|
||||
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
|
||||
term=Term.MTH,
|
||||
)
|
||||
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.USD,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("30.00"),
|
||||
)
|
||||
|
||||
VSHNAppCatUnitRate.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.USD,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("4.0000"),
|
||||
)
|
||||
|
||||
# Create external competitor pricing
|
||||
aws_provider = CloudProvider.objects.create(
|
||||
name="AWS",
|
||||
slug="aws",
|
||||
description="Amazon Web Services",
|
||||
website="https://aws.amazon.com",
|
||||
)
|
||||
|
||||
external_price = ExternalPricePlans.objects.create(
|
||||
plan_name="RDS PostgreSQL db.t3.medium",
|
||||
description="AWS RDS PostgreSQL instance",
|
||||
source="https://aws.amazon.com/rds/postgresql/pricing/",
|
||||
cloud_provider=aws_provider,
|
||||
service=self.postgresql_service,
|
||||
vshn_appcat_price=appcat_price,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
currency=Currency.USD,
|
||||
term=Term.MTH,
|
||||
amount=Decimal("62.56"), # Monthly cost for db.t3.medium
|
||||
vcpus=2.0,
|
||||
ram=4.0, # 4 GiB RAM
|
||||
storage=20.0, # 20 GiB storage included
|
||||
competitor_sla="99.95%",
|
||||
replicas=1,
|
||||
)
|
||||
|
||||
# Compare internal vs external pricing for equivalent setup
|
||||
internal_result = appcat_price.calculate_final_price(
|
||||
Currency.USD,
|
||||
VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
4, # 4 GiB RAM to match external offering
|
||||
)
|
||||
|
||||
# Internal: 30 + (4 * 4) = 46 USD
|
||||
internal_price = internal_result["total_price"]
|
||||
external_price_amount = external_price.amount
|
||||
|
||||
self.assertEqual(internal_price, Decimal("46.00"))
|
||||
self.assertEqual(external_price_amount, Decimal("62.56"))
|
||||
|
||||
# Verify our pricing is competitive
|
||||
self.assertLess(internal_price, external_price_amount)
|
||||
|
||||
def test_service_availability_with_pricing(self):
|
||||
"""Test service availability based on pricing configuration"""
|
||||
# Create service with pricing but not enabled for public display
|
||||
redis_service = Service.objects.create(
|
||||
name="Redis",
|
||||
slug="redis",
|
||||
description="In-memory data store",
|
||||
features="High performance caching",
|
||||
)
|
||||
|
||||
redis_price = VSHNAppCatPrice.objects.create(
|
||||
service=redis_service,
|
||||
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
|
||||
term=Term.MTH,
|
||||
public_display_enabled=False, # Private pricing
|
||||
)
|
||||
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=redis_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("20.00"),
|
||||
)
|
||||
|
||||
# Service should exist but not be publicly available for pricing
|
||||
self.assertFalse(redis_price.public_display_enabled)
|
||||
|
||||
# Enable public display
|
||||
redis_price.public_display_enabled = True
|
||||
redis_price.save()
|
||||
|
||||
self.assertTrue(redis_price.public_display_enabled)
|
||||
|
||||
def test_pricing_model_relationships(self):
|
||||
"""Test all pricing model relationships work correctly"""
|
||||
# Verify cloud provider relationships
|
||||
self.assertEqual(self.cloud_provider.compute_plans.count(), 3)
|
||||
self.assertEqual(self.cloud_provider.storage_plans.count(), 1)
|
||||
|
||||
# Verify service relationships
|
||||
self.assertTrue(hasattr(self.postgresql_service, "vshn_appcat_price"))
|
||||
|
||||
# Verify compute plan group relationships
|
||||
self.assertEqual(self.standard_group.compute_plans.count(), 3)
|
||||
|
||||
# Create and verify discount model relationships
|
||||
appcat_price = VSHNAppCatPrice.objects.create(
|
||||
service=self.postgresql_service,
|
||||
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
|
||||
term=Term.MTH,
|
||||
discount_model=self.ram_discount_model,
|
||||
)
|
||||
|
||||
self.assertEqual(self.ram_discount_model.price_configs.count(), 1)
|
||||
self.assertEqual(self.ram_discount_model.tiers.count(), 3)
|
||||
|
||||
# Test cascade deletions work properly
|
||||
service_id = self.postgresql_service.id
|
||||
appcat_price_id = appcat_price.id
|
||||
|
||||
# Delete service should cascade to appcat price
|
||||
self.postgresql_service.delete()
|
||||
|
||||
with self.assertRaises(VSHNAppCatPrice.DoesNotExist):
|
||||
VSHNAppCatPrice.objects.get(id=appcat_price_id)
|
||||
|
||||
def test_comprehensive_pricing_scenario(self):
|
||||
"""Test a comprehensive real-world pricing scenario"""
|
||||
# Company needs PostgreSQL with high availability
|
||||
# Requirements: 16 GiB RAM, automated backups, monitoring, SSL
|
||||
|
||||
appcat_price = VSHNAppCatPrice.objects.create(
|
||||
service=self.postgresql_service,
|
||||
variable_unit=VSHNAppCatPrice.VariableUnit.RAM,
|
||||
term=Term.MTH,
|
||||
discount_model=self.ram_discount_model,
|
||||
ha_replica_min=2,
|
||||
ha_replica_max=3,
|
||||
public_display_enabled=True,
|
||||
)
|
||||
|
||||
# Set up pricing
|
||||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("40.00"), # Base fee for managed service
|
||||
)
|
||||
|
||||
VSHNAppCatUnitRate.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("6.0000"), # CHF per GiB RAM
|
||||
)
|
||||
|
||||
# Create all required addons
|
||||
backup_addon = VSHNAppCatAddon.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
name="Enterprise Backup",
|
||||
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
|
||||
mandatory=True,
|
||||
order=1,
|
||||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=backup_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("25.00")
|
||||
)
|
||||
|
||||
monitoring_addon = VSHNAppCatAddon.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
name="Advanced Monitoring",
|
||||
addon_type=VSHNAppCatAddon.AddonType.UNIT_RATE,
|
||||
mandatory=False,
|
||||
order=2,
|
||||
)
|
||||
|
||||
VSHNAppCatAddonUnitRate.objects.create(
|
||||
addon=monitoring_addon,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=Decimal("0.7500"),
|
||||
)
|
||||
|
||||
ssl_addon = VSHNAppCatAddon.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
name="SSL Certificate",
|
||||
addon_type=VSHNAppCatAddon.AddonType.BASE_FEE,
|
||||
mandatory=False,
|
||||
order=3,
|
||||
)
|
||||
|
||||
VSHNAppCatAddonBaseFee.objects.create(
|
||||
addon=ssl_addon, currency=Currency.CHF, service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED, amount=Decimal("18.00")
|
||||
addon=ssl_addon, currency=Currency.CHF, amount=Decimal("18.00")
|
||||
)
|
||||
|
||||
# Calculate final price with all selected addons
|
||||
|
|
|
@ -81,7 +81,6 @@ class PricingTestMixin:
|
|||
VSHNAppCatBaseFee.objects.create(
|
||||
vshn_appcat_price_config=appcat_price,
|
||||
currency=Currency.CHF,
|
||||
service_level=VSHNAppCatPrice.ServiceLevel.GUARANTEED,
|
||||
amount=base_fee,
|
||||
)
|
||||
|
||||
|
|
|
@ -1,243 +0,0 @@
|
|||
from django.core.files.base import ContentFile
|
||||
from django.utils.text import slugify
|
||||
from ..models.images import ImageLibrary
|
||||
import os
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
requests = None
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def create_image_from_file(
|
||||
file_path, name, description="", alt_text="", category="other", tags=""
|
||||
):
|
||||
"""
|
||||
Create an ImageLibrary entry from a local file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the image file
|
||||
name: Name for the image
|
||||
description: Optional description
|
||||
alt_text: Alternative text for accessibility
|
||||
category: Image category
|
||||
tags: Comma-separated tags
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if failed
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
print(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
# Generate slug
|
||||
slug = slugify(name)
|
||||
|
||||
# Check if image already exists
|
||||
if ImageLibrary.objects.filter(slug=slug).exists():
|
||||
print(f"Image with slug '{slug}' already exists")
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
alt_text=alt_text or name,
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Read and save the image file
|
||||
with open(file_path, "rb") as f:
|
||||
image_lib.image.save(
|
||||
os.path.basename(file_path), ContentFile(f.read()), save=True
|
||||
)
|
||||
|
||||
print(f"Created image library entry: {name}")
|
||||
return image_lib
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating image library entry: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def create_image_from_url(
|
||||
url, name, description="", alt_text="", category="other", tags=""
|
||||
):
|
||||
"""
|
||||
Create an ImageLibrary entry from a URL.
|
||||
|
||||
Args:
|
||||
url: URL to the image
|
||||
name: Name for the image
|
||||
description: Optional description
|
||||
alt_text: Alternative text for accessibility
|
||||
category: Image category
|
||||
tags: Comma-separated tags
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if failed
|
||||
"""
|
||||
if requests is None:
|
||||
print("requests library is not installed. Cannot download from URL.")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Generate slug
|
||||
slug = slugify(name)
|
||||
|
||||
# Check if image already exists
|
||||
if ImageLibrary.objects.filter(slug=slug).exists():
|
||||
print(f"Image with slug '{slug}' already exists")
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
|
||||
# Download the image
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Create image library entry
|
||||
image_lib = ImageLibrary(
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
alt_text=alt_text or name,
|
||||
category=category,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Save the image
|
||||
filename = url.split("/")[-1]
|
||||
if "?" in filename:
|
||||
filename = filename.split("?")[0]
|
||||
|
||||
image_lib.image.save(filename, ContentFile(response.content), save=True)
|
||||
|
||||
print(f"Created image library entry from URL: {name}")
|
||||
return image_lib
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating image library entry from URL: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_image_by_slug(slug):
|
||||
"""
|
||||
Get an image from the library by slug.
|
||||
|
||||
Args:
|
||||
slug: Slug of the image
|
||||
|
||||
Returns:
|
||||
ImageLibrary instance or None if not found
|
||||
"""
|
||||
try:
|
||||
return ImageLibrary.objects.get(slug=slug)
|
||||
except ImageLibrary.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_images_by_category(category):
|
||||
"""
|
||||
Get all images from a specific category.
|
||||
|
||||
Args:
|
||||
category: Category name
|
||||
|
||||
Returns:
|
||||
QuerySet of ImageLibrary instances
|
||||
"""
|
||||
return ImageLibrary.objects.filter(category=category)
|
||||
|
||||
|
||||
def get_images_by_tags(tags):
|
||||
"""
|
||||
Get images that contain any of the specified tags.
|
||||
|
||||
Args:
|
||||
tags: List of tags or comma-separated string
|
||||
|
||||
Returns:
|
||||
QuerySet of ImageLibrary instances
|
||||
"""
|
||||
if isinstance(tags, str):
|
||||
tags = [tag.strip() for tag in tags.split(",")]
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
query = Q()
|
||||
for tag in tags:
|
||||
query |= Q(tags__icontains=tag)
|
||||
|
||||
return ImageLibrary.objects.filter(query).distinct()
|
||||
|
||||
|
||||
def cleanup_unused_images():
|
||||
"""
|
||||
Find and optionally clean up unused images from the library.
|
||||
|
||||
Returns:
|
||||
List of ImageLibrary instances with usage_count = 0
|
||||
"""
|
||||
unused_images = ImageLibrary.objects.filter(usage_count=0)
|
||||
|
||||
print(f"Found {unused_images.count()} unused images:")
|
||||
for image in unused_images:
|
||||
print(f" - {image.name} ({image.slug})")
|
||||
|
||||
return unused_images
|
||||
|
||||
|
||||
def optimize_image(image_library_instance, max_width=1920, max_height=1080, quality=85):
|
||||
"""
|
||||
Optimize an image in the library by resizing and compressing.
|
||||
|
||||
Args:
|
||||
image_library_instance: ImageLibrary instance
|
||||
max_width: Maximum width in pixels
|
||||
max_height: Maximum height in pixels
|
||||
quality: JPEG quality (1-100)
|
||||
|
||||
Returns:
|
||||
bool: True if optimization was successful
|
||||
"""
|
||||
try:
|
||||
if not image_library_instance.image:
|
||||
return False
|
||||
|
||||
# Open the image
|
||||
with PILImage.open(image_library_instance.image.path) as img:
|
||||
# Calculate new dimensions while maintaining aspect ratio
|
||||
ratio = min(max_width / img.width, max_height / img.height)
|
||||
|
||||
if ratio < 1: # Only resize if image is larger than max dimensions
|
||||
new_width = int(img.width * ratio)
|
||||
new_height = int(img.height * ratio)
|
||||
|
||||
# Resize the image
|
||||
img_resized = img.resize(
|
||||
(new_width, new_height), PILImage.Resampling.LANCZOS
|
||||
)
|
||||
|
||||
# Save the optimized image
|
||||
img_resized.save(
|
||||
image_library_instance.image.path,
|
||||
format="JPEG",
|
||||
quality=quality,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
# Update the image properties
|
||||
image_library_instance._update_image_properties()
|
||||
|
||||
print(f"Optimized image: {image_library_instance.name}")
|
||||
return True
|
||||
else:
|
||||
print(f"Image already optimal: {image_library_instance.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error optimizing image {image_library_instance.name}: {e}")
|
||||
return False
|
|
@ -23,7 +23,7 @@ def article_list(request):
|
|||
# Apply filters based on request parameters
|
||||
if search_query:
|
||||
articles = articles.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
@ -41,7 +41,7 @@ def article_list(request):
|
|||
# Order articles: featured first, then by creation date (newest first)
|
||||
articles = articles.order_by(
|
||||
"-is_featured", # Featured first (True before False)
|
||||
"-article_date", # Newest first
|
||||
"-created_at", # Newest first
|
||||
)
|
||||
|
||||
# Create base querysets for each filter type that apply all OTHER current filters
|
||||
|
@ -51,7 +51,7 @@ def article_list(request):
|
|||
service_filter_base = all_articles
|
||||
if search_query:
|
||||
service_filter_base = service_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
@ -69,7 +69,7 @@ def article_list(request):
|
|||
cp_filter_base = all_articles
|
||||
if search_query:
|
||||
cp_filter_base = cp_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
@ -85,7 +85,7 @@ def article_list(request):
|
|||
cloud_filter_base = all_articles
|
||||
if search_query:
|
||||
cloud_filter_base = cloud_filter_base.filter(
|
||||
Q(title__icontains=search_query)
|
||||
Q(title__icontains=search_query)
|
||||
| Q(excerpt__icontains=search_query)
|
||||
| Q(content__icontains=search_query)
|
||||
| Q(meta_keywords__icontains=search_query)
|
||||
|
@ -136,14 +136,16 @@ def article_detail(request, slug):
|
|||
Article.objects.select_related(
|
||||
"author",
|
||||
"related_service",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider",
|
||||
"related_consulting_partner",
|
||||
"related_cloud_provider"
|
||||
).filter(is_published=True),
|
||||
slug=slug,
|
||||
)
|
||||
|
||||
# Get related articles (same service, partner, or provider)
|
||||
related_articles = Article.objects.filter(is_published=True).exclude(id=article.id)
|
||||
related_articles = Article.objects.filter(
|
||||
is_published=True
|
||||
).exclude(id=article.id)
|
||||
|
||||
if article.related_service:
|
||||
related_articles = related_articles.filter(
|
||||
|
@ -162,13 +164,13 @@ def article_detail(request, slug):
|
|||
related_articles = related_articles.filter(
|
||||
related_service__isnull=True,
|
||||
related_consulting_partner__isnull=True,
|
||||
related_cloud_provider__isnull=True,
|
||||
related_cloud_provider__isnull=True
|
||||
)
|
||||
|
||||
related_articles = related_articles.order_by("-article_date")[:3]
|
||||
related_articles = related_articles.order_by("-created_at")[:3]
|
||||
|
||||
context = {
|
||||
"article": article,
|
||||
"related_articles": related_articles,
|
||||
}
|
||||
return render(request, "services/article_detail.html", context)
|
||||
return render(request, "services/article_detail.html", context)
|
|
@ -173,28 +173,15 @@ def generate_exoscale_marketplace_yaml(offering):
|
|||
).strip()
|
||||
|
||||
# Build YAML structure
|
||||
service_name = offering.service.name
|
||||
|
||||
# List of service names that should have "Enterprise" appended
|
||||
# This concerns all services which are already available on Exoscale Marketplace or DBaaS for differentiation
|
||||
# A workaround because we don't particularly have "Enterprise" services yet
|
||||
enterprise_services = ["GitLab", "PostgreSQL"]
|
||||
|
||||
if any(
|
||||
enterprise_service in service_name for enterprise_service in enterprise_services
|
||||
):
|
||||
service_name += " Enterprise"
|
||||
|
||||
title = f"{service_name} by Servala"
|
||||
yaml_structure = {
|
||||
yaml_key: {
|
||||
"page_class": "tmpl-marketplace-product",
|
||||
"html_title": title,
|
||||
"meta_desc": f"Managed {offering.service.name} by Servala - a product by VSHN. Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||
"page_header_title": title,
|
||||
"html_title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"meta_desc": "Servala is the Open Cloud Native Service Hub. It connects businesses, developers, and cloud service providers on one unique hub with secure, scalable, and easy-to-use cloud-native services.",
|
||||
"page_header_title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"provider_key": "vshn",
|
||||
"slug": f"{offering.service.slug}-by-servala",
|
||||
"title": title,
|
||||
"slug": f"servala-managed-{offering.service.slug}",
|
||||
"title": f"Managed {offering.service.name} by VSHN via Servala",
|
||||
"logo": f"img/servala-{offering.service.slug}.svg",
|
||||
"list_display": [],
|
||||
"meta": [
|
||||
|
@ -353,7 +340,7 @@ def generate_pricing_data(offering):
|
|||
|
||||
# Get pricing components
|
||||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||
base_fee = appcat_price.get_base_fee(currency)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
|
@ -393,7 +380,7 @@ def generate_pricing_data(offering):
|
|||
addon_price_per_unit = None
|
||||
|
||||
if addon.addon_type == "BF": # Base Fee
|
||||
addon_price = addon.get_price(currency, service_level)
|
||||
addon_price = addon.get_price(currency)
|
||||
elif addon.addon_type == "UR": # Unit Rate
|
||||
addon_price_per_unit = addon.get_price(currency, service_level)
|
||||
if addon_price_per_unit:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
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):
|
||||
|
@ -9,7 +8,6 @@ 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")
|
||||
|
@ -26,9 +24,6 @@ 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(
|
||||
|
@ -73,7 +68,6 @@ 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)
|
||||
|
||||
|
|
|
@ -2,21 +2,20 @@ import re
|
|||
|
||||
from django.shortcuts import render
|
||||
from collections import defaultdict
|
||||
from hub.services.models.pricing import (
|
||||
from hub.services.models import (
|
||||
ComputePlan,
|
||||
StoragePlan,
|
||||
ExternalPricePlans,
|
||||
VSHNAppCatPrice,
|
||||
ExternalPricePlans,
|
||||
StoragePlan,
|
||||
)
|
||||
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)
|
||||
parts = re.split(r"(\d+)", name)
|
||||
return [int(part) if part.isdigit() else part for part in parts]
|
||||
def natural_sort_key(name):
|
||||
"""Extract numeric part from compute plan name for natural sorting"""
|
||||
match = re.search(r"compute-std-(\d+)", name)
|
||||
return int(match.group(1)) if match else 0
|
||||
|
||||
|
||||
def get_external_price_comparisons(plan, appcat_price, currency, service_level):
|
||||
|
@ -39,93 +38,9 @@ def get_external_price_comparisons(plan, appcat_price, currency, service_level):
|
|||
return []
|
||||
|
||||
|
||||
def get_internal_cloud_provider_comparisons(
|
||||
plan, appcat_price, currency, service_level
|
||||
):
|
||||
"""Get internal comparisons with other cloud provider plans from the database"""
|
||||
try:
|
||||
# Get similar compute plans from other cloud providers with same specs
|
||||
similar_plans = (
|
||||
ComputePlan.objects.filter(
|
||||
active=True,
|
||||
vcpus=plan.vcpus,
|
||||
ram=plan.ram,
|
||||
)
|
||||
.exclude(cloud_provider=plan.cloud_provider) # Exclude same cloud provider
|
||||
.select_related("cloud_provider")
|
||||
.prefetch_related("prices")
|
||||
)
|
||||
|
||||
internal_comparisons = []
|
||||
|
||||
for similar_plan in similar_plans:
|
||||
# Get pricing components for comparison plan
|
||||
compare_plan_price = similar_plan.get_price(currency)
|
||||
compare_base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||
compare_unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
if any(
|
||||
price is None
|
||||
for price in [compare_plan_price, compare_base_fee, compare_unit_rate]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate units based on variable unit type
|
||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||
units = int(similar_plan.ram)
|
||||
elif appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.CPU:
|
||||
units = int(similar_plan.vcpus)
|
||||
else:
|
||||
continue
|
||||
|
||||
# Calculate replica enforcement based on service level
|
||||
if service_level == VSHNAppCatPrice.ServiceLevel.GUARANTEED:
|
||||
replica_enforce = appcat_price.ha_replica_min
|
||||
else:
|
||||
replica_enforce = 1
|
||||
|
||||
total_units = units * replica_enforce
|
||||
|
||||
# Calculate final price using the same logic as the main plan
|
||||
price_calculation = appcat_price.calculate_final_price(
|
||||
currency_code=currency,
|
||||
service_level=service_level,
|
||||
number_of_units=total_units,
|
||||
addon_ids=None, # Include only mandatory addons
|
||||
)
|
||||
|
||||
if price_calculation is None:
|
||||
continue
|
||||
|
||||
service_price_with_addons = price_calculation["total_price"]
|
||||
compare_final_price = compare_plan_price + service_price_with_addons
|
||||
|
||||
internal_comparisons.append(
|
||||
{
|
||||
"plan_name": similar_plan.name,
|
||||
"provider": similar_plan.cloud_provider.name,
|
||||
"compute_plan_price": compare_plan_price,
|
||||
"service_price": service_price_with_addons,
|
||||
"final_price": compare_final_price,
|
||||
"currency": currency,
|
||||
"vcpus": similar_plan.vcpus,
|
||||
"ram": similar_plan.ram,
|
||||
"group_name": (
|
||||
similar_plan.group.name if similar_plan.group else "No Group"
|
||||
),
|
||||
"is_internal": True, # Flag to distinguish from external comparisons
|
||||
}
|
||||
)
|
||||
|
||||
return internal_comparisons
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def pricelist(request):
|
||||
"""Generate comprehensive price list grouped by compute plan groups and service levels (optimized)"""
|
||||
"""Generate comprehensive price list grouped by compute plan groups and service levels"""
|
||||
# Get filter parameters from request
|
||||
show_discount_details = request.GET.get("discount_details", "").lower() == "true"
|
||||
show_addon_details = request.GET.get("addon_details", "").lower() == "true"
|
||||
|
@ -135,90 +50,45 @@ 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")
|
||||
# Fetch all active compute plans with related data
|
||||
compute_plans = (
|
||||
ComputePlan.objects.filter(active=True)
|
||||
.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name", "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()
|
||||
# Apply compute plan filters
|
||||
if filter_cloud_provider:
|
||||
compute_plans_qs = compute_plans_qs.filter(
|
||||
cloud_provider__name=filter_cloud_provider
|
||||
)
|
||||
compute_plans = compute_plans.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)
|
||||
compute_plans = compute_plans.filter(group__isnull=True)
|
||||
else:
|
||||
compute_plans_qs = compute_plans_qs.filter(
|
||||
group__name=filter_compute_plan_group
|
||||
)
|
||||
compute_plans = list(
|
||||
compute_plans_qs.select_related("cloud_provider", "group")
|
||||
.prefetch_related("prices")
|
||||
.order_by("group__order", "group__name", "cloud_provider__name", "name")
|
||||
)
|
||||
# Restore natural sorting of compute plan names
|
||||
compute_plans = compute_plans.filter(group__name=filter_compute_plan_group)
|
||||
|
||||
# Apply natural sorting for compute plan names
|
||||
compute_plans = sorted(
|
||||
compute_plans,
|
||||
key=lambda p: (
|
||||
p.group.order if p.group else 999,
|
||||
p.group.name if p.group else "ZZZ",
|
||||
natural_sort_key(p),
|
||||
key=lambda x: (
|
||||
x.group.order if x.group else 999, # No group plans at the end
|
||||
x.group.name if x.group else "ZZZ",
|
||||
x.cloud_provider.name,
|
||||
natural_sort_key(x.name),
|
||||
),
|
||||
)
|
||||
|
||||
# Fetch all appcat price configurations (prefetch addons)
|
||||
appcat_prices_qs = (
|
||||
# Fetch all appcat price configurations
|
||||
appcat_prices = (
|
||||
VSHNAppCatPrice.objects.all()
|
||||
.select_related("service", "discount_model")
|
||||
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers", "addons")
|
||||
.prefetch_related("base_fees", "unit_rates", "discount_model__tiers")
|
||||
.order_by("service__name")
|
||||
)
|
||||
if filter_service:
|
||||
appcat_prices_qs = appcat_prices_qs.filter(service__name=filter_service)
|
||||
appcat_prices = list(appcat_prices_qs)
|
||||
|
||||
# Prefetch all storage plans for all cloud providers and build a lookup
|
||||
all_storage_plans = StoragePlan.objects.all().prefetch_related("prices")
|
||||
storage_plans_by_provider = defaultdict(list)
|
||||
for sp in all_storage_plans:
|
||||
storage_plans_by_provider[sp.cloud_provider_id].append(sp)
|
||||
# Apply service filter
|
||||
if filter_service:
|
||||
appcat_prices = appcat_prices.filter(service__name=filter_service)
|
||||
|
||||
pricing_data_by_group_and_service_level = defaultdict(lambda: defaultdict(list))
|
||||
processed_combinations = set()
|
||||
|
@ -226,6 +96,7 @@ def pricelist(request):
|
|||
# Generate pricing combinations for each compute plan and service
|
||||
for plan in compute_plans:
|
||||
plan_currencies = set(plan.prices.values_list("currency", flat=True))
|
||||
|
||||
for appcat_price in appcat_prices:
|
||||
# Determine units based on variable unit type
|
||||
if appcat_price.variable_unit == VSHNAppCatPrice.VariableUnit.RAM:
|
||||
|
@ -234,12 +105,15 @@ 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()
|
||||
|
||||
# Apply service level filter
|
||||
if filter_service_level:
|
||||
service_levels = [
|
||||
|
@ -248,18 +122,22 @@ def pricelist(request):
|
|||
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)
|
||||
)
|
||||
|
||||
# Find currencies that exist across all pricing components
|
||||
matching_currencies = plan_currencies.intersection(
|
||||
base_fee_currencies
|
||||
).intersection(unit_rate_currencies)
|
||||
|
||||
if not matching_currencies:
|
||||
continue
|
||||
|
||||
for currency in matching_currencies:
|
||||
combination_key = (
|
||||
plan.cloud_provider.name,
|
||||
|
@ -268,25 +146,34 @@ def pricelist(request):
|
|||
service_level,
|
||||
currency,
|
||||
)
|
||||
|
||||
# Skip if combination already processed
|
||||
if combination_key in processed_combinations:
|
||||
continue
|
||||
|
||||
processed_combinations.add(combination_key)
|
||||
|
||||
# Get pricing components
|
||||
compute_plan_price = plan.get_price(currency)
|
||||
base_fee = appcat_price.get_base_fee(currency, service_level)
|
||||
base_fee = appcat_price.get_base_fee(currency)
|
||||
unit_rate = appcat_price.get_unit_rate(currency, service_level)
|
||||
|
||||
# Skip if any pricing component is missing
|
||||
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:
|
||||
replica_enforce = appcat_price.ha_replica_min
|
||||
else:
|
||||
replica_enforce = 1
|
||||
|
||||
total_units = units * replica_enforce
|
||||
standard_sla_price = base_fee + (total_units * unit_rate)
|
||||
|
||||
# Apply discount if available
|
||||
discount_breakdown = None
|
||||
if (
|
||||
|
@ -315,6 +202,7 @@ def pricelist(request):
|
|||
discounted_price = total_units * unit_rate
|
||||
discount_savings = 0
|
||||
discount_percentage = 0
|
||||
|
||||
# Calculate final price using the model method to ensure consistency
|
||||
price_calculation = appcat_price.calculate_final_price(
|
||||
currency_code=currency,
|
||||
|
@ -322,24 +210,60 @@ def pricelist(request):
|
|||
number_of_units=total_units,
|
||||
addon_ids=None, # This will include only mandatory addons
|
||||
)
|
||||
|
||||
if price_calculation is None:
|
||||
continue
|
||||
|
||||
# Calculate base service price (without addons) for display purposes
|
||||
base_sla_price = base_fee + (total_units * unit_rate)
|
||||
# Extract addon information from the calculation (use prefetched addons)
|
||||
|
||||
# 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
|
||||
)
|
||||
)
|
||||
sla_price = base_fee + discounted_price
|
||||
discount_savings = base_sla_price - sla_price
|
||||
discount_percentage = (
|
||||
(discount_savings / base_sla_price) * 100
|
||||
if base_sla_price > 0
|
||||
else 0
|
||||
)
|
||||
discount_breakdown = (
|
||||
appcat_price.discount_model.get_discount_breakdown(
|
||||
unit_rate, total_units
|
||||
)
|
||||
)
|
||||
else:
|
||||
sla_price = base_sla_price
|
||||
discounted_price = total_units * unit_rate
|
||||
discount_savings = 0
|
||||
discount_percentage = 0
|
||||
|
||||
# Extract addon information from the calculation
|
||||
mandatory_addons = []
|
||||
optional_addons = []
|
||||
all_addons = [a for a in appcat_price.addons.all() if a.active]
|
||||
|
||||
# Get all addons to separate mandatory from optional
|
||||
all_addons = appcat_price.addons.filter(active=True)
|
||||
for addon in all_addons:
|
||||
addon_price = None
|
||||
|
||||
if addon.addon_type == "BF": # Base Fee
|
||||
addon_price = addon.get_price(currency, service_level)
|
||||
addon_price = addon.get_price(currency)
|
||||
elif addon.addon_type == "UR": # Unit Rate
|
||||
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 = {
|
||||
"id": addon.id,
|
||||
"name": addon.name,
|
||||
|
@ -348,34 +272,39 @@ def pricelist(request):
|
|||
"addon_type": addon.get_addon_type_display(),
|
||||
"price": addon_price,
|
||||
}
|
||||
|
||||
if addon.mandatory:
|
||||
mandatory_addons.append(addon_info)
|
||||
else:
|
||||
optional_addons.append(addon_info)
|
||||
|
||||
# Use the calculated total price which includes mandatory addons
|
||||
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)
|
||||
# Get external/internal price comparisons if enabled (unchanged, but could be optimized further)
|
||||
service_level_display = dict(VSHNAppCatPrice.ServiceLevel.choices)[
|
||||
service_level
|
||||
]
|
||||
|
||||
# Get external price comparisons if enabled
|
||||
external_comparisons = []
|
||||
internal_comparisons = []
|
||||
if show_price_comparison:
|
||||
external_prices = get_external_price_comparisons(
|
||||
plan, appcat_price, currency, service_level
|
||||
)
|
||||
for ext_price in external_prices:
|
||||
# Calculate price difference using external price currency
|
||||
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,
|
||||
"currency": ext_price.currency, # Use external price currency
|
||||
"vcpus": ext_price.vcpus,
|
||||
"ram": ext_price.ram,
|
||||
"storage": ext_price.storage,
|
||||
|
@ -384,45 +313,17 @@ def pricelist(request):
|
|||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
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, []
|
||||
)
|
||||
|
||||
# Get storage plans for this cloud provider
|
||||
storage_plans = StoragePlan.objects.filter(
|
||||
cloud_provider=plan.cloud_provider
|
||||
).prefetch_related("prices")
|
||||
|
||||
# Add pricing data to the grouped structure
|
||||
pricing_data_by_group_and_service_level[group_name][
|
||||
service_level_display
|
||||
].append(
|
||||
|
@ -473,22 +374,24 @@ def pricelist(request):
|
|||
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())
|
||||
|
||||
if "No Group" in all_group_names:
|
||||
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]
|
||||
)
|
||||
|
||||
# Convert defaultdicts to regular dicts for the template
|
||||
final_context_data = {}
|
||||
for group_key, service_levels_dict in ordered_groups_intermediate.items():
|
||||
|
@ -496,9 +399,10 @@ def pricelist(request):
|
|||
sl_key: list(plans_list)
|
||||
for sl_key, plans_list in service_levels_dict.items()
|
||||
}
|
||||
# Get filter options for dropdowns (include all providers/groups from all plans, not just active)
|
||||
|
||||
# Get filter options for dropdowns
|
||||
all_cloud_providers = (
|
||||
ComputePlan.objects.all()
|
||||
ComputePlan.objects.filter(active=True)
|
||||
.values_list("cloud_provider__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("cloud_provider__name")
|
||||
|
@ -509,7 +413,7 @@ def pricelist(request):
|
|||
.order_by("service__name")
|
||||
)
|
||||
all_compute_plan_groups = list(
|
||||
ComputePlan.objects.filter(group__isnull=False)
|
||||
ComputePlan.objects.filter(active=True, group__isnull=False)
|
||||
.values_list("group__name", flat=True)
|
||||
.distinct()
|
||||
.order_by("group__name")
|
||||
|
|
|
@ -59,7 +59,6 @@ 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
|
||||
|
@ -77,7 +76,6 @@ INSTALLED_APPS = [
|
|||
"django.contrib.staticfiles",
|
||||
"django.contrib.sitemaps",
|
||||
# 3rd party
|
||||
"compressor",
|
||||
"django_prose_editor",
|
||||
"rest_framework",
|
||||
"schema_viewer",
|
||||
|
@ -188,25 +186,6 @@ 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
|
||||
|
||||
|
@ -266,7 +245,6 @@ JAZZMIN_SETTINGS = {
|
|||
"new_window": True,
|
||||
},
|
||||
{"name": "Articles", "url": "/admin/services/article/"},
|
||||
{"name": "Image Library", "url": "/admin/services/imagelibrary/"},
|
||||
{"name": "FAQs", "url": "/admin/services/websitefaq/"},
|
||||
],
|
||||
"show_sidebar": True,
|
||||
|
@ -277,11 +255,8 @@ JAZZMIN_SETTINGS = {
|
|||
"services.ProgressiveDiscountModel": "single",
|
||||
"services.VSHNAppCatPrice": "single",
|
||||
"services.VSHNAppCatAddon": "single",
|
||||
"services.ServiceOffering": "single",
|
||||
"services.Plan": "single",
|
||||
},
|
||||
"related_modal_active": True,
|
||||
}
|
||||
|
||||
IMPORT_EXPORT_FORMATS = [CSV]
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
|
|
@ -7,11 +7,10 @@ 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.15.0",
|
||||
"django-prose-editor[sanitize]>=0.10.3",
|
||||
"django-schema-viewer>=0.5.2",
|
||||
"djangorestframework>=3.15.2",
|
||||
"environs[django]~=14.0",
|
||||
|
|
55
uv.lock
generated
55
uv.lock
generated
|
@ -81,18 +81,6 @@ 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"
|
||||
|
@ -115,21 +103,6 @@ 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"
|
||||
|
@ -158,14 +131,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django-js-asset"
|
||||
version = "3.1.2"
|
||||
version = "3.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -182,15 +155,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "django-prose-editor"
|
||||
version = "0.15.2"
|
||||
version = "0.10.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-js-asset" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
@ -378,18 +351,6 @@ 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"
|
||||
|
@ -397,7 +358,6 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-admin-sortable2" },
|
||||
{ name = "django-compressor" },
|
||||
{ name = "django-import-export" },
|
||||
{ name = "django-jazzmin" },
|
||||
{ name = "django-nested-admin" },
|
||||
|
@ -421,11 +381,10 @@ 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.15.0" },
|
||||
{ name = "django-prose-editor", extras = ["sanitize"], specifier = ">=0.10.3" },
|
||||
{ name = "django-schema-viewer", specifier = ">=0.5.2" },
|
||||
{ name = "djangorestframework", specifier = ">=3.15.2" },
|
||||
{ name = "environs", extras = ["django"], specifier = "~=14.0" },
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue