# -*- coding: utf-8 -*- # Copyright (C) 2009 Canonical # # Authors: # Michael Vogt # # This program is free software; you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software # Foundation; version 3. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more # details. # # You should have received a copy of the GNU General Public License along with # this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import logging import json import os import time from softwarecenter.backend.spawn_helper import SpawnHelper from softwarecenter.backend.reviews import ( ReviewLoader, Review, ReviewStats, UsefulnessCache, ) from softwarecenter.backend.piston.rnrclient import RatingsAndReviewsAPI from softwarecenter.backend.piston.rnrclient_pristine import ReviewDetails from softwarecenter.db.database import Application import softwarecenter.distro from softwarecenter.netstatus import network_state_is_connected from softwarecenter.paths import ( SOFTWARE_CENTER_CACHE_DIR, PistonHelpers, RNRApps, ) from softwarecenter.utils import ( calc_dr, utf8, save_person_to_config, ) LOG = logging.getLogger(__name__) # this code had several incarnations: # - python threads, slow and full of latency (GIL) # - python multiprocessing, crashed when accessibility was turned on, # does not work in the quest session (#743020) # - GLib.spawn_async() looks good so far (using the SpawnHelper code) class ReviewLoaderSpawningRNRClient(ReviewLoader): """ loader that uses multiprocessing to call rnrclient and a glib timeout watcher that polls periodically for the data """ def __init__(self, cache, db, distro=None): super(ReviewLoaderSpawningRNRClient, self).__init__(cache, db, distro) cachedir = os.path.join(SOFTWARE_CENTER_CACHE_DIR, "rnrclient") self.rnrclient = RatingsAndReviewsAPI(cachedir=cachedir) cachedir = os.path.join(SOFTWARE_CENTER_CACHE_DIR, "rnrclient") self.rnrclient = RatingsAndReviewsAPI(cachedir=cachedir) self._reviews = {} def _update_rnrclient_offline_state(self): # this needs the lp:~mvo/piston-mini-client/offline-mode branch self.rnrclient._offline_mode = not network_state_is_connected() # reviews def get_reviews(self, translated_app, page=1, language=None, sort=0, relaxed=False): """ public API, triggers fetching a review and emits get-reviews-finished signal when its ready """ # its fine to use the translated appname here, we only submit the # pkgname to the server app = translated_app self._update_rnrclient_offline_state() sort_method = self._review_sort_methods[sort] if language is None: language = self.language # gather args for the helper if relaxed: origin = 'any' distroseries = 'any' else: try: origin = self.cache.get_origin(app.pkgname) except: # this can happen if e.g. the app has multiple origins, this # will be handled later origin = None # special case for not-enabled PPAs if not origin and self.db: details = app.get_details(self.db) ppa = details.ppaname if ppa: origin = "lp-ppa-%s" % ppa.replace("/", "-") # if there is no origin, there is nothing to do if not origin: self.emit("get-reviews-finished", app, []) return distroseries = self.distro.get_codename() # run the command and add watcher cmd = [os.path.join(softwarecenter.paths.datadir, PistonHelpers.GET_REVIEWS), "--language", language, "--origin", origin, "--distroseries", distroseries, "--pkgname", str(app.pkgname), # ensure its str, not unicode "--page", str(page), "--sort", sort_method, ] spawn_helper = SpawnHelper() spawn_helper.connect( "data-available", self._on_reviews_helper_data, app) spawn_helper.run(cmd) def _on_reviews_helper_data(self, spawn_helper, piston_reviews, app): # convert into our review objects reviews = [] for r in piston_reviews: reviews.append(Review.from_piston_mini_client(r)) # add to our dicts and emit signal self._reviews[app] = reviews self.emit("get-reviews-finished", app, self._reviews[app]) return False # stats def refresh_review_stats(self): """ public API, refresh the available statistics """ try: mtime = os.path.getmtime(self.REVIEW_STATS_CACHE_FILE) days_delta = int((time.time() - mtime) // (24 * 60 * 60)) days_delta += 1 except OSError: days_delta = 0 LOG.debug("refresh with days_delta: %s" % days_delta) # FIXME: the server currently has bug (#757695) so we # can not turn this on just yet and need to use # the old "catch-all" review-stats for now #origin = "any" #distroseries = self.distro.get_codename() spawn_helper = SpawnHelper() spawn_helper.connect("data-available", self._on_review_stats_data) if days_delta: spawn_helper.run_generic_piston_helper( "RatingsAndReviewsAPI", "review_stats", days=days_delta) else: spawn_helper.run_generic_piston_helper( "RatingsAndReviewsAPI", "review_stats") def _on_review_stats_data(self, spawn_helper, piston_review_stats): """ process stdout from the helper """ review_stats = self.REVIEW_STATS_CACHE if self._cache_version_old and self._server_has_histogram( piston_review_stats): self.REVIEW_STATS_CACHE = {} self.save_review_stats_cache_file() self.refresh_review_stats() return # convert to the format that s-c uses for r in piston_review_stats: s = ReviewStats(Application("", r.package_name)) s.ratings_average = float(r.ratings_average) s.ratings_total = float(r.ratings_total) if r.histogram: s.rating_spread = json.loads(r.histogram) else: s.rating_spread = [0, 0, 0, 0, 0] s.dampened_rating = calc_dr(s.rating_spread) review_stats[s.app] = s self.REVIEW_STATS_CACHE = review_stats self.emit("refresh-review-stats-finished", review_stats) self.save_review_stats_cache_file() def _server_has_histogram(self, piston_review_stats): '''check response from server to see if histogram is supported''' supported = getattr(piston_review_stats[0], "histogram", False) if not supported: return False return True # writing new reviews spawns external helper def spawn_write_new_review_ui(self, translated_app, version, iconname, origin, parent_xid, datadir): """ this spawns the UI for writing a new review and adds it automatically to the reviews DB """ app = translated_app.get_untranslated_app(self.db) cmd = [os.path.join(datadir, RNRApps.SUBMIT_REVIEW), "--pkgname", app.pkgname, "--iconname", iconname, "--parent-xid", "%s" % parent_xid, "--version", version, "--origin", origin, "--datadir", datadir, ] if app.appname: # needs to be (utf8 encoded) str, otherwise call fails cmd += ["--appname", utf8(app.appname)] spawn_helper = SpawnHelper(format="json") spawn_helper.connect( "data-available", self._on_submit_review_data, app) spawn_helper.connect("exited", self._on_exited_callback, app) spawn_helper.connect("error", self._on_error_callback, app) spawn_helper.run(cmd) def _on_exited_callback(self, spawn_helper, return_code, app): # FIXME: send a proper error here instead! self.emit("get-reviews-finished", app, []) def _on_error_callback(self, spawn_helper, error_str, app): # FIXME: send a proper error here instead! self.emit("get-reviews-finished", app, []) def _on_submit_review_data(self, spawn_helper, review_json, app): """ called when submit_review finished, when the review was send successfully the callback is triggered with the new reviews """ LOG.debug("_on_submit_review_data") # read stdout from submit_review review = ReviewDetails.from_dict(review_json) # FIXME: ideally this would be stored in ubuntu-sso-client # but it doesn't so we store it here save_person_to_config(review.reviewer_username) if not app in self._reviews: self._reviews[app] = [] self._reviews[app].insert(0, Review.from_piston_mini_client(review)) self.emit("get-reviews-finished", app, self._reviews[app]) def spawn_report_abuse_ui(self, review_id, parent_xid, datadir): """ this spawns the UI for reporting a review as inappropriate and adds the review-id to the internal hide list. once the operation is complete it will emit remove-review with the updated review list """ cmd = [os.path.join(datadir, RNRApps.REPORT_REVIEW), "--review-id", review_id, "--parent-xid", "%s" % parent_xid, "--datadir", datadir, ] spawn_helper = SpawnHelper("json") spawn_helper.connect("exited", self._on_report_abuse_finished, review_id) spawn_helper.run(cmd) def _on_report_abuse_finished(self, spawn_helper, exitcode, review_id): """ called when report_abuse finished """ LOG.debug("hide id %s " % review_id) if exitcode == 0: for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): # remove the one we don't want to see anymore self._reviews[app].remove(review) self.emit("remove-review", app, review) break def spawn_submit_usefulness_ui(self, review_id, is_useful, parent_xid, datadir): cmd = [os.path.join(datadir, RNRApps.SUBMIT_USEFULNESS), "--review-id", "%s" % review_id, "--is-useful", "%s" % int(is_useful), "--parent-xid", "%s" % parent_xid, "--datadir", datadir, ] spawn_helper = SpawnHelper(format="none") spawn_helper.connect("exited", self._on_submit_usefulness_finished, review_id, is_useful) spawn_helper.connect("error", self._on_submit_usefulness_error, review_id) spawn_helper.run(cmd) def _on_submit_usefulness_finished(self, spawn_helper, res, review_id, is_useful): """ called when report_usefulness finished """ # "Created", "Updated", "Not modified" - # once lp:~mvo/rnr-server/submit-usefulness-result-strings makes it response = spawn_helper._stdout if response == '"Not modified"': self._on_submit_usefulness_error(spawn_helper, response, review_id) return LOG.debug("usefulness id %s " % review_id) useful_votes = UsefulnessCache() useful_votes.add_usefulness_vote(review_id, is_useful) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): # update usefulness, older servers do not send # usefulness_{total,favorable} so we use getattr review.usefulness_total = getattr(review, "usefulness_total", 0) + 1 if is_useful: review.usefulness_favorable = getattr( review, "usefulness_favorable", 0) + 1 self.emit("update-usefulness-votes", useful_votes) self.emit("replace-review", app, review) break def _on_submit_usefulness_error(self, spawn_helper, error_str, review_id): LOG.warn("submit usefulness id=%s failed with error: %s" % (review_id, error_str)) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): review.usefulness_submit_error = True self.emit("replace-review", app, review) break def spawn_delete_review_ui(self, review_id, parent_xid, datadir): cmd = [os.path.join(datadir, RNRApps.DELETE_REVIEW), "--review-id", "%s" % review_id, "--parent-xid", "%s" % parent_xid, "--datadir", datadir, ] spawn_helper = SpawnHelper(format="none") spawn_helper.connect("exited", self._on_delete_review_finished, review_id) spawn_helper.connect("error", self._on_delete_review_error, review_id) spawn_helper.run(cmd) def _on_delete_review_finished(self, spawn_helper, res, review_id): """ called when delete_review finished""" LOG.debug("delete id %s " % review_id) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): # remove the one we don't want to see anymore self._reviews[app].remove(review) self.emit("remove-review", app, review) break def _on_delete_review_error(self, spawn_helper, error_str, review_id): """called if delete review errors""" LOG.warn("delete review id=%s failed with error: %s" % (review_id, error_str)) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): review.delete_error = True self.emit("remove-review", app, review) break def spawn_modify_review_ui(self, parent_xid, iconname, datadir, review_id): """ this spawns the UI for writing a new review and adds it automatically to the reviews DB """ cmd = [os.path.join(datadir, RNRApps.MODIFY_REVIEW), "--parent-xid", "%s" % parent_xid, "--iconname", iconname, "--datadir", "%s" % datadir, "--review-id", "%s" % review_id, ] spawn_helper = SpawnHelper(format="json") spawn_helper.connect("data-available", self._on_modify_review_finished, review_id) spawn_helper.connect("error", self._on_modify_review_error, review_id) spawn_helper.run(cmd) def _on_modify_review_finished(self, spawn_helper, review_json, review_id): """called when modify_review finished""" LOG.debug("_on_modify_review_finished") #review_json = spawn_helper._stdout mod_review = ReviewDetails.from_dict(review_json) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): # remove the one we don't want to see anymore self._reviews[app].remove(review) new_review = Review.from_piston_mini_client(mod_review) self._reviews[app].insert(0, new_review) self.emit("replace-review", app, new_review) break def _on_modify_review_error(self, spawn_helper, error_str, review_id): """called if modify review errors""" LOG.debug("modify review id=%s failed with error: %s" % (review_id, error_str)) for (app, reviews) in self._reviews.items(): for review in reviews: if str(review.id) == str(review_id): review.modify_error = True self.emit("replace-review", app, review) break