from django.db import models from django.core.exceptions import ValidationError from django.urls import reverse from django.utils.text import slugify from django_prose_editor.fields import ProseEditorField from django.core.validators import URLValidator def validate_image_size(value): filesize = value.size if filesize > 1 * 1024 * 1024: # 1MB raise ValidationError("Maximum file size is 1MB") class ReusableText(models.Model): name = models.CharField(max_length=100) textsnippet = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children", ) text = ProseEditorField() class Meta: ordering = ["name"] def __str__(self): return self.name def get_full_text(self): """Returns the text with all nested textsnippet content recursively included in reverse order""" text_parts = [] # Recursively collect all text parts def collect_text(snippet): if snippet is None: return collect_text(snippet.textsnippet) # Collect deepest snippets first text_parts.append(snippet.text) # Start collection with the deepest snippets collect_text(self.textsnippet) # Add the main text at the end text_parts.append(self.text) # Join all text parts return "".join(text_parts) class Category(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) parent = models.ForeignKey( "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" ) description = models.TextField(blank=True) order = models.IntegerField(default=0) class Meta: verbose_name = "Service Category" verbose_name_plural = "Service Categories" ordering = ["order", "name"] def __str__(self): if self.parent: return f"{self.parent} > {self.name}" return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) @property def full_path(self): path = [self.name] parent = self.parent while parent: path.append(parent.name) parent = parent.parent return " > ".join(reversed(path)) class CloudProvider(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) 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) class Meta: ordering = ["order"] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("services:provider_detail", kwargs={"slug": self.slug}) class Service(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(max_length=250, unique=True) 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 = ProseEditorField() is_featured = models.BooleanField(default=False) is_coming_soon = models.BooleanField(default=False) disable_listing = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): return self.name def clean(self): if self.is_featured and self.is_coming_soon: raise ValidationError( "A service cannot be both featured and coming soon simultaneously." ) super().clean() def save(self, *args, **kwargs): self.clean() # Ensure validation runs on save if not self.slug: self.slug = slugify(self.name) counter = 1 while Service.objects.filter(slug=self.slug).exists(): self.slug = f"{slugify(self.name)}-{counter}" counter += 1 super().save(*args, **kwargs) def get_absolute_url(self): return reverse("services:service_detail", kwargs={"slug": self.slug}) class ConsultingPartner(models.Model): name = models.CharField(max_length=200) slug = models.SlugField(unique=True) 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) services = models.ManyToManyField( Service, related_name="consulting_partners", blank=True ) cloud_providers = models.ManyToManyField( CloudProvider, related_name="consulting_partners", blank=True ) order = models.IntegerField(default=0) is_featured = models.BooleanField(default=False) disable_listing = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order"] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) def get_absolute_url(self): return reverse("services:partner_detail", kwargs={"slug": self.slug}) class ServiceOffering(models.Model): service = models.ForeignKey( Service, on_delete=models.CASCADE, related_name="offerings" ) cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="offerings" ) description = ProseEditorField(blank=True, null=True) offer_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, related_name="offer_descriptions", blank=True, null=True, ) disable_listing = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ["service", "cloud_provider"] ordering = ["service__name", "cloud_provider__name"] def __str__(self): return f"{self.service.name} on {self.cloud_provider.name}" def get_absolute_url(self): return reverse( "services:offering_detail", kwargs={ "provider_slug": self.cloud_provider.slug, "service_slug": self.service.slug, }, ) class Plan(models.Model): name = models.CharField(max_length=100) description = ProseEditorField(blank=True, null=True) pricing = ProseEditorField(blank=True, null=True) plan_description = models.ForeignKey( ReusableText, on_delete=models.PROTECT, related_name="plan_descriptions", blank=True, null=True, ) offering = models.ForeignKey( ServiceOffering, on_delete=models.CASCADE, related_name="plans" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["name"] unique_together = [["offering", "name"]] def __str__(self): return f"{self.offering} - {self.name}" class ExternalLinkOffering(models.Model): offering = models.ForeignKey( ServiceOffering, on_delete=models.CASCADE, related_name="external_links" ) url = models.URLField() description = models.CharField(max_length=200) order = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "description"] verbose_name = "External Link" verbose_name_plural = "External Links" def __str__(self): return f"{self.description} ({self.url})" def clean(self): validate = URLValidator() try: validate(self.url) except ValidationError: raise ValidationError({"url": "Enter a valid URL."}) class ExternalLink(models.Model): service = models.ForeignKey( Service, on_delete=models.CASCADE, related_name="external_links" ) url = models.URLField() description = models.CharField(max_length=200) order = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["order", "description"] verbose_name = "External Link" verbose_name_plural = "External Links" def __str__(self): return f"{self.description} ({self.url})" def clean(self): validate = URLValidator() try: validate(self.url) except ValidationError: raise ValidationError({"url": "Enter a valid URL."}) class Lead(models.Model): name = models.CharField(max_length=200) email = models.EmailField() company = models.CharField(max_length=200, null=True, blank=True) phone = models.CharField(max_length=50, null=True, blank=True) message = models.TextField(blank=True, null=True, max_length=1000) odoo_lead_id = models.IntegerField(null=True, blank=True) service = models.ForeignKey( Service, on_delete=models.SET_NULL, null=True, blank=True ) offering = models.ForeignKey( ServiceOffering, on_delete=models.SET_NULL, null=True, blank=True ) plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"{self.name} - {self.company} ({self.service})" class WebsiteFaq(models.Model): question = models.CharField(max_length=200) answer = ProseEditorField() order = models.IntegerField(default=0) class Meta: ordering = ["order"] def __str__(self): return self.question class Currency(models.TextChoices): CHF = "CHF", "Swiss Franc" EUR = "EUR", "Euro" USD = "USD", "US Dollar" class Term(models.TextChoices): MTH = "MTH", "per Month (30d)" DAY = "DAY", "per Day" HR = "HR", "per Hour" MIN = "MIN", "per Minute" class Unit(models.TextChoices): GIB = "GIB", "GiB" MIB = "MIB", "MiB" CPU = "CPU", "vCPU" class ComputePlanPrice(models.Model): compute_plan = models.ForeignKey( "ComputePlan", on_delete=models.CASCADE, related_name="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 = ("compute_plan", "currency") ordering = ["currency"] def __str__(self): return f"{self.compute_plan.name} - {self.amount} {self.currency}" class ComputePlan(models.Model): name = models.CharField(max_length=200) vcpus = models.FloatField(help_text="Number of available vCPUs") ram = models.FloatField(help_text="Amount of RAM available") cpu_mem_ratio = models.FloatField( help_text="vCPU to Memory ratio. How much vCPU per GiB RAM is available?" ) active = models.BooleanField(default=True, help_text="Is the plan active?") term = models.CharField( max_length=3, default=Term.MTH, choices=Term.choices, ) cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="compute_plans" ) valid_from = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True) class Meta: ordering = ["name"] def __str__(self): return self.name def get_price(self, currency_code: str): try: return self.prices.get(currency=currency_code).amount except ComputePlanPrice.DoesNotExist: return None class StoragePlanPrice(models.Model): storage_plan = models.ForeignKey( "StoragePlan", on_delete=models.CASCADE, related_name="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 = ("storage_plan", "currency") ordering = ["currency"] def __str__(self): return f"{self.storage_plan.name} - {self.amount} {self.currency}" class StoragePlan(models.Model): name = models.CharField(max_length=200) cloud_provider = models.ForeignKey( CloudProvider, on_delete=models.CASCADE, related_name="storage_plans" ) term = models.CharField( max_length=3, default=Term.MTH, choices=Term.choices, ) unit = models.CharField( max_length=3, default=Unit.GIB, choices=Unit.choices, ) valid_from = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True) class Meta: unique_together = ("cloud_provider", "term", "unit", "valid_from", "valid_to") ordering = ["name"] def __str__(self): return self.name def get_price(self, currency_code: str): try: return self.prices.get(currency=currency_code).amount except ComputePlanPrice.DoesNotExist: return None 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 ProgressiveDiscountModel(models.Model): name = models.CharField(max_length=100) description = models.TextField(blank=True) active = models.BooleanField(default=True) class Meta: verbose_name = "Discount Model" def __str__(self): return self.name def calculate_discount(self, base_rate, units): """Calculate price using progressive percentage discounts.""" final_price = 0 remaining_units = units processed_units = 0 # Get all tiers sorted by threshold discount_tiers = self.tiers.all().order_by("threshold") for i, tier in enumerate(discount_tiers): # Calculate how many units fall into this tier if i < discount_tiers.count() - 1: next_threshold = discount_tiers[i + 1].threshold tier_units = min(remaining_units, next_threshold - processed_units) else: # Last tier handles all remaining units tier_units = remaining_units # Calculate discounted rate for this tier discounted_rate = base_rate * (1 - tier.discount_percent / 100) # Add the price for units in this tier final_price += discounted_rate * tier_units # Update tracking variables remaining_units -= tier_units processed_units += tier_units # Exit if all units have been processed if remaining_units <= 0: break return final_price class DiscountTier(models.Model): discount_model = models.ForeignKey( ProgressiveDiscountModel, on_delete=models.CASCADE, related_name="tiers" ) threshold = models.PositiveIntegerField( help_text="Starting unit count for this tier" ) discount_percent = models.DecimalField( max_digits=5, decimal_places=2, help_text="Percentage discount applied (0-100)" ) class Meta: ordering = ["threshold"] unique_together = ["discount_model", "threshold"] def __str__(self): return f"{self.discount_model.name}: {self.threshold}+ units → {self.discount_percent}% discount" class VSHNAppCatPrice(models.Model): class VariableUnit(models.TextChoices): RAM = "RAM", "Memory (RAM)" CPU = "CPU", "CPU (vCPU)" USER = "USR", "Users" class ServiceLevel(models.TextChoices): BEST_EFFORT = "BE", "Best Effort" GUARANTEED = "GA", "Guaranteed Availability" service = models.ForeignKey( Service, on_delete=models.CASCADE, related_name="vshn_appcat_price" ) variable_unit = models.CharField( max_length=3, choices=VariableUnit.choices, default=VariableUnit.RAM, ) term = models.CharField( max_length=3, default=Term.MTH, choices=Term.choices, ) discount_model = models.ForeignKey( ProgressiveDiscountModel, on_delete=models.SET_NULL, null=True, blank=True, related_name="price_configs", ) ha_replica_min = models.IntegerField( default=1, help_text="Minimum of replicas for HA" ) ha_replica_max = models.IntegerField( default=1, help_text="Maximum supported replicas" ) valid_from = models.DateTimeField(blank=True, null=True) valid_to = models.DateTimeField(blank=True, null=True) class Meta: verbose_name = "AppCat Price" 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( currency=currency_code, service_level=service_level ).amount except VSHNAppCatUnitRate.DoesNotExist: return None def calculate_final_price( self, currency_code: str, service_level: str, number_of_units: int ): 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: return None if number_of_units < 0: raise ValueError("Number of units cannot be negative") # Apply discount model if available if self.discount_model and self.discount_model.active: discounted_price = self.discount_model.calculate_discount( unit_rate, number_of_units ) total_price = base_fee + discounted_price else: total_price = base_fee + (unit_rate * number_of_units) return total_price class VSHNAppCatUnitRate(models.Model): vshn_appcat_price_config = models.ForeignKey( VSHNAppCatPrice, on_delete=models.CASCADE, related_name="unit_rates" ) currency = models.CharField( max_length=3, choices=Currency.choices, ) service_level = models.CharField( max_length=2, choices=VSHNAppCatPrice.ServiceLevel.choices, ) amount = models.DecimalField( max_digits=10, decimal_places=4, help_text="Price per unit in the specified currency and service level, excl. VAT", ) class Meta: verbose_name = "Service Unit Rate" 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} - {self.get_service_level_display()} Unit Rate - {self.amount} {self.currency}"