uncloud/uncloud_v3/app/models.py
Nico Schottelius 35ca9f8c49 ++old
2026-06-11 12:22:13 +02:00

412 lines
14 KiB
Python

from django.db import models
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.urls import reverse
from django.db.models import Q
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import ipaddress
def validate_name_not_product(value):
"""
We want to prevent overriding our own code.
So the hardcoded name "product" may not be used as a product or resource name
"""
if value == "product":
raise ValidationError(
_("%(value)s is not allowed as the name"),
params={"value": value},
)
class Currency(models.Model):
slug = models.SlugField(null=True, unique=True)
name = models.CharField(max_length=128, unique=True)
short_name = models.CharField(max_length=3, unique=True)
def __str__(self):
return f"{self.name} ({self.short_name})"
class TimeFrame(models.Model):
slug = models.SlugField(null=True, unique=True)
name = models.CharField(max_length=128, unique=True)
seconds = models.IntegerField(null=True, blank=True)
@staticmethod
def secs_to_name(secs):
name = ""
days = 0
hours = 0
if secs >= 24*3600:
days = secs // (24*3600)
secs -= (days*24*3600)
if secs >= 3600:
hours = secs // 3600
secs -= hours*3600
return f"{days} days {hours} hours {secs} seconds"
def __str__(self):
#return "{} ({})".format(self.name, self.secs_to_name(self.seconds))
return f"{self.name}"
class OneTimePrice(models.Model):
value = models.FloatField()
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
class Meta:
ordering = ('value',)
def __str__(self):
return f"{self.value} {self.currency.short_name}"
class PricePerTime(models.Model):
timeframe = models.ForeignKey(TimeFrame, on_delete=models.CASCADE)
value = models.FloatField()
currency = models.ForeignKey(Currency, on_delete=models.CASCADE)
def __str__(self):
return f"{self.value}{self.currency.short_name}/{self.timeframe}"
class Resource(models.Model):
slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product]) # primary identifier
name = models.CharField(max_length=128, unique=False) # CPU, RAM
unit = models.CharField(max_length=128) # Count, GB
minimum_units = models.FloatField(null=True, blank=True) # might have min
maximum_units = models.FloatField(null=True, blank=True) # might have max
default_value = models.FloatField(null=True, blank=True) # default value to show
step_size = models.FloatField(default=1) # step size
price_per_time = models.ManyToManyField(PricePerTime, blank=True)
onetime_price = models.ForeignKey(OneTimePrice,
null=True, blank=True,
on_delete=models.CASCADE)
def __str__(self):
if self.minimum_units:
minimum = self.minimum_units
else:
minimum = "No minimum"
if self.maximum_units:
maximum = self.maximum_units
else:
maximum = "No maximum"
pricing = ", ".join([str(x) for x in self.price_per_time.all()])
#return f"{self.slug}: {minimum}-{maximum} (+/-){self.step_size} {self.unit} ({pricing})"
return f"{self.name} ({self.slug})"
class Product(models.Model):
"""
Describes a product a user can buy
"""
slug = models.SlugField(null=True, unique=True, validators=[validate_name_not_product])
name = models.CharField(max_length=128, unique=True)
resources = models.ManyToManyField(Resource, blank=True) # List of REQUIRED resources
timeframes = models.ManyToManyField(TimeFrame, blank=True) # List of POSSIBLE timeframes
def has_one_time_price(self):
has_otp = False
for res in self.resources.all():
if res.onetime_price:
has_otp = True
break
return has_otp
def valid_timeframes(self):
"""
Return all timeframes that have all resources configured
"""
valid_tf = []
num_res = self.resources.all().count()
for tf in self.timeframes.all():
# Get all distinct source for this timeframe
res = self.resources.filter(price_per_time__timeframe=tf).distinct().count()
if res == num_res:
valid_tf.append(tf)
return valid_tf
def get_absolute_url(self):
return reverse('product-detail', kwargs={'slug' : self.slug})
def __str__(self):
return self.name
class ResourceOrder(models.Model):
"""
Resources that have been ordered
We need to record the selected value *and* potentially the
calculated price
"""
value = models.FloatField()
resource = models.ForeignKey(Resource, on_delete=models.CASCADE)
def __str__(self):
return f"{self.value} x {self.resource}"
class ProductOrder(models.Model):
"""
Describes a product a user bought
"""
product = models.ForeignKey(Product, on_delete=models.CASCADE)
timeframe = models.ForeignKey(TimeFrame, null=True, blank=True, on_delete=models.CASCADE)
resources = models.ManyToManyField(ResourceOrder)
def __str__(self):
if self.timeframe:
txt = f"Order {self.id}: {self.product} for {self.timeframe}"
else:
txt = f"Order {self.id}: {self.product}"
return txt
class UncloudConfiguration(models.Model):
"""
Global configuration for uncloud instance
"""
# IPv6 prefix for billing numbers
ipv6_billing_prefix = models.CharField(
max_length=39, # Max length for IPv6 address
default='2001:db8::/64',
help_text='IPv6 prefix for generating billing numbers (e.g., 2001:db8::/64)'
)
# Counter for billing numbers
billing_counter = models.BigIntegerField(default=1)
# Singleton pattern - only one configuration should exist
class Meta:
verbose_name = 'Uncloud Configuration'
verbose_name_plural = 'Uncloud Configuration'
def save(self, *args, **kwargs):
# Validate IPv6 prefix
try:
network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False)
# Ensure we have enough host bits for billing numbers
if network.prefixlen >= 128:
raise ValidationError("IPv6 prefix must allow for host addresses (prefix length < 128)")
except ipaddress.AddressValueError:
raise ValidationError("Invalid IPv6 prefix format")
# Ensure only one configuration exists
if not self.pk and UncloudConfiguration.objects.exists():
raise ValidationError("Only one configuration instance is allowed")
super().save(*args, **kwargs)
def get_next_billing_ipv6(self):
"""
Generate the next IPv6 billing address and increment counter
"""
try:
network = ipaddress.IPv6Network(self.ipv6_billing_prefix, strict=False)
# Calculate the host address by adding the counter to the network address
host_address = network.network_address + self.billing_counter
# Ensure we don't exceed the network range
if host_address not in network:
raise ValidationError(f"Billing counter {self.billing_counter} exceeds network range {self.ipv6_billing_prefix}")
# Increment counter for next use
self.billing_counter += 1
self.save()
return str(host_address)
except Exception as e:
raise ValidationError(f"Error generating IPv6 billing address: {str(e)}")
@classmethod
def get_instance(cls):
"""
Get or create the singleton configuration instance
"""
config, created = cls.objects.get_or_create(
pk=1,
defaults={
'ipv6_billing_prefix': '2001:db8::/64',
'billing_counter': 1
}
)
return config
def __str__(self):
return f"Uncloud Config - IPv6 Prefix: {self.ipv6_billing_prefix}, Counter: {self.billing_counter}"
class Customer(models.Model):
"""
Customer model for tying orders to customers
Future: will be linked to authentik OIDC users
"""
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
company_name = models.CharField(max_length=255, blank=True)
# Contact information
phone = models.CharField(max_length=20, blank=True)
# Address fields
address_line1 = models.CharField(max_length=255, blank=True)
address_line2 = models.CharField(max_length=255, blank=True)
city = models.CharField(max_length=100, blank=True)
postal_code = models.CharField(max_length=20, blank=True)
country = models.CharField(max_length=100, blank=True)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Future: authentik integration
# authentik_user_id = models.CharField(max_length=255, blank=True, null=True, unique=True)
class Meta:
ordering = ['email']
def __str__(self):
if self.company_name:
return f"{self.company_name} ({self.email})"
elif self.first_name or self.last_name:
return f"{self.first_name} {self.last_name} ({self.email})".strip()
else:
return self.email
@property
def full_name(self):
return f"{self.first_name} {self.last_name}".strip()
class Order(models.Model):
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
# Remove the owner field since we're using customer now
# owner = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, editable=False)
creation_date = models.DateTimeField(auto_now_add=True)
starting_date = models.DateTimeField(default=timezone.now)
ending_date = models.DateTimeField(blank=True, null=True)
product = models.ManyToManyField(ProductOrder, blank=True)
def __str__(self):
return f"Order {self.id} for {self.customer}"
class Bill(models.Model):
"""
A bill for a customer covering a specific time period
"""
customer = models.ForeignKey('Customer', on_delete=models.CASCADE)
# Date range for billing period (staff sets these)
period_start_date = models.DateField()
period_end_date = models.DateField()
# Automatically calculated datetime period (start of day to end of day)
period_start = models.DateTimeField(editable=False)
period_end = models.DateTimeField(editable=False)
# Bill metadata - IPv6 address as billing number
bill_number = models.CharField(max_length=39, unique=True, blank=True) # IPv6 max length
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(get_user_model(), on_delete=models.PROTECT)
# Bill status
STATUS_CHOICES = [
('draft', 'Draft'),
('sent', 'Sent'),
('paid', 'Paid'),
('overdue', 'Overdue'),
('cancelled', 'Cancelled'),
]
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
# Payment information
due_date = models.DateField()
paid_date = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def save(self, *args, **kwargs):
# Auto-calculate period_start and period_end from date fields
if self.period_start_date:
# Start of day (00:00:00)
self.period_start = timezone.make_aware(
timezone.datetime.combine(self.period_start_date, timezone.datetime.min.time())
)
if self.period_end_date:
# End of day (23:59:59.999999)
self.period_end = timezone.make_aware(
timezone.datetime.combine(self.period_end_date, timezone.datetime.max.time())
)
if not self.bill_number:
# Generate IPv6 billing number
config = UncloudConfiguration.get_instance()
self.bill_number = config.get_next_billing_ipv6()
super().save(*args, **kwargs)
@property
def total_amount(self):
"""Calculate total amount from all line items"""
return sum(item.total for item in self.line_items.all())
@property
def period_days(self):
"""Number of days this bill covers"""
return (self.period_end_date - self.period_start_date).days + 1
@property
def can_edit_dates(self):
"""Check if billing dates can still be edited"""
return self.status == 'draft'
def __str__(self):
return f"Bill {self.bill_number} for {self.customer} ({self.period_start_date} to {self.period_end_date})"
class BillLineItem(models.Model):
"""
Individual line items on a bill
"""
bill = models.ForeignKey(Bill, on_delete=models.CASCADE, related_name='line_items')
# Description of the line item
description = models.CharField(max_length=255)
# Quantity and pricing
quantity = models.DecimalField(max_digits=10, decimal_places=2)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
# Optional: link to the product order that generated this line item
product_order = models.ForeignKey(ProductOrder, on_delete=models.SET_NULL, null=True, blank=True)
# Calculated total (quantity * unit_price)
@property
def total(self):
return self.quantity * self.unit_price
def __str__(self):
return f"{self.description} - {self.quantity} x {self.unit_price}"