Compare commits

..

2 commits

Author SHA1 Message Date
cce071397c Adjustments to data model
All checks were successful
Tests / test (push) Successful in 25s
2025-11-25 13:56:51 +01:00
9a3734192e Initial Plan model 2025-11-25 13:30:00 +01:00
5 changed files with 497 additions and 4 deletions

View file

@ -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")

View file

@ -1,3 +1,4 @@
from .odoo_cache import OdooObjectCache
from .organization import (
BillingEntity,
Organization,
@ -6,6 +7,10 @@ from .organization import (
OrganizationOrigin,
OrganizationRole,
)
from .plan import (
ComputePlan,
ComputePlanAssignment,
)
from .service import (
CloudProvider,
ControlPlane,
@ -21,8 +26,11 @@ from .user import User
__all__ = [
"BillingEntity",
"CloudProvider",
"ComputePlan",
"ComputePlanAssignment",
"ControlPlane",
"ControlPlaneCRD",
"OdooObjectCache",
"Organization",
"OrganizationInvitation",
"OrganizationMembership",
@ -30,8 +38,8 @@ __all__ = [
"OrganizationRole",
"Service",
"ServiceCategory",
"ServiceInstance",
"ServiceDefinition",
"ServiceInstance",
"ServiceOffering",
"User",
]

View file

@ -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"])

View file

@ -0,0 +1,172 @@
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from servala.core.models.mixins import ServalaModelMixin
class ComputePlan(ServalaModelMixin):
"""
Compute resource plans for service instances.
Defines CPU and memory allocations. Pricing and service level are configured
per assignment to a ControlPlaneCRD.
"""
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"),
)
# 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'"),
)
class Meta:
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.
Returns:
String like "2 vCPU, 4Gi RAM"
"""
return f"{self.cpu_limits} vCPU, {self.memory_limits} RAM"
class ComputePlanAssignment(ServalaModelMixin):
"""
Links compute plans to control plane CRDs with pricing and service level.
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.
"""
SLA_CHOICES = [
("besteffort", _("Best Effort")),
("guaranteed", _("Guaranteed Availability")),
]
compute_plan = models.ForeignKey(
ComputePlan,
on_delete=models.CASCADE,
related_name="assignments",
verbose_name=_("Compute plan"),
)
control_plane_crd = models.ForeignKey(
"ControlPlaneCRD",
on_delete=models.CASCADE,
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,
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"),
)
class Meta:
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.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.
In the future, this will query Odoo based on invoicing policy.
For now, returns the product ID directly.
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

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