2025-05-23 16:04:33 +02:00
|
|
|
from django.db import models
|
|
|
|
from django.core.exceptions import ValidationError
|
|
|
|
from django.utils.text import slugify
|
|
|
|
from django_prose_editor.fields import ProseEditorField
|
2025-07-08 15:24:47 +02:00
|
|
|
import mimetypes
|
|
|
|
import xml.etree.ElementTree as ET
|
2025-05-23 16:04:33 +02:00
|
|
|
|
|
|
|
|
2025-07-08 16:24:28 +02:00
|
|
|
# Centralized ProseEditorField configuration
|
|
|
|
PROSE_EDITOR_CONFIG = {
|
|
|
|
"extensions": {
|
|
|
|
"Bold": True,
|
|
|
|
"Italic": True,
|
|
|
|
"Strike": True,
|
|
|
|
"Underline": True,
|
|
|
|
"HardBreak": True,
|
|
|
|
"Heading": {"levels": [1, 2, 3, 4, 5, 6]},
|
|
|
|
"BulletList": True,
|
|
|
|
"OrderedList": True,
|
|
|
|
"Blockquote": True,
|
|
|
|
"Link": True,
|
|
|
|
"Table": True,
|
|
|
|
"History": True,
|
|
|
|
"HTML": True,
|
|
|
|
"Typographic": True,
|
|
|
|
},
|
|
|
|
"sanitize": True,
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_prose_editor_field(**kwargs):
|
|
|
|
"""
|
|
|
|
Returns a ProseEditorField with the standard configuration.
|
|
|
|
Additional kwargs can be passed to override or add field options.
|
|
|
|
"""
|
|
|
|
config = PROSE_EDITOR_CONFIG.copy()
|
|
|
|
config.update(kwargs)
|
|
|
|
return ProseEditorField(**config)
|
|
|
|
|
|
|
|
|
2025-07-04 16:28:13 +02:00
|
|
|
def validate_image_size(value, mb=1):
|
2025-05-23 16:04:33 +02:00
|
|
|
filesize = value.size
|
2025-07-04 16:28:13 +02:00
|
|
|
if filesize > mb * 1024 * 1024:
|
|
|
|
raise ValidationError(f"Maximum file size is {mb} MB")
|
2025-05-23 16:04:33 +02:00
|
|
|
|
|
|
|
|
2025-07-08 15:24:47 +02:00
|
|
|
def validate_image_or_svg(value):
|
|
|
|
"""
|
|
|
|
Validate that the uploaded file is either a valid image or SVG file.
|
|
|
|
"""
|
|
|
|
# Check file size first
|
|
|
|
validate_image_size(value)
|
|
|
|
|
|
|
|
# Get the file extension and MIME type
|
|
|
|
filename = value.name.lower()
|
|
|
|
mime_type, _ = mimetypes.guess_type(filename)
|
|
|
|
|
|
|
|
# List of allowed image formats
|
|
|
|
allowed_image_types = [
|
|
|
|
"image/jpeg",
|
|
|
|
"image/png",
|
|
|
|
"image/gif",
|
|
|
|
"image/webp",
|
|
|
|
"image/bmp",
|
|
|
|
"image/tiff",
|
|
|
|
"image/svg+xml",
|
|
|
|
]
|
|
|
|
|
|
|
|
# Check if it's an SVG file
|
|
|
|
if filename.endswith(".svg") or mime_type == "image/svg+xml":
|
|
|
|
try:
|
|
|
|
# Reset file pointer and read content
|
|
|
|
value.seek(0)
|
|
|
|
content = value.read()
|
|
|
|
value.seek(0) # Reset for later use
|
|
|
|
|
|
|
|
# Try to parse as XML to ensure it's valid SVG
|
|
|
|
ET.fromstring(content)
|
|
|
|
return # Valid SVG
|
|
|
|
except ET.ParseError:
|
|
|
|
raise ValidationError("Invalid SVG file format")
|
|
|
|
|
|
|
|
# For non-SVG files, check MIME type
|
|
|
|
if mime_type not in allowed_image_types:
|
|
|
|
raise ValidationError(
|
|
|
|
f"Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, BMP, TIFF, SVG"
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2025-05-23 16:04:33 +02:00
|
|
|
class Currency(models.TextChoices):
|
|
|
|
CHF = "CHF", "Swiss Franc"
|
|
|
|
EUR = "EUR", "Euro"
|
|
|
|
USD = "USD", "US Dollar"
|
|
|
|
|
|
|
|
|
|
|
|
class Term(models.TextChoices):
|
|
|
|
MTH = "MTH", "per Month (30d)"
|
|
|
|
DAY = "DAY", "per Day"
|
|
|
|
HR = "HR", "per Hour"
|
|
|
|
MIN = "MIN", "per Minute"
|
|
|
|
|
|
|
|
|
|
|
|
class Unit(models.TextChoices):
|
|
|
|
GIB = "GIB", "GiB"
|
|
|
|
MIB = "MIB", "MiB"
|
|
|
|
CPU = "CPU", "vCPU"
|
|
|
|
|
|
|
|
|
2025-07-11 10:52:44 +02:00
|
|
|
class PartnerCategory(models.TextChoices):
|
|
|
|
CONSULTING = "CONSULTING", "Consulting"
|
|
|
|
TRAINING = "TRAINING", "Training"
|
|
|
|
|
|
|
|
|
2025-05-23 17:43:29 +02:00
|
|
|
# This should be a relation, but for now this is good enough :TM:
|
|
|
|
class ManagedServiceProvider(models.TextChoices):
|
|
|
|
VS = "VS", "VSHN"
|
|
|
|
|
|
|
|
|
2025-05-23 16:04:33 +02:00
|
|
|
class ReusableText(models.Model):
|
|
|
|
name = models.CharField(max_length=100)
|
|
|
|
textsnippet = models.ForeignKey(
|
|
|
|
"self",
|
|
|
|
on_delete=models.SET_NULL,
|
|
|
|
null=True,
|
|
|
|
blank=True,
|
|
|
|
related_name="children",
|
|
|
|
)
|
2025-07-08 16:24:28 +02:00
|
|
|
text = get_prose_editor_field()
|
2025-05-23 16:04:33 +02:00
|
|
|
|
|
|
|
class Meta:
|
|
|
|
ordering = ["name"]
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
def get_full_text(self):
|
|
|
|
"""Returns the text with all nested textsnippet content recursively included in reverse order"""
|
|
|
|
text_parts = []
|
|
|
|
|
|
|
|
# Recursively collect all text parts
|
|
|
|
def collect_text(snippet):
|
|
|
|
if snippet is None:
|
|
|
|
return
|
|
|
|
collect_text(snippet.textsnippet) # Collect deepest snippets first
|
|
|
|
text_parts.append(snippet.text)
|
|
|
|
|
|
|
|
# Start collection with the deepest snippets
|
|
|
|
collect_text(self.textsnippet)
|
|
|
|
|
|
|
|
# Add the main text at the end
|
|
|
|
text_parts.append(self.text)
|
|
|
|
|
|
|
|
# Join all text parts
|
|
|
|
return "".join(text_parts)
|
|
|
|
|
|
|
|
|
|
|
|
class Category(models.Model):
|
|
|
|
name = models.CharField(max_length=100)
|
|
|
|
slug = models.SlugField(unique=True)
|
|
|
|
parent = models.ForeignKey(
|
|
|
|
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
|
|
|
|
)
|
|
|
|
description = models.TextField(blank=True)
|
|
|
|
order = models.IntegerField(default=0)
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
verbose_name = "Service Category"
|
|
|
|
verbose_name_plural = "Service Categories"
|
|
|
|
ordering = ["order", "name"]
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
if self.parent:
|
|
|
|
return f"{self.parent} > {self.name}"
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
if not self.slug:
|
|
|
|
self.slug = slugify(self.name)
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
|
|
@property
|
|
|
|
def full_path(self):
|
|
|
|
path = [self.name]
|
|
|
|
parent = self.parent
|
|
|
|
while parent:
|
|
|
|
path.append(parent.name)
|
|
|
|
parent = parent.parent
|
|
|
|
return " > ".join(reversed(path))
|