Files
2024-02-20 19:03:43 +01:00

192 lines
7.1 KiB
Python

from django.core.exceptions import ObjectDoesNotExist
from django.core.validators import MinLengthValidator, RegexValidator
from django.db.models import Model, CharField, DateTimeField, ForeignKey, \
CASCADE, TextField, FileField, CheckConstraint, Q
from django.db.models.signals import pre_save, post_delete
from django.dispatch import receiver
from hashlib import sha256
from tenants.models import Tenant
from winget.util import CharFieldFromChoices, randomize_filename
class Package(Model):
tenant = ForeignKey(Tenant, on_delete=CASCADE)
identifier = CharField(
max_length=128,
help_text='Unique identifier for the package (e.g. WinMerge.WinMerge).'
)
name = CharField(
max_length=256, validators=[MinLengthValidator(2)],
help_text='Package name (e.g. WinMerge).'
)
publisher = CharField(
max_length=256, validators=[MinLengthValidator(2)],
help_text='Package publisher (eg. Thingamahoochie Software)'
)
description = TextField(
max_length=256, validators=[MinLengthValidator(3)],
help_text=
'Package description (e.g. "An open source differencing tool.")'
)
created = DateTimeField(auto_now_add=True)
modified = DateTimeField(auto_now=True)
class Meta:
unique_together = ('tenant', 'identifier')
def __str__(self):
return self.name
class Version(Model):
package = ForeignKey(Package, on_delete=CASCADE)
version = CharField(
max_length=128, blank=True,
help_text="The package's version (eg. 2.16.26 or 1.2.3.4)."
)
created = DateTimeField(auto_now_add=True)
modified = DateTimeField(auto_now=True)
class Meta:
unique_together = ('package', 'version')
def __str__(self):
result = self.package.name
if self.version:
result += ' ' + self.version
return result
def installer_upload_to(instance, filename):
# Randomize the upload path. This prevents users from guessing it and
# prevents clashes.
randomized_filename = randomize_filename(filename)
return f'{instance.version.package.tenant.uuid}/{randomized_filename}'
class Installer(Model):
version = ForeignKey(Version, on_delete=CASCADE)
architecture = CharFieldFromChoices('x86', 'x64', 'arm', 'arm64')
scope = CharFieldFromChoices(
'user', 'machine', 'both', default='both',
help_text=
"Is this a machine-wide installer, just for the current user, or both?"
)
type = CharFieldFromChoices(
'msix', 'msi', 'appx', 'exe', 'zip', 'inno', 'nullsoft', 'wix', 'burn',
'pwa', 'msstore'
)
file = FileField(upload_to=installer_upload_to)
nested_installer = CharField(
blank=True, max_length=512, help_text=
"If this is a zip file, which installer inside it should be run?"
)
nested_installer_type = CharFieldFromChoices(
'msix', 'msi', 'appx', 'exe', 'inno', 'nullsoft', 'wix', 'burn',
'portable', blank=True, null=True, help_text=
"If this is a zip file, what's the type of the nested installer?"
)
silent_switch = CharField(
blank=True, max_length=512, help_text=
"Setting this to abc makes `winget install --silent` run "
"`installer.exe abc`."
)
silent_progress_switch = CharField(
blank=True, max_length=512, help_text=
"Setting this to abc makes `winget install` (without modifiers) run "
"`installer.exe abc`."
)
interactive_switch = CharField(
blank=True, max_length=512, help_text=
"Setting this to abc makes `winget install --interactive` run "
"`installer.exe abc`."
)
install_location_switch = CharField(
blank=True, max_length=512, help_text=
'Setting this to "abc <INSTALLPATH>" makes `winget install '
'--location C:\Dir` run `installer.exe abc C:\Dir`.'
)
log_switch = CharField(blank=True, max_length=512, help_text=
'Setting this to "abc <LOGPATH>" runs `installer.exe abc '
'<some file path>`. Set the path with `winget install --log`.'
)
upgrade_switch = CharField(
blank=True, max_length=512, help_text=
"The installer's command-line argument when the user chooses an upgrade."
)
custom_switch = CharField(
blank=True, max_length=512, help_text=
"Setting this to abc always appends `abc` to the installer's "
"command-line. Eg. `installer.exe abc`."
)
sha256 = CharField(
max_length=64, validators=[RegexValidator('^[a-fA-F0-9]{64}$')]
)
created = DateTimeField(auto_now_add=True)
modified = DateTimeField(auto_now=True)
class Meta:
unique_together = ('version', 'architecture', 'type')
# N.B.: The constraints here need to be checked in .validate(...) too.
constraints = [
CheckConstraint(
check=(Q(type='zip') & ~Q(nested_installer='')) | \
(~Q(type='zip') & Q(nested_installer='')),
name='nested_installer_iff_zip'
),
CheckConstraint(
check=(Q(type='zip') & ~Q(nested_installer_type=None)) | \
(~Q(type='zip') & Q(nested_installer_type=None)),
name='nested_installer_type_iff_zip'
),
]
@classmethod
def validate(cls, data):
# If possible, the checks here should be in Meta.constraints too.
errors = {}
def add_error(field, message):
field_name = cls._meta.get_field(field).verbose_name.capitalize()
errors[field] = field_name + ' ' + message
if data.get('type') == 'zip':
if not data.get('nested_installer'):
add_error('nested_installer', 'is required when type is "zip".')
if not data.get('nested_installer_type'):
add_error(
'nested_installer_type', 'is required when type is "zip".'
)
else:
if data.get('nested_installer'):
add_error(
'nested_installer', 'can only be set when type is "zip".'
)
if data.get('nested_installer_type'):
add_error(
'nested_installer_type',
'can only be set when type is "zip".'
)
return errors
@property
def scopes(self):
return ['user', 'machine'] if self.scope == 'both' else [self.scope]
def __str__(self):
result_parts = []
try:
result_parts.append(str(self.version))
except ObjectDoesNotExist:
pass
if self.architecture:
result_parts.append(self.architecture)
if self.scope != 'both':
result_parts.append(self.scope)
return ' '.join(result_parts)
@receiver(pre_save, sender=Installer)
def pre_installer_save(sender, instance, **kwargs):
m = sha256()
instance.file.seek(0)
for chunk in instance.file.chunks():
m.update(chunk)
instance.sha256 = m.digest().hex()
@receiver(post_delete, sender=Installer)
def post_installer_delete(sender, instance, using, **kwargs):
instance.file.delete(False)