source: voteplugin/trunk/tracvote/__init__.py @ 16141

Last change on this file since 16141 was 16141, checked in by Ryan J Ollos, 7 years ago

0.6.0dev: Use DatabaseManager methods in environment upgrade

Fixes #13010.

File size: 22.9 KB
Line 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2008 Alec Thomas <alec@swapoff.org>
4# Copyright (C) 2009 Noah Kantrowitz <noah@coderanger.net>
5# Copyright (C) 2009 Jeff Hammel <jhammel@openplans.org>
6# Copyright (C) 2010-2015 Ryan J Ollos <ryan.j.ollos@gmail.com>
7# Copyright (C) 2013-2015 Steffen Hoffmann <hoff.st@web.de>
8# All rights reserved.
9#
10# This software is licensed as described in the file COPYING, which
11# you should have received as part of this distribution.
12
13import pkg_resources
14import re
15from fnmatch import fnmatchcase
16from functools import partial
17
18from datetime import datetime
19
20from genshi import Markup
21from genshi.builder import tag
22from pkg_resources import resource_filename
23
24from trac.config import ListOption
25from trac.core import Component, TracError, implements
26from trac.db import DatabaseManager, Table, Column
27from trac.env import IEnvironmentSetupParticipant
28from trac.perm import IPermissionRequestor
29from trac.resource import Resource, ResourceSystem, get_resource_description
30from trac.resource import get_resource_url, resource_exists
31from trac.ticket.api import IMilestoneChangeListener
32from trac.util import as_int, get_reporter_id
33from trac.util.datefmt import format_datetime, to_datetime, to_utimestamp, utc
34from trac.util.text import to_unicode
35from trac.util.translation import domain_functions
36from trac.web.api import IRequestFilter, IRequestHandler
37from trac.web.chrome import Chrome, ITemplateProvider
38from trac.web.chrome import add_notice, add_script, add_stylesheet
39from trac.wiki.api import IWikiChangeListener, IWikiMacroProvider, parse_args
40
41import tracvote.compat
42
43_, add_domain, tag_ = domain_functions('tracvote', ('_', 'add_domain', 'tag_'))
44
45pkg_resources.require('Trac >= 1.0')
46
47
48def get_versioned_resource(env, resource):
49    """Find the current version for a Trac resource.
50
51    Because versioned resources with no version value default to 'latest',
52    the current version has to be retrieved separately.
53    """
54    realm = resource.realm
55    if realm == 'ticket':
56        for count, in env.db_query("""
57                SELECT COUNT(DISTINCT time)
58                FROM ticket_change WHERE ticket=%s
59                """, (resource.id,)):
60            if count != 0:
61                resource.version = count
62    elif realm == 'wiki':
63        for version, in env.db_query("""
64                SELECT version
65                  FROM wiki
66                 WHERE name=%s
67                 ORDER BY version DESC LIMIT 1
68                """, (resource.id,)):
69            resource.version = version
70    return resource
71
72
73def _resource_exists(env, resource):
74    """Avoid exception in database for Trac < 1.0.7.
75    http://trac.edgewall.org/ticket/12076
76    """
77    try:
78        return resource_exists(env, resource)
79    except env.db_exc.DatabaseError:
80        return False
81
82
83def resource_from_path(env, path):
84    """Find realm and resource ID from resource URL.
85
86    Assuming simple resource paths to convert to Trac resource identifiers.
87    """
88    if isinstance(path, basestring):
89        path = path.strip('/')
90        # Special-case: Default TracWiki start page.
91        if path == 'wiki':
92            path += '/WikiStart'
93    for realm in ResourceSystem(env).get_known_realms():
94        if path.startswith(realm):
95            resource_id = re.sub(realm, '', path, 1).lstrip('/')
96            resource = Resource(realm, resource_id)
97            if _resource_exists(env, resource) in (None, True):
98                return get_versioned_resource(env, resource)
99
100
101class VoteSystem(Component):
102    """Allow up- and down-voting on Trac resources."""
103
104    def __init__(self):
105        """Set up translation domain"""
106        try:
107            locale_dir = resource_filename(__name__, 'locale')
108        except KeyError:
109            pass
110        else:
111            add_domain(self.env.path, locale_dir)
112
113    implements(IEnvironmentSetupParticipant,
114               IMilestoneChangeListener,
115               IPermissionRequestor,
116               IRequestFilter,
117               IRequestHandler,
118               ITemplateProvider,
119               IWikiChangeListener,
120               IWikiMacroProvider)
121
122    image_map = {-1: ('aupgray.png', 'adownmod.png'),
123                  0: ('aupgray.png', 'adowngray.png'),
124                 +1: ('aupmod.png', 'adowngray.png')}
125
126    path_re = re.compile(r'/vote/(up|down)/(.*)')
127
128    schema = [
129        Table('votes', key=('realm', 'resource_id', 'username', 'vote'))[
130            Column('realm'),
131            Column('resource_id'),
132            Column('version', 'int'),
133            Column('username'),
134            Column('vote', 'int'),
135            Column('time', type='int64'),
136            Column('changetime', type='int64'),
137            ]
138        ]
139    # Database schema version identifier, used for automatic upgrades.
140    schema_version = 2
141    schema_version_key = 'vote_version'
142
143    # Default database values
144    # (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2)))
145    db_data = [
146        ('permission',
147            ('username', 'action'),
148                (('anonymous', 'VOTE_VIEW'),
149                 ('authenticated', 'VOTE_MODIFY'))),
150    ]
151
152    voteable_paths = ListOption('vote', 'paths', '/ticket*,/wiki*',
153        doc="List of URL paths to allow voting on. Globs are supported.",
154        doc_domain='tracvote')
155
156    # Public methods
157
158    def get_top_voted(self, req, realm=None, top=0):
159        """Return resources ordered top-down by vote count."""
160        args = []
161        if realm:
162            args.append(realm)
163        if top:
164            args.append(top)
165        for row in self.env.db_query("""
166                SELECT realm,resource_id,SUM(vote) AS count
167                  FROM votes%s
168                 GROUP by realm,resource_id
169                 ORDER by count DESC,resource_id%s
170                """ % (' WHERE realm=%s' if realm else '',
171                       ' LIMIT %s' if top else ''), args):
172            yield row
173
174    def get_vote_counts(self, resource):
175        """Get negative, total and positive vote counts and return them in a
176        tuple.
177        """
178        with self.env.db_query as db:
179            total = negative = positive = 0
180            for sum_vote, in db("""
181                    SELECT sum(vote)
182                      FROM votes
183                     WHERE realm=%s
184                       AND resource_id=%s
185                    """, (resource.realm, resource.id)):
186                total = sum_vote
187            for sum_vote, in db("""
188                    SELECT sum(vote)
189                      FROM votes
190                     WHERE vote < 0
191                       AND realm=%s
192                       AND resource_id=%s
193                    """, (resource.realm, resource.id)):
194                negative = sum_vote
195            for sum_vote, in db("""
196                    SELECT sum(vote)
197                      FROM votes
198                     WHERE vote > 0
199                       AND realm=%s
200                       AND resource_id=%s
201                    """, (resource.realm, to_unicode(resource.id))):
202                positive = sum_vote
203            return negative or 0, total or 0, positive or 0
204
205    def get_vote(self, req, resource):
206        """Return the current users vote for a resource."""
207        for vote, in self.env.db_query("""
208                SELECT vote
209                  FROM votes
210                 WHERE username=%s
211                   AND realm=%s
212                   AND resource_id=%s
213                """, (get_reporter_id(req), resource.realm,
214                      to_unicode(resource.id))):
215            return vote
216
217    def set_vote(self, req, resource, vote):
218        """Vote for a resource."""
219        now_ts = to_utimestamp(datetime.now(utc))
220        args = [now_ts, resource.version, vote, get_reporter_id(req),
221                resource.realm, to_unicode(resource.id)]
222        if not resource.version:
223            args.pop(1)
224        with self.env.db_transaction as db:
225            db("""
226                UPDATE votes
227                   SET changetime=%%s%s,vote=%%s
228                 WHERE username=%%s
229                   AND realm=%%s
230                   AND resource_id=%%s
231            """ % (',version=%s' if resource.version else ''), args)
232            if self.get_vote(req, resource) is None:
233                db("""
234                    INSERT INTO votes
235                      (realm,resource_id,version,username,vote,
236                       time,changetime)
237                    VALUES (%s,%s,%s,%s,%s,%s,%s)
238                """, (resource.realm, to_unicode(resource.id),
239                      resource.version, get_reporter_id(req), vote,
240                      now_ts, now_ts))
241
242    def reparent_votes(self, resource, old_id):
243        """Update resource reference of votes on a renamed resource."""
244        self.env.db_transaction("""
245            UPDATE votes
246               SET resource_id=%s
247             WHERE realm=%s
248               AND resource_id=%s
249            """, (to_unicode(resource.id), resource.realm,
250                  to_unicode(old_id)))
251
252    def delete_votes(self, resource):
253        """Delete votes for a resource."""
254        args = list((resource.realm, to_unicode(resource.id)))
255        if resource.version:
256            args.append(resource.version)
257        self.env.db_transaction("""
258            DELETE
259              FROM votes
260             WHERE realm=%%s
261               AND resource_id=%%s%s
262            """ % (' AND version=%s' if resource.version else ''), args)
263
264    def get_votes(self, req, resource=None, top=0):
265        """Return most recent votes, optionally only for one resource."""
266        args = [resource.realm, to_unicode(resource.id)] if resource else []
267        if top:
268            args.append(top)
269        for row in self.env.db_query("""
270            SELECT realm,resource_id,vote,username,changetime
271              FROM votes
272             WHERE changetime is not NULL%s
273             ORDER BY changetime DESC%s
274            """ % (' AND realm=%s AND resource_id=%s' if resource else '',
275                   ' LIMIT %s' if top else ''), args):
276            yield row
277
278    def get_total_vote_count(self, realm):
279        """Return the total vote count for a realm, like 'ticket'."""
280        with self.env.db_query as db:
281            total = negative = positive = 0
282            for sum_vote, in db(
283                    'SELECT sum(vote) FROM votes WHERE resource LIKE %s',
284                    (realm + '%',)):
285                total = sum_vote
286            for sum_vote, in db("""
287                    SELECT sum(vote)
288                      FROM votes
289                     WHERE vote < 0
290                       AND resource LIKE %s
291                    """, (realm + '%',)):
292                negative = sum_vote
293            for sum_vote, in db("""
294                    SELECT sum(vote)
295                      FROM votes
296                     WHERE vote > 0
297                       AND resource=%s
298                    """, (realm + '%',)):
299                positive = sum_vote
300            return negative, total, positive
301
302    def get_realm_votes(self, realm):
303        """Return a dictionary of vote count for a realm."""
304        resources = set()
305        for i, in self.env.db_query(
306                'SELECT resource FROM votes WHERE resource LIKE %s',
307                (realm + '%',)):
308            resources.add(i)
309        votes = {}
310        for resource in resources:
311            votes[resource] = self.get_vote_counts(resource)
312        return votes
313
314    def get_max_votes(self, realm):
315        votes = self.get_realm_votes(realm)
316        if not votes:
317            return 0
318        return max(i[1] for i in votes.values())
319
320    # IMilestoneChangeListener methods
321
322    def milestone_created(self, milestone):
323        """Called when a milestone is created."""
324        pass
325
326    def milestone_changed(self, milestone, old_values):
327        """Called when a milestone is modified."""
328        old_name = old_values.get('name')
329        if old_name and milestone.resource.id != old_name:
330            self.reparent_votes(milestone.resource, old_name)
331
332    def milestone_deleted(self, milestone):
333        """Called when a milestone is deleted."""
334        self.delete_votes(milestone.resource)
335
336    # IPermissionRequestor method
337
338    def get_permission_actions(self):
339        action = 'VOTE_VIEW'
340        return [('VOTE_MODIFY', [action]), action]
341
342    # IRequestHandler methods
343
344    def match_request(self, req):
345        match = self.path_re.match(req.path_info)
346        if match:
347            req.args['vote'] = match.group(1)
348            req.args['path'] = match.group(2)
349            return True
350
351    def process_request(self, req):
352        vote, path = req.args.get('vote'), req.args.get('path')
353        resource = resource_from_path(self.env, path)
354        if resource is None:
355            raise TracError(_("Invalid request path. Path does not "
356                              "contain a valid realm."))
357        req.perm(resource).require('VOTE_MODIFY')
358
359        vote = +1 if vote == 'up' else -1
360        old_vote = self.get_vote(req, resource)
361
362        # Protect against CSRF attacks: Validate the token like done in Trac
363        # core for all POST requests with a content-type corresponding
364        # to form submissions.
365        msg = ''
366        if req.args.get('token') != req.form_token:
367            if self.env.secure_cookies and req.scheme == 'http':
368                msg = _("Secure cookies are enabled, you must use https "
369                        "for your requests.")
370            else:
371                msg = _("Do you have cookies enabled?")
372            raise TracError(msg)
373        else:
374            if old_vote == vote:
375                # Second click on same icon revokes previous vote.
376                vote = 0
377            self.set_vote(req, resource, vote)
378
379        if req.args.get('js'):
380            body, title = self.format_votes(resource)
381            content = ':'.join(
382                          (req.href.chrome('vote/' + self.image_map[vote][0]),
383                           req.href.chrome('vote/' + self.image_map[vote][1]),
384                           body, title))
385            if isinstance(content, unicode):
386                content = content.encode('utf-8')
387            req.send(content)
388
389        req.redirect(get_resource_url(self.env, resource(version=None),
390                                      req.href))
391
392    # IRequestFilter methods
393
394    def pre_process_request(self, req, handler):
395        return handler
396
397    def post_process_request(self, req, template, data, content_type):
398        if template is not None:
399            for path in self.voteable_paths:
400                if fnmatchcase(req.path_info, path):
401                    resource = resource_from_path(self.env, req.path_info)
402                    if resource and 'VOTE_VIEW' in req.perm(resource):
403                        self.render_voter(req)
404                        break
405        return template, data, content_type
406
407    # ITemplateProvider methods
408
409    def get_templates_dirs(self):
410        return []
411
412    def get_htdocs_dirs(self):
413        return [('vote', resource_filename(__name__, 'htdocs'))]
414
415    # IWikiChangeListener methods
416
417    def wiki_page_added(self, page):
418        """Called whenever a new Wiki page is added."""
419        pass
420
421    def wiki_page_changed(self, page, version, t, comment, author, ipnr):
422        """Called when a page has been modified."""
423        pass
424
425    def wiki_page_deleted(self, page):
426        """Called when a page has been deleted."""
427        page.resource.version = None
428        self.delete_votes(page.resource)
429
430    def wiki_page_version_deleted(self, page):
431        """Called when a version of a page has been deleted."""
432        self.delete_votes(page.resource)
433
434    def wiki_page_renamed(self, page, old_name):
435        """Called when a page has been renamed."""
436        # Correct references for all page versions.
437        page.resource.version = None
438        # Work around issue t:#11138.
439        page.resource.id = page.name
440        self.reparent_votes(page.resource, old_name)
441
442    # IWikiMacroProvider methods
443
444    def get_macros(self):
445        yield 'LastVoted'
446        yield 'TopVoted'
447        yield 'VoteList'
448
449    def get_macro_description(self, name):
450        if name == 'LastVoted':
451            return _("Show most recently voted resources.")
452        elif name == 'TopVoted':
453            return _("Show listing of voted resources ordered by total score.")
454        elif name == 'VoteList':
455            return _("Show listing of most recent votes for a resource.")
456
457    def expand_macro(self, formatter, name, content):
458        env = formatter.env
459        req = formatter.req
460        if 'VOTE_VIEW' not in req.perm('vote'):
461            return
462        # Simplify function calls.
463        format_author = partial(Chrome(self.env).format_author, req)
464        if not content:
465            args = []
466            compact = None
467            kw = {}
468            top = 5
469        else:
470            args, kw = parse_args(content)
471            compact = 'compact' in args
472            top = as_int(kw.get('top'), 5, min=0)
473
474        if name == 'LastVoted':
475            lst = tag.ul()
476            for i in self.get_votes(req, top=top):
477                resource = Resource(i[0], i[1])
478                # Anotate who and when.
479                voted = _("by %(author)s at %(time)s",
480                          author=format_author(i[3]),
481                          time=format_datetime(to_datetime(i[4])))
482                lst(tag.li(tag.a(
483                    get_resource_description(
484                                env, resource,
485                                'compact' if compact else 'default'),
486                    href=get_resource_url(env, resource, formatter.href),
487                    title=('%+i %s' % (i[2], voted) if compact else None)),
488                    (Markup(' %s %s' % (tag.b('%+i' % i[2]),
489                                        voted)) if not compact else '')))
490            return lst
491
492        elif name == 'TopVoted':
493            realm = kw.get('realm')
494            lst = tag.ul()
495            for i in self.get_top_voted(req, realm=realm, top=top):
496                if 'up-only' in args and i[2] < 1:
497                    break
498                resource = Resource(i[0], i[1])
499                lst(tag.li(tag.a(
500                    get_resource_description(
501                                env, resource,
502                                'compact' if compact else 'default'),
503                    href=get_resource_url(env, resource, formatter.href),
504                    title=('%+i' % i[2] if compact else None)),
505                    (' (%+i)' % i[2] if not compact else '')))
506            return lst
507
508        elif name == 'VoteList':
509            lst = tag.ul()
510            resource = resource_from_path(env, req.path_info)
511            for i in self.get_votes(req, resource, top=top):
512                vote = _("at %(date)s",
513                         date=format_datetime(to_datetime(i[4])))
514                lst(tag.li(format_author(i[3]) if compact else
515                    tag_("%(count)s by %(author)s %(vote)s",
516                         count=tag.b('%+i' % i[2]),
517                         author=tag(format_author(i[3])),
518                         vote=vote)),
519                    title=('%+i %s' % (i[2], vote) if compact else None))
520            return lst
521
522    # IEnvironmentSetupParticipant methods
523
524    def environment_created(self):
525        self.upgrade_environment()
526
527    def environment_needs_upgrade(self, db=None):
528        schema_ver = self.get_schema_version()
529        if schema_ver < self.schema_version:
530            return True
531        elif schema_ver > self.schema_version:
532            raise TracError(
533                _("A newer version of VotePlugin has been installed before, "
534                  "but downgrading is unsupported."))
535        return False
536
537    def upgrade_environment(self, db=None):
538        """Each schema version should have its own upgrade module, named
539        upgrades/dbN.py, where 'N' is the version number (int).
540        """
541        dbm = DatabaseManager(self.env)
542        schema_version = self.get_schema_version()
543        if not schema_version:
544            with self.env.db_transaction:
545                dbm.create_tables(self.schema)
546                dbm.set_database_version(self.schema_version,
547                                         self.schema_version_key)
548                dbm.insert_into_tables(self.db_data)
549        else:
550            # Care for pre-tracvote-0.2 installations.
551            if schema_version == 1:
552                dbm.set_database_version(1, self.schema_version_key)
553            dbm.upgrade(self.schema_version, self.schema_version_key,
554                        'tracvote.upgrades')
555
556    # Internal methods
557
558    def get_schema_version(self):
559        """Return the current schema version for this plugin."""
560        dbm = DatabaseManager(self.env)
561        schema_ver = dbm.get_database_version('vote_version')
562        if schema_ver > 1:
563            # The expected outcome for any recent installation.
564            return schema_ver
565        # Care for pre-tracvote-0.2 installations.
566        tables = dbm.get_table_names()
567        if 'votes' in tables:
568            return 1
569        # This is a new installation.
570        return 0
571
572    def render_voter(self, req):
573        path = req.path_info.strip('/')
574        resource = resource_from_path(self.env, path)
575        vote = resource and self.get_vote(req, resource) or 0
576        up = tag.img(src=req.href.chrome('vote/' + self.image_map[vote][0]),
577                     alt=_("Up-vote"))
578        down = tag.img(src=req.href.chrome('vote/' + self.image_map[vote][1]),
579                       alt=_("Down-vote"))
580        if 'action' not in req.args and \
581                'VOTE_MODIFY' in req.perm(resource) and \
582                get_reporter_id(req) != 'anonymous':
583            down = tag.a(down, id='downvote',
584                         href=req.href.vote('down', path,
585                                            token=req.form_token),
586                         title=_("Down-vote"))
587            up = tag.a(up, id='upvote',
588                       href=req.href.vote('up', path, token=req.form_token),
589                       title=_("Up-vote"))
590            add_script(req, 'vote/js/tracvote.js')
591            shown = req.session.get('shown_vote_message')
592            if not shown:
593                add_notice(req, _("You can vote for resources on this Trac "
594                                  "install by clicking the up-vote/down-vote "
595                                  "arrows in the context navigation bar."))
596                req.session['shown_vote_message'] = '1'
597        body, title = self.format_votes(resource)
598        votes = tag.span(body, id='votes')
599        add_stylesheet(req, 'vote/css/tracvote.css')
600        elm = tag.span(up, votes, down, id='vote', title=title)
601        req.chrome.setdefault('ctxtnav', []).insert(0, elm)
602
603    def format_votes(self, resource):
604        """Return a tuple of (body_text, title_text) describing the votes on a
605        resource.
606        """
607        negative, total, positive = \
608            self.get_vote_counts(resource) if resource else (0, 0, 0)
609        count_detail = ['%+i' % i for i in (positive, negative) if i]
610        if count_detail:
611            count_detail = ' (%s)' % ', '.join(count_detail)
612        else:
613            count_detail = ''
614        return '%+i' % total, _("Vote count%(detail)s", detail=count_detail)
Note: See TracBrowser for help on using the repository browser.