From cce071397cc43ff57723359c8e8cb6f6df13c795 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Tue, 25 Nov 2025 13:40:05 +0100 Subject: [PATCH] Adjustments to data model --- src/servala/core/admin.py | 164 +++++++++++++- src/servala/core/models/__init__.py | 12 +- src/servala/core/models/plan.py | 335 +++++++--------------------- src/servala/core/models/service.py | 39 ++-- 4 files changed, 269 insertions(+), 281 deletions(-) diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 60fe147..f7cf161 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm from servala.core.models import ( BillingEntity, CloudProvider, + ComputePlan, + ComputePlanAssignment, ControlPlane, ControlPlaneCRD, + OdooObjectCache, Organization, OrganizationInvitation, OrganizationMembership, @@ -269,6 +272,19 @@ class ControlPlaneAdmin(admin.ModelAdmin): ), }, ), + ( + _("Storage Plan"), + { + "fields": ( + "storage_plan_odoo_product_id", + "storage_plan_odoo_unit_id", + "storage_plan_price_per_gib", + ), + "description": _( + "Storage plan configuration for this control plane (hardcoded per control plane)." + ), + }, + ), ) def get_exclude(self, request, obj=None): @@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin): @admin.register(ServiceInstance) class ServiceInstanceAdmin(admin.ModelAdmin): - list_display = ("name", "organization", "context", "created_by") - list_filter = ("organization", "context") + list_display = ( + "name", + "organization", + "context", + "compute_plan_assignment", + "created_by", + ) + list_filter = ("organization", "context", "compute_plan_assignment") search_fields = ( "name", "organization__name", "context__service_offering__service__name", ) readonly_fields = ("name", "organization", "context") - autocomplete_fields = ("organization", "context") + autocomplete_fields = ("organization", "context", "compute_plan_assignment") def get_readonly_fields(self, request, obj=None): if obj: # If this is an edit (not a new instance) @@ -390,6 +412,10 @@ class ServiceInstanceAdmin(admin.ModelAdmin): ) }, ), + ( + _("Plan"), + {"fields": ("compute_plan_assignment",)}, + ), ) @@ -420,3 +446,135 @@ class ServiceOfferingAdmin(admin.ModelAdmin): schema=external_links_schema ) return form + + +class ComputePlanAssignmentInline(admin.TabularInline): + model = ComputePlanAssignment + extra = 1 + autocomplete_fields = ("control_plane_crd",) + fields = ( + "compute_plan", + "control_plane_crd", + "sla", + "odoo_product_id", + "odoo_unit_id", + "price", + "minimum_service_size", + "sort_order", + "is_active", + ) + readonly_fields = () + + +@admin.register(ComputePlan) +class ComputePlanAdmin(admin.ModelAdmin): + list_display = ( + "name", + "is_active", + "memory_limits", + "cpu_limits", + ) + list_filter = ("is_active",) + search_fields = ("name", "description") + inlines = (ComputePlanAssignmentInline,) + fieldsets = ( + ( + None, + { + "fields": ( + "name", + "description", + "is_active", + ) + }, + ), + ( + _("Resources"), + { + "fields": ( + "memory_requests", + "memory_limits", + "cpu_requests", + "cpu_limits", + ) + }, + ), + ) + + +@admin.register(ComputePlanAssignment) +class ComputePlanAssignmentAdmin(admin.ModelAdmin): + list_display = ( + "compute_plan", + "control_plane_crd", + "sla", + "price", + "sort_order", + "is_active", + ) + list_filter = ("is_active", "sla", "control_plane_crd") + search_fields = ( + "compute_plan__name", + "control_plane_crd__service_offering__service__name", + ) + autocomplete_fields = ("compute_plan", "control_plane_crd") + fieldsets = ( + ( + None, + { + "fields": ( + "compute_plan", + "control_plane_crd", + "sla", + "is_active", + "sort_order", + ) + }, + ), + ( + _("Odoo Integration"), + { + "fields": ( + "odoo_product_id", + "odoo_unit_id", + ) + }, + ), + ( + _("Pricing & Constraints"), + { + "fields": ( + "price", + "minimum_service_size", + ) + }, + ), + ) + + +@admin.register(OdooObjectCache) +class OdooObjectCacheAdmin(admin.ModelAdmin): + list_display = ("odoo_model", "odoo_id", "updated_at", "expires_at", "is_expired") + list_filter = ("odoo_model", "updated_at", "expires_at") + search_fields = ("odoo_model", "odoo_id") + readonly_fields = ("created_at", "updated_at") + actions = ["refresh_caches"] + + def is_expired(self, obj): + return obj.is_expired() + + is_expired.boolean = True + is_expired.short_description = _("Expired") + + def refresh_caches(self, request, queryset): + """Admin action to refresh selected Odoo caches.""" + refreshed_count = 0 + for cache_obj in queryset: + cache_obj.fetch_and_update() + refreshed_count += 1 + messages.success( + request, + _(f"Successfully refreshed {refreshed_count} cache(s)."), + ) + + refresh_caches.short_description = _("Refresh caches") diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index e28081f..4cb8fd7 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -8,10 +8,8 @@ from .organization import ( OrganizationRole, ) from .plan import ( - ResourcePlan, - ResourcePlanAssignment, - StoragePlan, - StoragePlanAssignment, + ComputePlan, + ComputePlanAssignment, ) from .service import ( CloudProvider, @@ -28,6 +26,8 @@ from .user import User __all__ = [ "BillingEntity", "CloudProvider", + "ComputePlan", + "ComputePlanAssignment", "ControlPlane", "ControlPlaneCRD", "OdooObjectCache", @@ -36,14 +36,10 @@ __all__ = [ "OrganizationMembership", "OrganizationOrigin", "OrganizationRole", - "ResourcePlan", - "ResourcePlanAssignment", "Service", "ServiceCategory", "ServiceDefinition", "ServiceInstance", "ServiceOffering", - "StoragePlan", - "StoragePlanAssignment", "User", ] diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py index df7b088..6fc50e4 100644 --- a/src/servala/core/models/plan.py +++ b/src/servala/core/models/plan.py @@ -1,23 +1,20 @@ +from decimal import Decimal + 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): +class ComputePlan(ServalaModelMixin): """ - Abstract base class for all plan types (resource and storage plans). + Compute resource plans for service instances. - Plans define service configurations and are linked to Odoo products for billing. + Defines CPU and memory allocations. Pricing and service level are configured + per assignment to a ControlPlaneCRD. """ - ODOO_PRODUCT_TYPE_CHOICES = [ - ("product.product", _("Product Variant")), - ("product.template", _("Product Template")), - ] - name = models.CharField( max_length=100, verbose_name=_("Name"), @@ -32,165 +29,6 @@ class BasePlan(ServalaModelMixin): 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, @@ -213,26 +51,14 @@ class ResourcePlan(BasePlan): 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") + verbose_name = _("Compute Plan") + verbose_name_plural = _("Compute Plans") ordering = ["name"] + def __str__(self): + return self.name + def get_resource_summary(self): """ Get a human-readable summary of resources. @@ -243,41 +69,71 @@ class ResourcePlan(BasePlan): return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" -class StoragePlan(BasePlan): +class ComputePlanAssignment(ServalaModelMixin): """ - Storage plans for service instances. + Links compute plans to control plane CRDs with pricing and service level. - Currently inherits all fields from BasePlan. Future fields could include - storage class, performance tier, IOPS limits, etc. + A product in Odoo represents a service with a specific compute plan, control plane, + and SLA. This model stores that correlation. The same compute plan can be assigned + multiple times to the same CRD with different SLAs and pricing. """ - class Meta: - verbose_name = _("Storage Plan") - verbose_name_plural = _("Storage Plans") - ordering = ["name"] + SLA_CHOICES = [ + ("besteffort", _("Best Effort")), + ("guaranteed", _("Guaranteed Availability")), + ] - -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, + compute_plan = models.ForeignKey( + ComputePlan, on_delete=models.CASCADE, related_name="assignments", - verbose_name=_("Resource plan"), + verbose_name=_("Compute plan"), ) control_plane_crd = models.ForeignKey( "ControlPlaneCRD", on_delete=models.CASCADE, - related_name="resource_plan_assignments", + related_name="compute_plan_assignments", verbose_name=_("Control plane CRD"), ) + # Service Level Agreement + sla = models.CharField( + max_length=20, + choices=SLA_CHOICES, + verbose_name=_("SLA"), + help_text=_("Service Level Agreement"), + ) + + # Odoo product reference + odoo_product_id = models.IntegerField( + verbose_name=_("Odoo product ID"), + help_text=_("ID of the product in Odoo (product.product or product.template)"), + ) + odoo_unit_id = models.IntegerField( + verbose_name=_("Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + + # Pricing + price = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(Decimal("0.00"))], + verbose_name=_("Price"), + help_text=_("Price per unit"), + ) + + # Service constraints + minimum_service_size = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_("Minimum service size"), + help_text=_( + "Minimum value for spec.parameters.instances " + "(Guaranteed Availability may require multiple instances)" + ), + ) + # Display ordering in UI sort_order = models.PositiveIntegerField( default=0, @@ -292,60 +148,25 @@ class ResourcePlanAssignment(ServalaModelMixin): 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"] + verbose_name = _("Compute Plan Assignment") + verbose_name_plural = _("Compute Plan Assignments") + unique_together = [["compute_plan", "control_plane_crd", "sla"]] + ordering = ["sort_order", "compute_plan__name", "sla"] def __str__(self): - return f"{self.resource_plan.name} → {self.control_plane_crd}" + return f"{self.compute_plan.name} ({self.get_sla_display()}) → {self.control_plane_crd}" + def get_odoo_reporting_product_id(self): + """ + Get the reporting product ID for this plan. -class StoragePlanAssignment(ServalaModelMixin): - """ - Links storage plans to control plane CRDs. + In the future, this will query Odoo based on invoicing policy. + For now, returns the product ID directly. - 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}" + Returns: + The Odoo product ID to use for billing + """ + # TODO: Implement Odoo cache lookup when OdooObjectCache is integrated + # For now, just return the product ID + return self.odoo_product_id diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index 5a01f79..d4612c7 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -170,6 +170,28 @@ class ControlPlane(ServalaModelMixin, models.Model): ), ) + # Storage plan configuration (hardcoded per control plane) + storage_plan_odoo_product_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo product ID"), + help_text=_("ID of the storage product in Odoo"), + ) + storage_plan_odoo_unit_id = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Storage plan Odoo unit ID"), + help_text=_("ID of the unit of measure in Odoo (uom.uom)"), + ) + storage_plan_price_per_gib = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + verbose_name=_("Storage plan price per GiB"), + help_text=_("Price per GiB of storage"), + ) + class Meta: verbose_name = _("Control plane") verbose_name_plural = _("Control planes") @@ -613,23 +635,14 @@ class ServiceInstance(ServalaModelMixin, models.Model): related_name="service_instances", on_delete=models.PROTECT, ) - resource_plan = models.ForeignKey( - to="core.ResourcePlan", + compute_plan_assignment = models.ForeignKey( + to="core.ComputePlanAssignment", 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"), + verbose_name=_("Compute plan assignment"), + help_text=_("Compute plan with SLA for this instance"), ) class Meta: