From 9a3734192ed4d5254f1951859de1457f63144fc4 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Nov 2025 13:30:00 +0100 Subject: [PATCH] Initial Plan model --- src/servala/core/models/__init__.py | 14 +- src/servala/core/models/odoo_cache.py | 124 +++++++++ src/servala/core/models/plan.py | 351 ++++++++++++++++++++++++++ src/servala/core/models/service.py | 18 ++ 4 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/models/odoo_cache.py create mode 100644 src/servala/core/models/plan.py diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 4c23f18..e28081f 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -1,3 +1,4 @@ +from .odoo_cache import OdooObjectCache from .organization import ( BillingEntity, Organization, @@ -6,6 +7,12 @@ from .organization import ( OrganizationOrigin, OrganizationRole, ) +from .plan import ( + ResourcePlan, + ResourcePlanAssignment, + StoragePlan, + StoragePlanAssignment, +) from .service import ( CloudProvider, ControlPlane, @@ -23,15 +30,20 @@ __all__ = [ "CloudProvider", "ControlPlane", "ControlPlaneCRD", + "OdooObjectCache", "Organization", "OrganizationInvitation", "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", + "ResourcePlan", + "ResourcePlanAssignment", "Service", "ServiceCategory", - "ServiceInstance", "ServiceDefinition", + "ServiceInstance", "ServiceOffering", + "StoragePlan", + "StoragePlanAssignment", "User", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py new file mode 100644 index 0000000..8f0bd3f --- /dev/null +++ b/src/servala/core/models/odoo_cache.py @@ -0,0 +1,124 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class OdooObjectCache(ServalaModelMixin): + """ + Generic cache for Odoo API responses. + + Caches data from various Odoo models (product.product, product.template, uom.uom, etc.) + to reduce API calls and improve performance. + """ + + odoo_model = models.CharField( + max_length=100, + verbose_name=_("Odoo model"), + help_text=_( + "Odoo model name: 'product.product', 'product.template', 'uom.uom', etc." + ), + ) + odoo_id = models.PositiveIntegerField( + verbose_name=_("Odoo ID"), + help_text=_("ID in the Odoo model"), + ) + data = models.JSONField( + verbose_name=_("Cached data"), + help_text=_("Cached Odoo data including price, reporting_product_id, etc."), + ) + expires_at = models.DateTimeField( + null=True, + blank=True, + verbose_name=_("Expires at"), + help_text=_("When cache should be refreshed (null = never expires)"), + ) + + class Meta: + verbose_name = _("Odoo Object Cache") + verbose_name_plural = _("Odoo Object Caches") + unique_together = [["odoo_model", "odoo_id"]] + indexes = [ + models.Index(fields=["odoo_model", "odoo_id"]), + models.Index(fields=["expires_at"]), + ] + + def __str__(self): + return f"{self.odoo_model}({self.odoo_id})" + + def is_expired(self): + """Check if cache needs refresh.""" + if self.expires_at is None: + return False + from django.utils import timezone + + return timezone.now() > self.expires_at + + @classmethod + def get_or_fetch(cls, odoo_model, odoo_id, ttl_hours=24): + """ + Get cached data or fetch from Odoo if expired/missing. + + Args: + odoo_model: Odoo model name (e.g., 'product.product') + odoo_id: ID in the Odoo model + ttl_hours: Time-to-live in hours for the cache + + Returns: + OdooObjectCache instance with fresh data + """ + from datetime import timedelta + + from django.utils import timezone + + try: + cache_obj = cls.objects.get(odoo_model=odoo_model, odoo_id=odoo_id) + if not cache_obj.is_expired(): + return cache_obj + # Cache exists but expired, refresh it + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + except cls.DoesNotExist: + # Create new cache entry + cache_obj = cls.objects.create( + odoo_model=odoo_model, + odoo_id=odoo_id, + data={}, + expires_at=( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ), + ) + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + return cache_obj + + def fetch_and_update(self, ttl_hours=24): + """ + Fetch latest data from Odoo and update cache. + + Args: + ttl_hours: Time-to-live in hours for the cache + """ + from datetime import timedelta + + from django.utils import timezone + + from servala.core.odoo import CLIENT + + # Fetch data from Odoo + results = CLIENT.search_read( + self.odoo_model, + [[("id", "=", self.odoo_id)]], + fields=None, # Fetch all fields + ) + + if results: + self.data = results[0] + self.expires_at = ( + timezone.now() + timedelta(hours=ttl_hours) if ttl_hours else None + ) + self.save(update_fields=["data", "expires_at", "updated_at"]) + else: + # Object not found in Odoo, mark as expired immediately + self.data = {} + self.expires_at = timezone.now() + self.save(update_fields=["data", "expires_at", "updated_at"]) diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py new file mode 100644 index 0000000..df7b088 --- /dev/null +++ b/src/servala/core/models/plan.py @@ -0,0 +1,351 @@ +from django.core.validators import MinValueValidator +from django.db import models +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ + +from servala.core.models.mixins import ServalaModelMixin + + +class BasePlan(ServalaModelMixin): + """ + Abstract base class for all plan types (resource and storage plans). + + Plans define service configurations and are linked to Odoo products for billing. + """ + + ODOO_PRODUCT_TYPE_CHOICES = [ + ("product.product", _("Product Variant")), + ("product.template", _("Product Template")), + ] + + name = models.CharField( + max_length=100, + verbose_name=_("Name"), + ) + description = models.TextField( + blank=True, + verbose_name=_("Description"), + ) + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for selection"), + ) + + # Odoo product reference + odoo_plan_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Odoo plan ID"), + help_text=_("ID in the Odoo product model"), + ) + odoo_plan_type = models.CharField( + max_length=50, + choices=ODOO_PRODUCT_TYPE_CHOICES, + null=True, + blank=True, + verbose_name=_("Odoo plan type"), + help_text=_("Type of Odoo product model"), + ) + + # Odoo unit reference (name is cached in OdooObjectCache) + odoo_unit_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + + class Meta: + abstract = True + + def __str__(self): + return self.name + + @cached_property + def odoo_product_cache(self): + """ + Fetch cached Odoo product data. + + Returns: + OdooObjectCache instance or None if not configured + """ + if not self.odoo_plan_id or not self.odoo_plan_type: + return None + + from servala.core.models.odoo_cache import OdooObjectCache + + return OdooObjectCache.get_or_fetch( + odoo_model=self.odoo_plan_type, + odoo_id=self.odoo_plan_id, + ttl_hours=24, + ) + + @cached_property + def odoo_unit_cache(self): + """ + Fetch cached Odoo unit data. + + Returns: + OdooObjectCache instance or None if not configured + """ + if not self.odoo_unit_id: + return None + + from servala.core.models.odoo_cache import OdooObjectCache + + return OdooObjectCache.get_or_fetch( + odoo_model="uom.uom", + odoo_id=self.odoo_unit_id, + ttl_hours=168, # 1 week - units change rarely + ) + + def get_cached_price(self): + """ + Get price from Odoo cache. + + Returns: + Price as float or None if not available + """ + cache = self.odoo_product_cache + if cache and cache.data: + return cache.data.get("list_price") or cache.data.get("standard_price") + return None + + def get_reporting_product_id(self): + """ + Get the reporting product ID based on invoicing policy. + + Returns: + Reporting product ID or None + """ + cache = self.odoo_product_cache + if not cache or not cache.data: + return None + + invoicing_policy = cache.data.get("invoice_policy") + if invoicing_policy == "delivery": + # Metered billing + return cache.data.get("metered_billing_id") + else: + # Order-based billing + return cache.data.get("product_instance_event_id") + + def get_odoo_unit_name(self): + """ + Get the unit name from Odoo cache. + + Returns: + Unit name string or None + """ + cache = self.odoo_unit_cache + if cache and cache.data: + return cache.data.get("name") + return None + + def refresh_odoo_caches(self, ttl_hours=24): + """ + Force refresh of all Odoo caches for this plan. + + Args: + ttl_hours: Time-to-live in hours for the cache + """ + # Clear cached_property caches + if "odoo_product_cache" in self.__dict__: + del self.__dict__["odoo_product_cache"] + if "odoo_unit_cache" in self.__dict__: + del self.__dict__["odoo_unit_cache"] + + # Refresh caches + if self.odoo_plan_id and self.odoo_plan_type: + from servala.core.models.odoo_cache import OdooObjectCache + + try: + cache_obj = OdooObjectCache.objects.get( + odoo_model=self.odoo_plan_type, + odoo_id=self.odoo_plan_id, + ) + cache_obj.fetch_and_update(ttl_hours=ttl_hours) + except OdooObjectCache.DoesNotExist: + # Will be created on next access + pass + + if self.odoo_unit_id: + from servala.core.models.odoo_cache import OdooObjectCache + + try: + cache_obj = OdooObjectCache.objects.get( + odoo_model="uom.uom", + odoo_id=self.odoo_unit_id, + ) + cache_obj.fetch_and_update(ttl_hours=168) + except OdooObjectCache.DoesNotExist: + # Will be created on next access + pass + + +class ResourcePlan(BasePlan): + """ + Compute resource plans for service instances. + + Defines CPU, memory, and storage allocations along with service level. + """ + + # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m") + memory_requests = models.CharField( + max_length=20, + verbose_name=_("Memory requests"), + help_text=_("e.g., '2Gi', '512Mi'"), + ) + memory_limits = models.CharField( + max_length=20, + verbose_name=_("Memory limits"), + help_text=_("e.g., '4Gi', '1Gi'"), + ) + cpu_requests = models.CharField( + max_length=20, + verbose_name=_("CPU requests"), + help_text=_("e.g., '500m', '1', '2'"), + ) + cpu_limits = models.CharField( + max_length=20, + verbose_name=_("CPU limits"), + help_text=_("e.g., '2000m', '2', '4'"), + ) + + # Storage proposal (for UI pre-fill) + proposed_storage_gib = models.PositiveIntegerField( + validators=[MinValueValidator(1)], + verbose_name=_("Proposed storage (GiB)"), + help_text=_("Suggested storage amount in GiB to propose to the user"), + ) + + # Service level + service_level = models.CharField( + max_length=50, + blank=True, + verbose_name=_("Service level"), + help_text=_("e.g., 'Standard', 'Premium', 'Enterprise'"), + ) + + class Meta: + verbose_name = _("Resource Plan") + verbose_name_plural = _("Resource Plans") + ordering = ["name"] + + def get_resource_summary(self): + """ + Get a human-readable summary of resources. + + Returns: + String like "2 vCPU, 4Gi RAM" + """ + return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" + + +class StoragePlan(BasePlan): + """ + Storage plans for service instances. + + Currently inherits all fields from BasePlan. Future fields could include + storage class, performance tier, IOPS limits, etc. + """ + + class Meta: + verbose_name = _("Storage Plan") + verbose_name_plural = _("Storage Plans") + ordering = ["name"] + + +class ResourcePlanAssignment(ServalaModelMixin): + """ + Links resource plans to control plane CRDs. + + Allows the same plan to be reused across multiple CRDs with per-assignment + configuration (sorting, activation, future: pricing overrides, limits, etc.) + """ + + resource_plan = models.ForeignKey( + ResourcePlan, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Resource plan"), + ) + control_plane_crd = models.ForeignKey( + "ControlPlaneCRD", + on_delete=models.CASCADE, + related_name="resource_plan_assignments", + verbose_name=_("Control plane CRD"), + ) + + # Display ordering in UI + sort_order = models.PositiveIntegerField( + default=0, + verbose_name=_("Sort order"), + help_text=_("Order in which plans are displayed to users"), + ) + + # Allow per-assignment activation + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for this CRD"), + ) + + # Future: organization limits, pricing overrides, etc. + + class Meta: + verbose_name = _("Resource Plan Assignment") + verbose_name_plural = _("Resource Plan Assignments") + unique_together = [["resource_plan", "control_plane_crd"]] + ordering = ["sort_order", "resource_plan__name"] + + def __str__(self): + return f"{self.resource_plan.name} → {self.control_plane_crd}" + + +class StoragePlanAssignment(ServalaModelMixin): + """ + Links storage plans to control plane CRDs. + + Allows the same plan to be reused across multiple CRDs with per-assignment + configuration (sorting, activation, future: pricing overrides, limits, etc.) + """ + + storage_plan = models.ForeignKey( + StoragePlan, + on_delete=models.CASCADE, + related_name="assignments", + verbose_name=_("Storage plan"), + ) + control_plane_crd = models.ForeignKey( + "ControlPlaneCRD", + on_delete=models.CASCADE, + related_name="storage_plan_assignments", + verbose_name=_("Control plane CRD"), + ) + + # Display ordering in UI + sort_order = models.PositiveIntegerField( + default=0, + verbose_name=_("Sort order"), + help_text=_("Order in which plans are displayed to users"), + ) + + # Allow per-assignment activation + is_active = models.BooleanField( + default=True, + verbose_name=_("Is active"), + help_text=_("Whether this plan is available for this CRD"), + ) + + # Future: organization limits, pricing overrides, etc. + + class Meta: + verbose_name = _("Storage Plan Assignment") + verbose_name_plural = _("Storage Plan Assignments") + unique_together = [["storage_plan", "control_plane_crd"]] + ordering = ["sort_order", "storage_plan__name"] + + def __str__(self): + return f"{self.storage_plan.name} → {self.control_plane_crd}" diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index ab7b76f..5a01f79 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -613,6 +613,24 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) + resource_plan = models.ForeignKey( + to="core.ResourcePlan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="instances", + verbose_name=_("Resource plan"), + help_text=_("Compute resource plan for this instance"), + ) + storage_plan = models.ForeignKey( + to="core.StoragePlan", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="instances", + verbose_name=_("Storage plan"), + help_text=_("Storage plan for this instance"), + ) class Meta: verbose_name = _("Service instance")