412 lines
14 KiB
Python
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}"
|