fdroid-repomaker/repomaker/models/remoteapp.py

263 lines
11 KiB
Python

import datetime
import logging
from io import BytesIO
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.utils import timezone, translation
from django.utils.translation import ugettext_lazy as _
from fdroidserver import net
from repomaker import tasks
from repomaker.tasks import PRIORITY_REMOTE_APP_ICON
from repomaker.utils import clean
from .app import AbstractApp
from .category import Category
from .remoterepository import RemoteRepository
class RemoteApp(AbstractApp):
repo = models.ForeignKey(RemoteRepository, on_delete=models.CASCADE)
icon_etag = models.CharField(max_length=128, blank=True, null=True)
last_updated_date = models.DateTimeField(blank=True)
# Translated fields
feature_graphic_url = models.URLField(blank=True, max_length=2048)
feature_graphic_etag = models.CharField(max_length=128, blank=True, null=True)
high_res_icon_url = models.URLField(blank=True, max_length=2048)
high_res_icon_etag = models.CharField(max_length=128, blank=True, null=True)
tv_banner_url = models.URLField(blank=True, max_length=2048)
tv_banner_etag = models.CharField(max_length=128, blank=True, null=True)
def update_from_json(self, app):
"""
Updates the data for this app and ensures that at least one translation exists.
:param app: A JSON app object from the repository v1 index.
:return: True if app changed, False otherwise
"""
if 'lastUpdated' not in app:
logging.warning("App %s is missing 'lastUpdated' in index")
return False
# don't update if app hasn't changed since last update
last_update = datetime.datetime.fromtimestamp(app['lastUpdated'] / 1000, timezone.utc)
if self.last_updated_date and self.last_updated_date >= last_update:
logging.info("Skipping update of %s, because did not change.", self)
return False
else:
self.last_updated_date = last_update
if 'name' not in app:
logging.warning("App %s is missing 'name' in index")
return False
self.name = app['name']
if 'summary' in app and not self._move_to_localized(app, 'summary'):
self.summary_override = app['summary']
if 'description' in app and not self._move_to_localized(app, 'description'):
self.description_override = clean(app['description'])
if 'authorName' in app:
self.author_name = app['authorName']
if 'webSite' in app:
self.website = app['webSite']
if 'categories' in app:
self._update_categories(app['categories'])
if 'added' in app:
date_added = datetime.datetime.fromtimestamp(app['added'] / 1000, timezone.utc)
if self.added_date > date_added:
self.added_date = date_added
self.save()
if 'localized' in app:
self._update_translations(app['localized'])
self._update_screenshots(app['localized'])
if len(self.get_available_languages()) == 0:
# no localization available, translate in default language
self.default_translate()
self.save()
# do the icon last, because we require the app to be saved, so a pk exists
if 'icon' in app:
# Schedule icon updating task, because it takes too long within this task
# pylint: disable=unexpected-keyword-arg
tasks.update_remote_app_icon(self.pk, app['icon'], priority=PRIORITY_REMOTE_APP_ICON)
return True
@staticmethod
def _move_to_localized(app, key):
"""
Moves :param key into JSON localized object if possible.
:param app: A JSON app object from the repository v1 index.
:return: True if key could be moved, False otherwise.
"""
if 'localized' not in app:
app['localized'] = dict()
if settings.LANGUAGE_CODE not in app['localized']:
app['localized'][settings.LANGUAGE_CODE] = dict()
if key not in app['localized'][settings.LANGUAGE_CODE]:
# cleaning not required here, because it will be done when importing localized
app['localized'][settings.LANGUAGE_CODE][key] = app[key]
del app[key]
return True
return False
def update_icon(self, icon_name):
"""
Updates the app's icon from the remote repository.
Should be run in a background task.
:param icon_name: The file name of the icon
"""
url = self.repo.url + '/icons-640/' + icon_name
icon, etag = net.http_get(url, self.icon_etag)
if icon is None:
return # icon did not change
self.delete_old_icon()
self.icon_etag = etag
self.icon.save(icon_name, BytesIO(icon), save=True)
# update icons of all local apps tracking this one
for tracking_app in self.app_set.all():
tracking_app.update_icon(self.icon)
def _update_categories(self, categories):
if not self.pk:
# we need to save before we can use a ManyToManyField
self.save()
for category in categories:
try:
cat = Category.objects.get(name=category)
# TODO not only add, but also remove old categories again
self.category.add(cat)
except ObjectDoesNotExist:
# Drop the unknown category, don't create new categories automatically here
pass
def _update_translations(self, localized):
# TODO also support 'name, 'whatsNew' and 'video'
supported_fields = ['summary', 'description', 'featureGraphic', 'icon', 'tvBanner']
available_languages = self.get_available_languages()
for original_language_code, app_translation in localized.items():
if set(supported_fields).isdisjoint(app_translation.keys()):
continue # no supported fields in translation
# store language code in lower-case, because in Django they are all lower-case as well
language_code = original_language_code.lower()
# TODO not only add, but also remove old translations again
if language_code not in available_languages:
self.translate(language_code)
with translation.override(language_code):
self.apply_translation(original_language_code, app_translation)
# pylint: disable=attribute-defined-outside-init
# noinspection PyAttributeOutsideInit
def apply_translation(self, original_language_code, new_translation):
# textual metadata
if 'summary' in new_translation:
self.summary = new_translation['summary']
if 'description' in new_translation:
self.description = clean(new_translation['description'])
# graphic assets
url = self._get_base_url(original_language_code)
if 'featureGraphic' in new_translation:
self.feature_graphic_url = url + new_translation['featureGraphic']
if 'icon' in new_translation:
self.high_res_icon_url = url + new_translation['icon']
if 'tvBanner' in new_translation:
self.tv_banner_url = url + new_translation['tvBanner']
self.save()
def _update_screenshots(self, localized):
from repomaker.models import RemoteScreenshot
for original_language_code, types in localized.items():
# store language code in lower-case, because in Django they are all lower-case as well
language_code = original_language_code.lower()
for t, files in types.items():
type_url = self._get_base_url(original_language_code, t)
# TODO not only add, but also remove old screenshots again
# add screenshot (ignores unsupported types such as summary)
RemoteScreenshot.add(language_code, t, self, type_url, files)
def _get_base_url(self, locale, asset_type=None):
"""
Returns the base URL for the given locale and asset type with a trailing slash
"""
url = self.repo.url + '/' + self.package_id + '/' + locale + '/'
if asset_type is None:
return url
return url + asset_type + '/'
def get_latest_apk_pointer(self):
"""
Returns this app's latest RemoteApkPointer object or None if none exists.
"""
from .apk import RemoteApkPointer
qs = RemoteApkPointer.objects.filter(app=self).order_by('-apk__version_code').all()
if qs.count() < 1:
return None
return qs[0]
def get_latest_apk(self):
"""
Returns this app's latest Apk object or None if none exists.
"""
apk_pointer = self.get_latest_apk_pointer()
if apk_pointer is None:
return None
return apk_pointer.apk
def add_to_repo(self, repo):
"""
Adds this RemoteApp to the given local repository.
:param repo: The local repository the app should be added to
:return: The added App object
"""
from .app import App
from .screenshot import RemoteScreenshot
if self.is_in_repo(repo):
raise ValidationError(_("This app does already exist in your repository."))
# add only latest APK
remote_pointer = self.get_latest_apk_pointer()
if remote_pointer is None:
raise ValidationError(_("This app does not have any working versions available."))
# create new local app and update its information from remote app
app = App(repo=repo, package_id=self.package_id, tracked_remote=self)
app.update_from_tracked_remote_app(remote_pointer)
# since this is the initial import, also add the app icon
if self.icon:
app.update_icon(self.icon)
# schedule download of remote graphic assets
tasks.download_remote_graphic_assets(app.id, self.id)
# schedule download of remote screenshots if available
for remote in RemoteScreenshot.objects.filter(app=self).all():
remote.download_async(app)
return app
def get_latest_version(self):
from .apk import RemoteApkPointer
pointers = RemoteApkPointer.objects.filter(app=self).order_by('-apk__version_code')
if pointers.exists() and pointers[0].apk:
return pointers[0].apk
return None
def is_in_repo(self, repo):
"""
:param repo: A Repository object.
:return: True if an app with this package_id is in repo, False otherwise
"""
from .app import App
return App.objects.filter(repo=repo, package_id=self.package_id).exists()
@receiver(post_delete, sender=RemoteApp)
def remote_app_post_delete_handler(**kwargs):
app = kwargs['instance']
app.delete_old_icon()