# -*- coding: utf-8 -*- # Copyright (C) 2012 Canonical # # Authors: # Gary Lasker # # 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 from gi.repository import Gtk, GObject, GLib import logging from gettext import gettext as _ from softwarecenter.ui.gtk3.em import StockEms from softwarecenter.ui.gtk3.widgets.containers import (FramedHeaderBox, TileGrid) from softwarecenter.ui.gtk3.utils import get_parent_xid from softwarecenter.db.categories import (RecommendedForYouCategory, AppRecommendationsCategory) from softwarecenter.backend.installbackend import get_install_backend from softwarecenter.backend.recagent import RecommenderAgent from softwarecenter.backend.login import get_login_backend from softwarecenter.backend.ubuntusso import get_ubuntu_sso_backend from softwarecenter.enums import ( LOBBY_RECOMMENDATIONS_CAROUSEL_LIMIT, DETAILS_RECOMMENDATIONS_CAROUSEL_LIMIT, SOFTWARE_CENTER_NAME_KEYRING, RecommenderFeedbackActions, TransactionTypes, ) from softwarecenter.utils import utf8 from softwarecenter.netstatus import network_state_is_connected LOG = logging.getLogger(__name__) class RecommendationsPanel(FramedHeaderBox): """ Base class for widgets that display recommendations """ __gsignals__ = { "application-activated": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (GObject.TYPE_PYOBJECT,), ), } def __init__(self): FramedHeaderBox.__init__(self) self.recommender_agent = RecommenderAgent() # keep track of applications that have been viewed via a # recommendation so that we can detect when a recommended app # has been installed self.recommended_apps_viewed = set() self.backend = get_install_backend() self.backend.connect("transaction-started", self._on_transaction_started) self.backend.connect("transaction-finished", self._on_transaction_finished) def _on_application_activated(self, tile, app): self.emit("application-activated", app) # we only report on items if the user has opted-in to the # recommendations service if self.recommender_agent.is_opted_in(): self.recommended_apps_viewed.add(app.pkgname) if network_state_is_connected(): # let the recommendations service know that a # recommended item has been viewed (if it is # subsequently installed we will send an additional # signal to indicate that, in on_transaction_finished) # (there is no need to monitor for an error, etc., for this) self.recommender_agent.post_implicit_feedback( app.pkgname, RecommenderFeedbackActions.VIEWED) def _on_transaction_started(self, backend, pkgname, appname, trans_id, trans_type): if (trans_type != TransactionTypes.INSTALL and pkgname in self.recommended_apps_viewed): # if the transaction is not an installation we don't want to # track it as a recommended item self.recommended_apps_viewed.remove(pkgname) def _on_transaction_finished(self, backend, result): if result.pkgname in self.recommended_apps_viewed: self.recommended_apps_viewed.remove(result.pkgname) if network_state_is_connected(): # let the recommendations service know that a # recommended item has been successfully installed # (there is no need to monitor for an error, etc., for this) self.recommender_agent.post_implicit_feedback( result.pkgname, RecommenderFeedbackActions.INSTALLED) def _on_recommender_agent_error(self, agent, msg): LOG.warn("Error while accessing the recommender agent: %s" % msg) # this can happen if: # - there is a real error from the agent # - no cached data is available # hide the panel on an error self.hide() class RecommendationsPanelCategory(RecommendationsPanel): """ Panel for use in the category view that displays recommended apps for the given category """ __gsignals__ = { "more-button-clicked": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, ), ), } def __init__(self, db, properties_helper, subcategory): RecommendationsPanel.__init__(self) self.db = db self.properties_helper = properties_helper self.subcategory = subcategory if self.subcategory: self.set_header_label(GLib.markup_escape_text(utf8( _("Recommended For You in %s")) % utf8(self.subcategory.name))) self.recommended_for_you_content = None if self.recommender_agent.is_opted_in(): self._update_recommended_for_you_content() else: self.hide() def _update_recommended_for_you_content(self): # destroy the old content to ensure we don't see it twice if self.recommended_for_you_content: self.recommended_for_you_content.destroy() # add the new stuff self.recommended_for_you_content = TileGrid() self.recommended_for_you_content.connect( "application-activated", self._on_application_activated) self.add(self.recommended_for_you_content) self.spinner_notebook.show_spinner(_(u"Receiving recommendations…")) # get the recommendations from the recommender agent self.recommended_for_you_cat = RecommendedForYouCategory( self.db, subcategory=self.subcategory) self.recommended_for_you_cat.connect( 'needs-refresh', self._on_recommended_for_you_agent_refresh) self.recommended_for_you_cat.connect('recommender-agent-error', self._on_recommender_agent_error) def _on_recommended_for_you_agent_refresh(self, cat): self.header_implements_more_button() self.more.connect("clicked", self._on_more_button_clicked, self.recommended_for_you_cat) docs = cat.get_documents(self.db) # display the recommendations if len(docs) > 0: self.recommended_for_you_content.add_tiles( self.properties_helper, docs, LOBBY_RECOMMENDATIONS_CAROUSEL_LIMIT) self.recommended_for_you_content.show_all() self.spinner_notebook.hide_spinner() self.header.queue_draw() self.show_all() else: # hide the panel if we have no recommendations to show self.hide() def _on_more_button_clicked(self, btn, category): self.emit("more-button-clicked", category) class RecommendationsPanelLobby(RecommendationsPanelCategory): """ Panel for use in the lobby view that manages the recommendations experience, includes the initial opt-in screen and display of recommendations once they have been received from the recommender agent """ __gsignals__ = { "recommendations-opt-in": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (), ), "recommendations-opt-out": (GObject.SIGNAL_RUN_LAST, GObject.TYPE_NONE, (), ), } TURN_ON_RECOMMENDATIONS_TEXT = _(u"Turn On Recommendations") RECOMMENDATIONS_OPT_IN_TEXT = _(u"To make recommendations, " "Ubuntu Software Center " "will occasionally send to Canonical a list " "of software currently installed.") NO_NETWORK_RECOMMENDATIONS_TEXT = _(u"Recommendations will appear " "when next online.") def __init__(self, db, properties_helper): RecommendationsPanel.__init__(self) self.db = db self.properties_helper = properties_helper self.subcategory = None self.set_header_label(_(u"Recommended For You")) self.recommended_for_you_content = None # .is_opted_in() means either "successfully opted-in" or # "requested opt-in" (but not done yet) if self.recommender_agent.is_opted_in(): if network_state_is_connected(): self._try_sso_login() else: if self.recommender_agent.opt_in_requested: # the user has opted in but has not yet completed the # initial recommender profile upload, therefore there # are no cached values available yet to display self._show_no_network_view() else: # display cached recommendations self._update_recommended_for_you_content() else: self._show_opt_in_view() def _show_opt_in_view(self): # opt in box self.recommended_for_you_content = Gtk.Box.new( Gtk.Orientation.VERTICAL, StockEms.MEDIUM) self.recommended_for_you_content.set_border_width(StockEms.MEDIUM) self.add(self.recommended_for_you_content) # opt in button self.opt_in_button = Gtk.Button(label=_(self.TURN_ON_RECOMMENDATIONS_TEXT)) self.opt_in_button.connect("clicked", self._on_opt_in_button_clicked) hbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 0) hbox.pack_start(self.opt_in_button, False, False, 0) self.recommended_for_you_content.pack_start(hbox, False, False, 0) # opt in text text = _(self.RECOMMENDATIONS_OPT_IN_TEXT) self.opt_in_label = self._create_opt_in_label(text) self.recommended_for_you_content.pack_start(self.opt_in_label, False, False, 0) def _show_no_network_view(self): # display network not available message if not self.recommended_for_you_content: self.recommended_for_you_content = Gtk.Box.new( Gtk.Orientation.VERTICAL, StockEms.MEDIUM) self.recommended_for_you_content.set_border_width(StockEms.MEDIUM) self.add(self.recommended_for_you_content) text = _(self.NO_NETWORK_RECOMMENDATIONS_TEXT) self.opt_in_label = self._create_opt_in_label(text) self.recommended_for_you_content.pack_start(self.opt_in_label, False, False, 0) else: self.opt_in_button.hide() text = _(self.NO_NETWORK_RECOMMENDATIONS_TEXT) self.opt_in_label.set_markup(self.opt_in_label_markup % text) def _create_opt_in_label(self, label_text): opt_in_label = Gtk.Label() opt_in_label.set_use_markup(True) self.opt_in_label_markup = '%s' opt_in_label.set_name("subtle-label") opt_in_label.set_markup(self.opt_in_label_markup % label_text) opt_in_label.set_alignment(0, 0.5) opt_in_label.set_line_wrap(True) return opt_in_label def _on_opt_in_button_clicked(self, button): self.opt_in_to_recommendations_service() def opt_in_to_recommendations_service(self): # first we verify the ubuntu sso login/oath status, and if that is good # we upload the user profile here, and only after this is finished # do we fire the request for recommendations and finally display # them here -- a spinner is shown for this process (the spec # wants a progress bar, but we don't have access to real-time # progress info) if network_state_is_connected(): self._try_sso_login() else: self._show_no_network_view() self.recommender_agent.recommender_opt_in_requested(True) self.emit("recommendations-opt-in") def opt_out_of_recommendations_service(self): # tell the backend that the user has opted out self.recommender_agent.opt_out() # update the UI if self.recommended_for_you_content: self.recommended_for_you_content.destroy() self._show_opt_in_view() self.remove_more_button() self.show_all() self.emit("recommendations-opt-out") self._disconnect_recommender_listeners() def _try_sso_login(self): # display the SSO login dialog if needed # FIXME: consider improving the text in the SSO dialog, for now # we simply reuse the opt-in text from the panel since we # are well past string freeze self.spinner_notebook.show_spinner() self.sso = get_login_backend(get_parent_xid(self), SOFTWARE_CENTER_NAME_KEYRING, self.RECOMMENDATIONS_OPT_IN_TEXT) self.sso.connect("login-successful", self._maybe_login_successful) self.sso.connect("login-failed", self._login_failed) self.sso.connect("login-canceled", self._login_canceled) self.sso.login_or_register() def _maybe_login_successful(self, sso, oauth_result): self.ssoapi = get_ubuntu_sso_backend() self.ssoapi.connect("whoami", self._whoami_done) self.ssoapi.connect("error", self._whoami_error) # this will automatically verify the keyring token and retrigger # login (once) if its expired self.ssoapi.whoami() def _whoami_done(self, ssologin, result): # we are all squared up with SSO login, now we can proceed with the # recommendations display, or the profile upload if this is an # initial opt-in if not self.recommender_agent.recommender_uuid: self._upload_user_profile_and_get_recommendations() if self.recommender_agent.recommender_opt_in_requested: self.recommender_agent.recommender_opt_in_requested(False) else: self._update_recommended_for_you_content() def _whoami_error(self, ssologin, e): self.spinner_notebook.hide_spinner() # FIXME: there is a race condition here if the network state changed # between the call and this check, to fix this properly the # spawn_helper/piston-generic-helper will need to return # better error information though if not network_state_is_connected(): # if there is an error in the SSO whois, first just check if we # have network access and if we do no, just hide the panel self._show_no_network_view() else: # an error that is not network related indicates that the user's # token has likely been revoked or invalidated on the server, for # this case we want to reset the user's opt-in status self.opt_out_of_recommendations_service() def _login_failed(self, sso): # if the user cancels out of the SSO dialog, reset everything to the # opt-in view state self.spinner_notebook.hide_spinner() self.opt_out_of_recommendations_service() def _login_canceled(self, sso): # if the user cancels out of the SSO dialog, reset everything to the # opt-in view state self.spinner_notebook.hide_spinner() self.opt_out_of_recommendations_service() def _upload_user_profile_and_get_recommendations(self): # initiate upload of the user profile here self._upload_user_profile() def _upload_user_profile(self): self.spinner_notebook.show_spinner(_(u"Submitting inventory…")) self.recommender_agent.connect("submit-profile-finished", self._on_profile_submitted) self.recommender_agent.connect("error", self._on_profile_submitted_error) self.recommender_agent.post_submit_profile(self.db) def _on_profile_submitted(self, agent, profile): # after the user profile data has been uploaded, make the request # and load the the recommended_for_you content LOG.debug("The updated profile was successfully submitted to the " "recommender service") # only detect the very first profile upload as that indicates # the user's initial opt-in self._update_recommended_for_you_content() self._disconnect_recommender_listeners() self.emit("recommendations-opt-in") def _on_profile_submitted_error(self, agent, msg): LOG.warn("Error while submitting the recommendations profile to the " "recommender agent: %s" % msg) # TODO: handle this! display an error message in the panel # detect the very first profile upload as that indicates # the user's initial opt-in self._disconnect_recommender_listeners() self.hide() def _disconnect_recommender_listeners(self): try: self.recommender_agent.disconnect_by_func( self._on_profile_submitted) self.recommender_agent.disconnect_by_func( self._on_profile_submitted_error) except TypeError: pass class RecommendationsPanelDetails(RecommendationsPanel): """ Panel for use in the details view to display recommendations for a given application """ def __init__(self, db, properties_helper): RecommendationsPanel.__init__(self) self.db = db self.properties_helper = properties_helper self.set_header_label(_(u"People Also Installed")) self.app_recommendations_content = TileGrid() self.app_recommendations_content.connect( "application-activated", self._on_application_activated) self.add(self.app_recommendations_content) def set_pkgname(self, pkgname): self.pkgname = pkgname self._update_app_recommendations_content() def _update_app_recommendations_content(self): if self.app_recommendations_content: self.app_recommendations_content.remove_all() self.spinner_notebook.show_spinner(_(u"Receiving recommendations…")) # get the recommendations from the recommender agent self.app_recommendations_cat = AppRecommendationsCategory( self.db, self.pkgname) self.app_recommendations_cat.connect( 'needs-refresh', self._on_app_recommendations_agent_refresh) self.app_recommendations_cat.connect('recommender-agent-error', self._on_recommender_agent_error) def _on_app_recommendations_agent_refresh(self, cat): docs = cat.get_documents(self.db) # display the recommendations if len(docs) > 0: self.app_recommendations_content.add_tiles( self.properties_helper, docs, DETAILS_RECOMMENDATIONS_CAROUSEL_LIMIT) self.show_all() self.spinner_notebook.hide_spinner() else: self.hide() class RecommendationsOptInDialog(Gtk.MessageDialog): """ Dialog to display the recommendations opt-in message when opt-in is initiated from the menu. """ def __init__(self, icons): Gtk.MessageDialog.__init__(self, flags=Gtk.DialogFlags.MODAL, type=Gtk.MessageType.INFO) self.set_title("") icon_name = "softwarecenter" if icons.has_icon(icon_name): icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DIALOG) self.set_image(icon) icon.show() self.format_secondary_text( _(RecommendationsPanelLobby.RECOMMENDATIONS_OPT_IN_TEXT)) self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_button( _(RecommendationsPanelLobby.TURN_ON_RECOMMENDATIONS_TEXT), Gtk.ResponseType.YES) self.set_default_response(Gtk.ResponseType.YES)