Compare commits
No commits in common. "cce071397cc43ff57723359c8e8cb6f6df13c795" and "c0d3a83c9de81ac794f637b710620de16c290e84" have entirely different histories.
cce071397c
...
c0d3a83c9d
5 changed files with 4 additions and 497 deletions
|
|
@ -9,11 +9,8 @@ from servala.core.forms import ControlPlaneAdminForm, ServiceDefinitionAdminForm
|
|||
from servala.core.models import (
|
||||
BillingEntity,
|
||||
CloudProvider,
|
||||
ComputePlan,
|
||||
ComputePlanAssignment,
|
||||
ControlPlane,
|
||||
ControlPlaneCRD,
|
||||
OdooObjectCache,
|
||||
Organization,
|
||||
OrganizationInvitation,
|
||||
OrganizationMembership,
|
||||
|
|
@ -272,19 +269,6 @@ 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):
|
||||
|
|
@ -379,21 +363,15 @@ class ControlPlaneCRDAdmin(admin.ModelAdmin):
|
|||
|
||||
@admin.register(ServiceInstance)
|
||||
class ServiceInstanceAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"organization",
|
||||
"context",
|
||||
"compute_plan_assignment",
|
||||
"created_by",
|
||||
)
|
||||
list_filter = ("organization", "context", "compute_plan_assignment")
|
||||
list_display = ("name", "organization", "context", "created_by")
|
||||
list_filter = ("organization", "context")
|
||||
search_fields = (
|
||||
"name",
|
||||
"organization__name",
|
||||
"context__service_offering__service__name",
|
||||
)
|
||||
readonly_fields = ("name", "organization", "context")
|
||||
autocomplete_fields = ("organization", "context", "compute_plan_assignment")
|
||||
autocomplete_fields = ("organization", "context")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
if obj: # If this is an edit (not a new instance)
|
||||
|
|
@ -412,10 +390,6 @@ class ServiceInstanceAdmin(admin.ModelAdmin):
|
|||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Plan"),
|
||||
{"fields": ("compute_plan_assignment",)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -446,135 +420,3 @@ 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")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
from .odoo_cache import OdooObjectCache
|
||||
from .organization import (
|
||||
BillingEntity,
|
||||
Organization,
|
||||
|
|
@ -7,10 +6,6 @@ from .organization import (
|
|||
OrganizationOrigin,
|
||||
OrganizationRole,
|
||||
)
|
||||
from .plan import (
|
||||
ComputePlan,
|
||||
ComputePlanAssignment,
|
||||
)
|
||||
from .service import (
|
||||
CloudProvider,
|
||||
ControlPlane,
|
||||
|
|
@ -26,11 +21,8 @@ from .user import User
|
|||
__all__ = [
|
||||
"BillingEntity",
|
||||
"CloudProvider",
|
||||
"ComputePlan",
|
||||
"ComputePlanAssignment",
|
||||
"ControlPlane",
|
||||
"ControlPlaneCRD",
|
||||
"OdooObjectCache",
|
||||
"Organization",
|
||||
"OrganizationInvitation",
|
||||
"OrganizationMembership",
|
||||
|
|
@ -38,8 +30,8 @@ __all__ = [
|
|||
"OrganizationRole",
|
||||
"Service",
|
||||
"ServiceCategory",
|
||||
"ServiceDefinition",
|
||||
"ServiceInstance",
|
||||
"ServiceDefinition",
|
||||
"ServiceOffering",
|
||||
"User",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
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"])
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
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
|
||||
|
|
@ -170,28 +170,6 @@ 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")
|
||||
|
|
@ -635,15 +613,6 @@ 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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue