diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index f7cf161..60fe147 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -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") diff --git a/src/servala/core/models/__init__.py b/src/servala/core/models/__init__.py index 4cb8fd7..4c23f18 100644 --- a/src/servala/core/models/__init__.py +++ b/src/servala/core/models/__init__.py @@ -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", ] diff --git a/src/servala/core/models/odoo_cache.py b/src/servala/core/models/odoo_cache.py deleted file mode 100644 index 8f0bd3f..0000000 --- a/src/servala/core/models/odoo_cache.py +++ /dev/null @@ -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"]) diff --git a/src/servala/core/models/plan.py b/src/servala/core/models/plan.py deleted file mode 100644 index 6fc50e4..0000000 --- a/src/servala/core/models/plan.py +++ /dev/null @@ -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 diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index d4612c7..ab7b76f 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -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")