fdroid-repomaker/repomaker/models/app.py

364 lines
14 KiB
Python

from io import BytesIO
import os
from django.conf import settings
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.templatetags.static import static
from django.urls import reverse
from django.utils import timezone, translation
from django.utils.translation import ugettext_lazy as _
from fdroidserver import metadata, net
from modeltranslation.utils import get_language
from repomaker.storage import get_icon_file_path_for_app, \
get_graphic_asset_file_path
from repomaker.utils import clean, to_universal_language_code
from .category import Category
from .repository import Repository
APK = 'apk'
BOOK = 'book'
DOCUMENT = 'document'
IMAGE = 'image'
AUDIO = 'audio'
VIDEO = 'video'
OTHER = 'other' # deprecated due to white-list
TYPE_CHOICES = (
(APK, _('APK')),
(BOOK, _('Book')),
(DOCUMENT, _('Document')),
(IMAGE, _('Image')),
(AUDIO, _('Audio')),
(VIDEO, _('Video')),
(OTHER, _('Other')),
)
APP_DEFAULT_ICON = os.path.join('repomaker', 'images', 'default-app-icon.png')
class AbstractApp(models.Model):
package_id = models.CharField(max_length=255, blank=True)
name = models.CharField(max_length=255, blank=True)
summary_override = models.CharField(max_length=255, blank=True)
description_override = models.TextField(blank=True) # always clean and then consider safe
author_name = models.CharField(max_length=255, blank=True)
website = models.URLField(max_length=2048, blank=True)
icon = models.ImageField(upload_to=get_icon_file_path_for_app)
category = models.ManyToManyField(Category, blank=True, limit_choices_to={'user': None})
added_date = models.DateTimeField(default=timezone.now)
# Translated fields
# for historic reasons summary and description are also included non-localized in the index
summary = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True) # always clean and then consider safe
# hvad kept track of what translations exist, modeltranslation doesn't so we
# keep track in this field (max_lenght just affects admin display)
# we keep a comma separated list
available_languages = models.TextField(max_length=8, default="")
def __str__(self):
return self.name
@property
def icon_url(self):
if self.icon:
return self.icon.url
return static(APP_DEFAULT_ICON)
def translate(self, language):
"""
This was a method from hvad, we're keeping track becasue modeltranslation
doesn't
order of the list is significant, the first item is assumed to be the
default language
"""
if self.available_languages == "":
self.available_languages = language
else:
lang_list = self.available_languages.split(',')
lang_list.append(language)
lang_list = list(set(lang_list)) # remove duplicate values
self.available_languages = ','.join(lang_list)
def default_translate(self):
"""Creates a new default translation"""
self.translate(get_language())
def get_available_languages(self):
"""
This method was originally provided by django-hvad
TODO: should we just keep track of what translations are provided
in a separate field?
"""
if self.available_languages == '':
return []
return self.available_languages.split(',')
def get_available_languages_as_dicts(self):
"""
Returns a list of dictionaries that include the language name and code
If no name is available, it uses the language code as the name
"""
languages = []
for lang in self.get_available_languages():
found_name = False
for code, name in settings.LANGUAGES:
if code == lang:
languages.append({'code': code, 'name': name})
found_name = True
break
if not found_name:
languages.append({'code': lang, 'name': lang})
return languages
def get_icon_basename(self):
if self.icon:
return os.path.basename(self.icon.path)
# TODO handle default app icons on static repo page
return None
def get_latest_version(self):
raise NotImplementedError()
def delete_old_icon(self):
if self.icon:
self.icon.delete(save=False)
class Meta:
abstract = True
ordering = ['added_date']
unique_together = (("package_id", "repo"),)
class App(AbstractApp):
repo = models.ForeignKey(Repository, on_delete=models.CASCADE)
type = models.CharField(max_length=16, choices=TYPE_CHOICES, default=APK)
last_updated_date = models.DateTimeField(auto_now=True)
tracked_remote = models.ForeignKey('RemoteApp', null=True, default=None,
on_delete=models.SET_NULL)
# Translated fields
feature_graphic = models.ImageField(blank=True, max_length=1024,
upload_to=get_graphic_asset_file_path)
high_res_icon = models.ImageField(blank=True, max_length=1024,
upload_to=get_graphic_asset_file_path)
tv_banner = models.ImageField(blank=True, max_length=1024,
upload_to=get_graphic_asset_file_path)
def get_absolute_url(self):
kwargs = {'repo_id': self.repo.pk, 'app_id': self.pk}
lang = get_language()
if lang in self.get_available_languages():
kwargs['lang'] = lang
else:
kwargs['lang'] = self.get_available_languages()[0]
return reverse('app', kwargs=kwargs)
def get_edit_url(self):
kwargs = {'repo_id': self.repo.pk, 'app_id': self.pk}
lang = get_language()
if lang in self.get_available_languages():
kwargs['lang'] = lang
else:
kwargs['lang'] = self.get_available_languages()[0]
return reverse('app_edit', kwargs=kwargs)
def get_previous(self):
return self.get_previous_by_added_date(repo=self.repo)
def get_next(self):
return self.get_next_by_added_date(repo=self.repo)
def to_metadata_app(self):
meta = metadata.App()
meta.id = self.package_id
meta.Name = self.name
meta.WebSite = self.website
meta.Summary = self.summary_override
meta.Description = self.description_override
meta.AuthorName = self.author_name
meta.added = timezone.make_naive(self.added_date)
meta.Categories = [category.name for category in self.category.all()]
meta['localized'] = self._get_screenshot_dict()
self._add_translations_to_localized(meta['localized'])
return meta
def _add_translations_to_localized(self, localized):
for original_language_code in self.get_available_languages():
# upper-case region part of language code for compatibility
language_code = to_universal_language_code(original_language_code)
if language_code not in localized:
localized[language_code] = dict()
with translation.override(original_language_code):
if self.summary:
localized[language_code]['summary'] = self.summary
if self.description:
localized[language_code]['description'] = self.description
if self.feature_graphic:
localized[language_code]['featureGraphic'] = os.path.basename(
self.feature_graphic.name)
if self.high_res_icon:
localized[language_code]['icon'] = os.path.basename(self.high_res_icon.name)
if self.tv_banner:
localized[language_code]['tvBanner'] = os.path.basename(self.tv_banner.name)
if localized[language_code] == {}:
# remove empty translation
del localized[language_code]
def _get_screenshot_dict(self):
from . import Screenshot
localized = dict()
screenshots = Screenshot.objects.filter(app=self).all()
for s in screenshots:
# upper-case region part of language code for compatibility
language_code = to_universal_language_code(s.language_code)
if language_code not in localized:
localized[language_code] = dict()
if s.type not in localized[language_code]:
localized[language_code][s.type] = []
localized[language_code][s.type].append(os.path.basename(s.file.name))
return localized
# pylint: disable=attribute-defined-outside-init
# noinspection PyAttributeOutsideInit
def copy_translations_from_remote_app(self, remote_app):
"""
Copies metadata translations from given RemoteApp
and ensures that at least one translation exists at the end.
"""
from .remoteapp import RemoteApp
remote_app = RemoteApp.objects.get(pk=remote_app.pk)
for language_code in remote_app.get_available_languages():
summary_field = 'summary_{}'.format(language_code.replace('-', '_'))
description_field = 'description_{}'.format(language_code.replace('-', '_'))
if language_code not in self.get_available_languages():
self.translate(language_code)
summary = getattr(remote_app, summary_field)
if summary:
setattr(self, summary_field, summary)
description = getattr(remote_app, description_field)
if description:
setattr(self, description_field, clean(description))
# ensure that at least one translation exists
if len(self.get_available_languages()) == 0:
self.default_translate()
self.save()
def download_graphic_assets_from_remote_app(self, remote_app):
"""
Does a blocking download of the RemoteApp's graphic assets and replaces the local ones.
Attention: This assumes that all translations exist already.
"""
from .remoteapp import RemoteApp
for language_code in remote_app.get_available_languages():
# get the translation for current language_code
with translation.override(language_code):
remote_app = RemoteApp.objects.get(pk=remote_app.pk)
if remote_app.feature_graphic_url:
graphic, etag = net.http_get(remote_app.feature_graphic_url,
remote_app.feature_graphic_etag)
if graphic is not None:
self.feature_graphic.delete()
graphic_name = os.path.basename(remote_app.feature_graphic_url)
self.feature_graphic.save(graphic_name, BytesIO(graphic), save=False)
remote_app.feature_graphic_etag = etag
if remote_app.high_res_icon_url:
graphic, etag = net.http_get(remote_app.high_res_icon_url,
remote_app.high_res_icon_etag)
if graphic is not None:
self.high_res_icon.delete()
graphic_name = os.path.basename(remote_app.high_res_icon_url)
self.high_res_icon.save(graphic_name, BytesIO(graphic), save=False)
remote_app.high_res_icon_etag = etag
if remote_app.tv_banner_url:
graphic, etag = net.http_get(remote_app.tv_banner_url,
remote_app.tv_banner_etag)
if graphic is not None:
self.tv_banner.delete()
graphic_name = os.path.basename(remote_app.tv_banner_url)
self.tv_banner.save(graphic_name, BytesIO(graphic), save=False)
remote_app.tv_banner_etag = etag
self.save()
remote_app.save()
# noinspection PyTypeChecker
def update_from_tracked_remote_app(self, remote_apk_pointer):
"""
Update all app information from the tracked remote app
:param remote_apk_pointer: the latest RemoteApkPointer of the app
"""
if not self.tracked_remote:
raise RuntimeWarning("Trying to add an APK to an app that is not tracking a remote.")
self.name = self.tracked_remote.name
self.author_name = self.tracked_remote.author_name
self.website = self.tracked_remote.website
# the icon gets updated asynchronously later by the remote app
if not self.pk:
self.save() # save before adding categories, so pk exists
self.category.set(self.tracked_remote.category.all())
self.copy_translations_from_remote_app(self.tracked_remote)
self.add_apk_from_tracked_remote_app(remote_apk_pointer)
self.save()
def add_apk_from_tracked_remote_app(self, remote_apk_pointer):
"""
Add the latest APK to this app, if it does not already exist.
Creates an ApkPointer object as well and triggers an async download if necessary.
:param remote_apk_pointer: the latest RemoteApkPointer of the app
"""
# bail out if we already have the latest APK
latest_apk = self.get_latest_version()
if latest_apk is not None and latest_apk == remote_apk_pointer.apk:
return
# create a local pointer to the APK
from .apk import ApkPointer
apk = remote_apk_pointer.apk
pointer = ApkPointer(apk=apk, repo=self.repo, app=self)
if apk.file:
pointer.link_file_from_apk() # this also saves the pointer
else:
pointer.save()
# schedule APK file download if necessary, also updates all local pointers to that APK
apk.download_async(remote_apk_pointer.url)
def update_icon(self, icon):
"""
Replace the old icon of this app with a new one
:param icon: The new icon as a File
"""
self.delete_old_icon()
self.icon.save(os.path.basename(icon.name), icon.file, save=True)
def get_latest_version(self):
from .apk import ApkPointer
pointers = ApkPointer.objects.filter(app=self).order_by('-apk__version_code')
if pointers.exists() and pointers[0].apk:
return pointers[0].apk
return None
@receiver(post_delete, sender=App)
def app_post_delete_handler(**kwargs):
app = kwargs['instance']
app.delete_old_icon()