Adjustments to data model
All checks were successful
Tests / test (push) Successful in 25s

This commit is contained in:
Tobias Kunze 2025-11-25 13:40:05 +01:00
parent 9a3734192e
commit cce071397c
4 changed files with 269 additions and 281 deletions

View file

@ -9,8 +9,11 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
from servala.core.models import ( from servala.core.models import (
BillingEntity, BillingEntity,
CloudProvider, CloudProvider,
ComputePlan,
ComputePlanAssignment,
ControlPlane, ControlPlane,
ControlPlaneCRD, ControlPlaneCRD,
OdooObjectCache,
Organization, Organization,
OrganizationInvitation, OrganizationInvitation,
OrganizationMembership, 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): def get_exclude(self, request, obj=None):
@ -363,15 +379,21 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
@admin.register(ServiceInstance) @admin.register(ServiceInstance)
class ServiceInstanceAdmin(admin.ModelAdmin): class ServiceInstanceAdmin(admin.ModelAdmin):
list_display = ("name", "organization", "context", "created_by") list_display = (
list_filter = ("organization", "context") "name",
"organization",
"context",
"compute_plan_assignment",
"created_by",
)
list_filter = ("organization", "context", "compute_plan_assignment")
search_fields = ( search_fields = (
"name", "name",
"organization__name", "organization__name",
"context__service_offering__service__name", "context__service_offering__service__name",
) )
readonly_fields = ("name", "organization", "context") readonly_fields = ("name", "organization", "context")
autocomplete_fields = ("organization", "context") autocomplete_fields = ("organization", "context", "compute_plan_assignment")
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
if obj: # If this is an edit (not a new instance) 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 schema=external_links_schema
) )
return form 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")

View file

@ -8,10 +8,8 @@ from .organization import (
OrganizationRole, OrganizationRole,
) )
from .plan import ( from .plan import (
ResourcePlan, ComputePlan,
ResourcePlanAssignment, ComputePlanAssignment,
StoragePlan,
StoragePlanAssignment,
) )
from .service import ( from .service import (
CloudProvider, CloudProvider,
@ -28,6 +26,8 @@ from .user import User
__all__ = [ __all__ = [
"BillingEntity", "BillingEntity",
"CloudProvider", "CloudProvider",
"ComputePlan",
"ComputePlanAssignment",
"ControlPlane", "ControlPlane",
"ControlPlaneCRD", "ControlPlaneCRD",
"OdooObjectCache", "OdooObjectCache",
@ -36,14 +36,10 @@ __all__ = [
"OrganizationMembership", "OrganizationMembership",
"OrganizationOrigin", "OrganizationOrigin",
"OrganizationRole", "OrganizationRole",
"ResourcePlan",
"ResourcePlanAssignment",
"Service", "Service",
"ServiceCategory", "ServiceCategory",
"ServiceDefinition", "ServiceDefinition",
"ServiceInstance", "ServiceInstance",
"ServiceOffering", "ServiceOffering",
"StoragePlan",
"StoragePlanAssignment",
"User", "User",
] ]

View file

@ -1,23 +1,20 @@
from decimal import Decimal
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from servala.core.models.mixins import ServalaModelMixin 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( name = models.CharField(
max_length=100, max_length=100,
verbose_name=_("Name"), verbose_name=_("Name"),
@ -32,165 +29,6 @@ class BasePlan(ServalaModelMixin):
help_text=_("Whether this plan is available for selection"), 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") # Kubernetes resource specifications (use Kubernetes format: "2Gi", "500m")
memory_requests = models.CharField( memory_requests = models.CharField(
max_length=20, max_length=20,
@ -213,26 +51,14 @@ class ResourcePlan(BasePlan):
help_text=_("e.g., '2000m', '2', '4'"), 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: class Meta:
verbose_name = _("Resource Plan") verbose_name = _("Compute Plan")
verbose_name_plural = _("Resource Plans") verbose_name_plural = _("Compute Plans")
ordering = ["name"] ordering = ["name"]
def __str__(self):
return self.name
def get_resource_summary(self): def get_resource_summary(self):
""" """
Get a human-readable summary of resources. Get a human-readable summary of resources.
@ -243,41 +69,71 @@ class ResourcePlan(BasePlan):
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM" 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 A product in Odoo represents a service with a specific compute plan, control plane,
storage class, performance tier, IOPS limits, etc. 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: SLA_CHOICES = [
verbose_name = _("Storage Plan") ("besteffort", _("Best Effort")),
verbose_name_plural = _("Storage Plans") ("guaranteed", _("Guaranteed Availability")),
ordering = ["name"] ]
compute_plan = models.ForeignKey(
class ResourcePlanAssignment(ServalaModelMixin): ComputePlan,
"""
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, on_delete=models.CASCADE,
related_name="assignments", related_name="assignments",
verbose_name=_("Resource plan"), verbose_name=_("Compute plan"),
) )
control_plane_crd = models.ForeignKey( control_plane_crd = models.ForeignKey(
"ControlPlaneCRD", "ControlPlaneCRD",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="resource_plan_assignments", related_name="compute_plan_assignments",
verbose_name=_("Control plane CRD"), 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 # Display ordering in UI
sort_order = models.PositiveIntegerField( sort_order = models.PositiveIntegerField(
default=0, default=0,
@ -292,60 +148,25 @@ class ResourcePlanAssignment(ServalaModelMixin):
help_text=_("Whether this plan is available for this CRD"), help_text=_("Whether this plan is available for this CRD"),
) )
# Future: organization limits, pricing overrides, etc.
class Meta: class Meta:
verbose_name = _("Resource Plan Assignment") verbose_name = _("Compute Plan Assignment")
verbose_name_plural = _("Resource Plan Assignments") verbose_name_plural = _("Compute Plan Assignments")
unique_together = [["resource_plan", "control_plane_crd"]] unique_together = [["compute_plan", "control_plane_crd", "sla"]]
ordering = ["sort_order", "resource_plan__name"] ordering = ["sort_order", "compute_plan__name", "sla"]
def __str__(self): 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): In the future, this will query Odoo based on invoicing policy.
""" For now, returns the product ID directly.
Links storage plans to control plane CRDs.
Allows the same plan to be reused across multiple CRDs with per-assignment Returns:
configuration (sorting, activation, future: pricing overrides, limits, etc.) The Odoo product ID to use for billing
""" """
# TODO: Implement Odoo cache lookup when OdooObjectCache is integrated
storage_plan = models.ForeignKey( # For now, just return the product ID
StoragePlan, return self.odoo_product_id
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}"

View file

@ -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: class Meta:
verbose_name = _("Control plane") verbose_name = _("Control plane")
verbose_name_plural = _("Control planes") verbose_name_plural = _("Control planes")
@ -613,23 +635,14 @@ class ServiceInstance(ServalaModelMixin, models.Model):
related_name="service_instances", related_name="service_instances",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
resource_plan = models.ForeignKey( compute_plan_assignment = models.ForeignKey(
to="core.ResourcePlan", to="core.ComputePlanAssignment",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name="instances", related_name="instances",
verbose_name=_("Resource plan"), verbose_name=_("Compute plan assignment"),
help_text=_("Compute resource plan for this instance"), help_text=_("Compute plan with SLA 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: class Meta: