~osomon/software-center/qml

« back to all changes in this revision

Viewing changes to softwarecenter/backend/reviews/__init__.py

  • Committer: Olivier Tilloy
  • Date: 2011-11-04 15:35:16 UTC
  • mfrom: (1773.49.656 software-center)
  • Revision ID: olivier@tilloy.net-20111104153516-np83cyxd806bt71p
Merged the latest changes from trunk.

Show diffs side-by-side

added added

removed removed

Lines of Context:
53
53
    from StringIO import StringIO
54
54
    from urllib import quote_plus
55
55
 
56
 
from softwarecenter.backend.piston.rnrclient import RatingsAndReviewsAPI
57
 
from softwarecenter.backend.piston.rnrclient_pristine import ReviewDetails
58
56
from softwarecenter.db.categories import CategoriesParser
59
57
from softwarecenter.db.database import Application, StoreDatabase
60
58
import softwarecenter.distro
77
75
 
78
76
from softwarecenter.netstatus import network_state_is_connected
79
77
 
80
 
from spawn_helper import SpawnHelper
 
78
from softwarecenter.backend.spawn_helper import SpawnHelper
81
79
 
82
80
LOG = logging.getLogger(__name__)
83
81
 
100
98
    USEFULNESS_CACHE = {}
101
99
    
102
100
    def __init__(self, try_server=False):
103
 
        self.rnrclient = RatingsAndReviewsAPI()
104
101
        fname = "usefulness.p"
105
102
        self.USEFULNESS_CACHE_FILE = os.path.join(SOFTWARE_CENTER_CACHE_DIR,
106
103
                                                    fname)
438
435
            applist.append(db.get_pkgname(doc))
439
436
        return applist
440
437
 
441
 
    # writing new reviews spawns external helper
442
 
    # FIXME: instead of the callback we should add proper gobject signals
443
438
    def spawn_write_new_review_ui(self, translated_app, version, iconname, 
444
439
                                  origin, parent_xid, datadir, callback):
445
 
        """ this spawns the UI for writing a new review and
446
 
            adds it automatically to the reviews DB """
447
 
        app = translated_app.get_untranslated_app(self.db)
448
 
        cmd = [os.path.join(datadir, RNRApps.SUBMIT_REVIEW), 
449
 
               "--pkgname", app.pkgname,
450
 
               "--iconname", iconname,
451
 
               "--parent-xid", "%s" % parent_xid,
452
 
               "--version", version,
453
 
               "--origin", origin,
454
 
               "--datadir", datadir,
455
 
               ]
456
 
        if app.appname:
457
 
            # needs to be (utf8 encoded) str, otherwise call fails
458
 
            cmd += ["--appname", utf8(app.appname)]
459
 
        spawn_helper = SpawnHelper(format="json")
460
 
        spawn_helper.connect(
461
 
            "data-available", self._on_submit_review_data, app, callback)
462
 
        spawn_helper.run(cmd)
463
 
 
464
 
    def _on_submit_review_data(self, spawn_helper, review_json, app, callback):
465
 
        """ called when submit_review finished, when the review was send
466
 
            successfully the callback is triggered with the new reviews
 
440
        """Spawn the UI for writing a new review and adds it automatically
 
441
        to the reviews DB.
467
442
        """
468
 
        LOG.debug("_on_submit_review_data")
469
 
        # read stdout from submit_review
470
 
        review = ReviewDetails.from_dict(review_json)
471
 
        # FIXME: ideally this would be stored in ubuntu-sso-client
472
 
        #        but it dosn't so we store it here
473
 
        save_person_to_config(review.reviewer_username)
474
 
        if not app in self._reviews: 
475
 
            self._reviews[app] = []
476
 
        self._reviews[app].insert(0, Review.from_piston_mini_client(review))
477
 
        callback(app, self._reviews[app])
 
443
        pass
478
444
 
479
445
    def spawn_report_abuse_ui(self, review_id, parent_xid, datadir, callback):
480
446
        """ this spawns the UI for reporting a review as inappropriate
482
448
            operation is complete it will call callback with the updated
483
449
            review list
484
450
        """
485
 
        cmd = [os.path.join(datadir, RNRApps.REPORT_REVIEW), 
486
 
               "--review-id", review_id,
487
 
               "--parent-xid", "%s" % parent_xid,
488
 
               "--datadir", datadir,
489
 
              ]
490
 
        spawn_helper = SpawnHelper("json")
491
 
        spawn_helper.connect("exited", 
492
 
                             self._on_report_abuse_finished, 
493
 
                             review_id, callback)
494
 
        spawn_helper.run(cmd)
495
 
 
496
 
    def _on_report_abuse_finished(self, spawn_helper, exitcode, review_id, callback):
497
 
        """ called when report_absuse finished """
498
 
        LOG.debug("hide id %s " % review_id)
499
 
        if exitcode == 0:
500
 
            for (app, reviews) in self._reviews.items():
501
 
                for review in reviews:
502
 
                    if str(review.id) == str(review_id):
503
 
                        # remove the one we don't want to see anymore
504
 
                        self._reviews[app].remove(review)
505
 
                        callback(app, self._reviews[app], None, 'remove', review)
506
 
                        break
507
 
 
 
451
        pass
508
452
 
509
453
    def spawn_submit_usefulness_ui(self, review_id, is_useful, parent_xid, datadir, callback):
510
 
        cmd = [os.path.join(datadir, RNRApps.SUBMIT_USEFULNESS), 
511
 
               "--review-id", "%s" % review_id,
512
 
               "--is-useful", "%s" % int(is_useful),
513
 
               "--parent-xid", "%s" % parent_xid,
514
 
               "--datadir", datadir,
515
 
              ]
516
 
        spawn_helper = SpawnHelper(format="none")
517
 
        spawn_helper.connect("exited", 
518
 
                             self._on_submit_usefulness_finished, 
519
 
                             review_id, is_useful, callback)
520
 
        spawn_helper.connect("error",
521
 
                             self._on_submit_usefulness_error,
522
 
                             review_id, callback)
523
 
        spawn_helper.run(cmd)
524
 
 
525
 
    def _on_submit_usefulness_finished(self, spawn_helper, res, review_id, is_useful, callback):
526
 
        """ called when report_usefulness finished """
527
 
        # "Created", "Updated", "Not modified" - 
528
 
        # once lp:~mvo/rnr-server/submit-usefulness-result-strings makes it
529
 
        response = spawn_helper._stdout
530
 
        if response == '"Not modified"':
531
 
            self._on_submit_usefulness_error(spawn_helper, response, review_id, callback)
532
 
            return
533
 
 
534
 
        LOG.debug("usefulness id %s " % review_id)
535
 
        useful_votes = UsefulnessCache()
536
 
        useful_votes.add_usefulness_vote(review_id, is_useful)
537
 
        for (app, reviews) in self._reviews.items():
538
 
            for review in reviews:
539
 
                if str(review.id) == str(review_id):
540
 
                    # update usefulness, older servers do not send
541
 
                    # usefulness_{total,favorable} so we use getattr
542
 
                    review.usefulness_total = getattr(review, "usefulness_total", 0) + 1
543
 
                    if is_useful:
544
 
                        review.usefulness_favorable = getattr(review, "usefulness_favorable", 0) + 1
545
 
                        callback(app, self._reviews[app], useful_votes, 'replace', review)
546
 
                        break
547
 
 
548
 
    def _on_submit_usefulness_error(self, spawn_helper, error_str, review_id, callback):
549
 
            LOG.warn("submit usefulness id=%s failed with error: %s" %
550
 
                     (review_id, error_str))
551
 
            for (app, reviews) in self._reviews.items():
552
 
                for review in reviews:
553
 
                    if str(review.id) == str(review_id):
554
 
                        review.usefulness_submit_error = True
555
 
                        callback(app, self._reviews[app], None, 'replace', review)
556
 
                        break
 
454
        """Spawn a helper to submit a usefulness vote."""
 
455
        pass
557
456
 
558
457
    def spawn_delete_review_ui(self, review_id, parent_xid, datadir, callback):
559
 
        cmd = [os.path.join(datadir, RNRApps.DELETE_REVIEW), 
560
 
               "--review-id", "%s" % review_id,
561
 
               "--parent-xid", "%s" % parent_xid,
562
 
               "--datadir", datadir,
563
 
              ]
564
 
        spawn_helper = SpawnHelper(format="none")
565
 
        spawn_helper.connect("exited", 
566
 
                             self._on_delete_review_finished, 
567
 
                             review_id, callback)
568
 
        spawn_helper.connect("error", self._on_delete_review_error,
569
 
                             review_id, callback)
570
 
        spawn_helper.run(cmd)
571
 
 
572
 
    def _on_delete_review_finished(self, spawn_helper, res, review_id, callback):
573
 
        """ called when delete_review finished"""
574
 
        LOG.debug("delete id %s " % review_id)
575
 
        for (app, reviews) in self._reviews.items():
576
 
            for review in reviews:
577
 
                if str(review.id) == str(review_id):
578
 
                    # remove the one we don't want to see anymore
579
 
                    self._reviews[app].remove(review)
580
 
                    callback(app, self._reviews[app], None, 'remove', review)
581
 
                    break                    
582
 
 
583
 
    def _on_delete_review_error(self, spawn_helper, error_str, review_id, callback):
584
 
        """called if delete review errors"""
585
 
        LOG.warn("delete review id=%s failed with error: %s" % (review_id, error_str))
586
 
        for (app, reviews) in self._reviews.items():
587
 
            for review in reviews:
588
 
                if str(review.id) == str(review_id):
589
 
                    review.delete_error = True
590
 
                    callback(app, self._reviews[app], action='replace', 
591
 
                             single_review=review)
592
 
                    break
593
 
 
594
 
    
 
458
        """Spawn a helper to delete a review."""
 
459
        pass
 
460
 
595
461
    def spawn_modify_review_ui(self, parent_xid, iconname, datadir, review_id, callback):
596
 
        """ this spawns the UI for writing a new review and
597
 
            adds it automatically to the reviews DB """
598
 
        cmd = [os.path.join(datadir, RNRApps.MODIFY_REVIEW), 
599
 
               "--parent-xid", "%s" % parent_xid,
600
 
               "--iconname", iconname,
601
 
               "--datadir", "%s" % datadir,
602
 
               "--review-id", "%s" % review_id,
603
 
               ]
604
 
        spawn_helper = SpawnHelper(format="json")
605
 
        spawn_helper.connect("data-available", 
606
 
                             self._on_modify_review_finished, 
607
 
                             review_id, callback)
608
 
        spawn_helper.connect("error", self._on_modify_review_error,
609
 
                             review_id, callback)
610
 
        spawn_helper.run(cmd)
611
 
 
612
 
    def _on_modify_review_finished(self, spawn_helper, review_json, review_id, callback):
613
 
        """called when modify_review finished"""
614
 
        LOG.debug("_on_modify_review_finished")
615
 
        #review_json = spawn_helper._stdout
616
 
        mod_review = ReviewDetails.from_dict(review_json)
617
 
        for (app, reviews) in self._reviews.items():
618
 
            for review in reviews:
619
 
                if str(review.id) == str(review_id):
620
 
                    # remove the one we don't want to see anymore
621
 
                    self._reviews[app].remove(review)
622
 
                    new_review = Review.from_piston_mini_client(mod_review)
623
 
                    self._reviews[app].insert(0, new_review)
624
 
                    callback(app, self._reviews[app], action='replace', 
625
 
                             single_review=new_review)
626
 
                    break
627
 
                    
628
 
    def _on_modify_review_error(self, spawn_helper, error_str, review_id, callback):
629
 
        """called if modify review errors"""
630
 
        LOG.debug("modify review id=%s failed with error: %s" % (review_id, error_str))
631
 
        for (app, reviews) in self._reviews.items():
632
 
            for review in reviews:
633
 
                if str(review.id) == str(review_id):
634
 
                    review.modify_error = True
635
 
                    callback(app, self._reviews[app], action='replace', 
636
 
                             single_review=review)
637
 
                    break
638
 
 
639
 
 
640
 
# this code had several incernations: 
641
 
# - python threads, slow and full of latency (GIL)
642
 
# - python multiprocesing, crashed when accessibility was turned on, 
643
 
#                          does not work in the quest session (#743020)
644
 
# - GObject.spawn_async() looks good so far (using the SpawnHelper code)
645
 
class ReviewLoaderSpawningRNRClient(ReviewLoader):
646
 
    """ loader that uses multiprocessing to call rnrclient and
647
 
        a glib timeout watcher that polls periodically for the
648
 
        data 
649
 
    """
650
 
 
651
 
    def __init__(self, cache, db, distro=None):
652
 
        super(ReviewLoaderSpawningRNRClient, self).__init__(cache, db, distro)
653
 
        cachedir = os.path.join(SOFTWARE_CENTER_CACHE_DIR, "rnrclient")
654
 
        self.rnrclient = RatingsAndReviewsAPI(cachedir=cachedir)
655
 
        cachedir = os.path.join(SOFTWARE_CENTER_CACHE_DIR, "rnrclient")
656
 
        self.rnrclient = RatingsAndReviewsAPI(cachedir=cachedir)
657
 
        self._reviews = {}
658
 
 
659
 
    def _update_rnrclient_offline_state(self):
660
 
        # this needs the lp:~mvo/piston-mini-client/offline-mode branch
661
 
        self.rnrclient._offline_mode = not network_state_is_connected()
662
 
 
663
 
    # reviews
664
 
    def get_reviews(self, translated_app, callback, page=1, 
665
 
                    language=None, sort=0):
666
 
        """ public api, triggers fetching a review and calls callback
667
 
            when its ready
668
 
        """
669
 
        # its fine to use the translated appname here, we only submit the
670
 
        # pkgname to the server
671
 
        app = translated_app
672
 
        self._update_rnrclient_offline_state()
673
 
        sort_method = self._review_sort_methods[sort]
674
 
        if language is None:
675
 
            language = self.language
676
 
        # gather args for the helper
677
 
        try:
678
 
            origin = self.cache.get_origin(app.pkgname)
679
 
        except:
680
 
            # this can happen if e.g. the app has multiple origins, this
681
 
            # will be handled later
682
 
            origin = None
683
 
        # special case for not-enabled PPAs
684
 
        if not origin and self.db:
685
 
            details = app.get_details(self.db)
686
 
            ppa = details.ppaname
687
 
            if ppa:
688
 
                origin = "lp-ppa-%s" % ppa.replace("/", "-")
689
 
        # if there is no origin, there is nothing to do
690
 
        if not origin:
691
 
            callback(app, [])
692
 
            return
693
 
        distroseries = self.distro.get_codename()
694
 
        # run the command and add watcher
695
 
        cmd = [os.path.join(softwarecenter.paths.datadir, PistonHelpers.GET_REVIEWS),
696
 
               "--language", language, 
697
 
               "--origin", origin, 
698
 
               "--distroseries", distroseries, 
699
 
               "--pkgname", str(app.pkgname), # ensure its str, not unicode
700
 
               "--page", str(page),
701
 
               "--sort", sort_method,
702
 
              ]
703
 
        spawn_helper = SpawnHelper()
704
 
        spawn_helper.connect(
705
 
            "data-available", self._on_reviews_helper_data, app, callback)
706
 
        spawn_helper.run(cmd)
707
 
 
708
 
    def _on_reviews_helper_data(self, spawn_helper, piston_reviews, app, callback):
709
 
        # convert into our review objects
710
 
        reviews = []
711
 
        for r in piston_reviews:
712
 
            reviews.append(Review.from_piston_mini_client(r))
713
 
        # add to our dicts and run callback
714
 
        self._reviews[app] = reviews
715
 
        callback(app, self._reviews[app])
716
 
        return False
717
 
 
718
 
    # stats
719
 
    def refresh_review_stats(self, callback):
720
 
        """ public api, refresh the available statistics """
721
 
        try:
722
 
            mtime = os.path.getmtime(self.REVIEW_STATS_CACHE_FILE)
723
 
            days_delta = int((time.time() - mtime) // (24*60*60))
724
 
            days_delta += 1
725
 
        except OSError:
726
 
            days_delta = 0
727
 
        LOG.debug("refresh with days_delta: %s" % days_delta)
728
 
        #origin = "any"
729
 
        #distroseries = self.distro.get_codename()
730
 
        cmd = [os.path.join(
731
 
                softwarecenter.paths.datadir, PistonHelpers.GET_REVIEW_STATS),
732
 
               # FIXME: the server currently has bug (#757695) so we
733
 
               #        can not turn this on just yet and need to use
734
 
               #        the old "catch-all" review-stats for now
735
 
               #"--origin", origin, 
736
 
               #"--distroseries", distroseries, 
737
 
              ]
738
 
        if days_delta:
739
 
            cmd += ["--days-delta", str(days_delta)]
740
 
        spawn_helper = SpawnHelper()
741
 
        spawn_helper.connect("data-available", self._on_review_stats_data, callback)
742
 
        spawn_helper.run(cmd)
743
 
 
744
 
    def _on_review_stats_data(self, spawn_helper, piston_review_stats, callback):
745
 
        """ process stdout from the helper """
746
 
        review_stats = self.REVIEW_STATS_CACHE
747
 
 
748
 
        if self._cache_version_old and self._server_has_histogram(piston_review_stats):
749
 
            self.REVIEW_STATS_CACHE = {}
750
 
            self.save_review_stats_cache_file()
751
 
            self.refresh_review_stats(callback)
752
 
            return
753
 
        
754
 
        # convert to the format that s-c uses
755
 
        for r in piston_review_stats:
756
 
            s = ReviewStats(Application("", r.package_name))
757
 
            s.ratings_average = float(r.ratings_average)
758
 
            s.ratings_total = float(r.ratings_total)
759
 
            if r.histogram:
760
 
                s.rating_spread = json.loads(r.histogram)
761
 
            else:
762
 
                s.rating_spread = [0,0,0,0,0]
763
 
            s.dampened_rating = calc_dr(s.rating_spread)
764
 
            review_stats[s.app] = s
765
 
        self.REVIEW_STATS_CACHE = review_stats
766
 
        callback(review_stats)
767
 
        self.save_review_stats_cache_file()
768
 
    
769
 
    def _server_has_histogram(self, piston_review_stats):
770
 
        '''check response from server to see if histogram is supported'''
771
 
        supported = getattr(piston_review_stats[0], "histogram", False)
772
 
        if not supported:
773
 
            return False
774
 
        return True
775
 
 
776
 
class ReviewLoaderJsonAsync(ReviewLoader):
777
 
    """ get json (or gzip compressed json) """
778
 
 
779
 
    def _gio_review_download_complete_callback(self, source, result):
780
 
        app = source.get_data("app")
781
 
        callback = source.get_data("callback")
782
 
        try:
783
 
            (success, json_str, etag) = source.load_contents_finish(result)
784
 
        except GObject.GError:
785
 
            # ignore read errors, most likely transient
786
 
            return callback(app, [])
787
 
        # check for gzip header
788
 
        if json_str.startswith("\37\213"):
789
 
            gz=gzip.GzipFile(fileobj=StringIO(json_str))
790
 
            json_str = gz.read()
791
 
        reviews_json = json.loads(json_str)
792
 
        reviews = []
793
 
        for review_json in reviews_json:
794
 
            review = Review.from_json(review_json)
795
 
            reviews.append(review)
796
 
        # run callback
797
 
        callback(app, reviews)
798
 
 
799
 
    def get_reviews(self, app, callback, page=1, language=None, sort=0):
800
 
        """ get a specific review and call callback when its available"""
801
 
        # FIXME: get this from the app details
802
 
        origin = self.cache.get_origin(app.pkgname)
803
 
        distroseries = self.distro.get_codename()
804
 
        if app.appname:
805
 
            appname = ";"+app.appname
806
 
        else:
807
 
            appname = ""
808
 
 
809
 
        sort_method = self._review_sort_methods[sort]
810
 
        
811
 
        url = self.distro.REVIEWS_URL % { 'pkgname' : app.pkgname,
812
 
                                          'appname' : quote_plus(appname.encode("utf-8")),
813
 
                                          'language' : self.language,
814
 
                                          'origin' : origin,
815
 
                                          'distroseries' : distroseries,
816
 
                                          'version' : 'any',
817
 
                                          'sort' : sort_method,
818
 
                                         }
819
 
        LOG.debug("looking for review at '%s'" % url)
820
 
        f=Gio.File.new_for_uri(url)
821
 
        f.set_data("app", app)
822
 
        f.set_data("callback", callback)
823
 
        f.load_contents_async(self._gio_review_download_complete_callback)
824
 
 
825
 
    # review stats code
826
 
    def _gio_review_stats_download_finished_callback(self, source, result):
827
 
        callback = source.get_data("callback")
828
 
        try:
829
 
            (json_str, length, etag) = source.load_contents_finish(result)
830
 
        except GObject.GError:
831
 
            # ignore read errors, most likely transient
832
 
            return
833
 
        # check for gzip header
834
 
        if json_str.startswith("\37\213"):
835
 
            gz=gzip.GzipFile(fileobj=StringIO(json_str))
836
 
            json_str = gz.read()
837
 
        review_stats_json = json.loads(json_str)
838
 
        review_stats = {}
839
 
        for review_stat_json in review_stats_json:
840
 
            #appname = review_stat_json["app_name"]
841
 
            pkgname = review_stat_json["package_name"]
842
 
            app = Application('', pkgname)
843
 
            stats = ReviewStats(app)
844
 
            stats.ratings_total = int(review_stat_json["ratings_total"])
845
 
            stats.ratings_average = float(review_stat_json["ratings_average"])
846
 
            review_stats[app] = stats
847
 
        # update review_stats dict
848
 
        self.REVIEW_STATS_CACHE = review_stats
849
 
        self.save_review_stats_cache_file()
850
 
        # run callback
851
 
        callback(review_stats)
852
 
 
853
 
    def refresh_review_stats(self, callback):
854
 
        """ get the review statists and call callback when its there """
855
 
        f=Gio.File(self.distro.REVIEW_STATS_URL)
856
 
        f.set_data("callback", callback)
857
 
        f.load_contents_async(self._gio_review_stats_download_finished_callback)
 
462
        """Spawn a helper to modify a review."""
 
463
        pass
 
464
 
858
465
 
859
466
class ReviewLoaderFake(ReviewLoader):
860
467
 
871
478
        return random.choice(self.LOREM.split("\n\n"))
872
479
    def _random_summary(self):
873
480
        return random.choice(self.SUMMARIES)
874
 
    def get_reviews(self, application, callback, page=1, language=None):
 
481
    def get_reviews(self, application, callback, page=1, language=None, sort=0):
875
482
        if not application in self._review_stats_cache:
876
483
            self.get_review_stats(application)
877
484
        stats = self._review_stats_cache[application]
1034
641
et ea rebum stet clita kasd gubergren no sea takimata sanctus est lorem
1035
642
ipsum dolor sit amet"""
1036
643
 
 
644
 
 
645
class ReviewLoaderNull(ReviewLoader):
 
646
 
 
647
    """A dummy review loader which just returns empty results."""
 
648
 
 
649
    def __init__(self, cache, db):
 
650
        self._review_stats_cache = {}
 
651
        self._reviews_cache = {}
 
652
 
 
653
    def get_reviews(self, application, callback, page=1, language=None, sort=0):
 
654
        callback(application, [])
 
655
 
 
656
    def get_review_stats(self, application):
 
657
        return None
 
658
 
 
659
    def refresh_review_stats(self, callback):
 
660
        review_stats = []
 
661
        callback(review_stats)
 
662
 
 
663
 
1037
664
review_loader = None
1038
665
def get_review_loader(cache, db=None):
1039
666
    """ 
1050
677
        elif "SOFTWARE_CENTER_GIO_REVIEWS" in os.environ:
1051
678
            review_loader = ReviewLoaderJsonAsync(cache, db)
1052
679
        else:
1053
 
            review_loader = ReviewLoaderSpawningRNRClient(cache, db)
 
680
            try:
 
681
                from softwarecenter.backend.reviews.rnr import ReviewLoaderSpawningRNRClient
 
682
            except ImportError:
 
683
                review_loader = ReviewLoaderNull(cache, db)
 
684
            else:
 
685
                review_loader = ReviewLoaderSpawningRNRClient(cache, db)
1054
686
    return review_loader
1055
687
 
1056
688
if __name__ == "__main__":