~launchpad-pqm/launchpad/devel

« back to all changes in this revision

Viewing changes to lib/lp/app/browser/tales.py

  • Committer: Danilo Segan
  • Date: 2011-04-22 14:02:29 UTC
  • mto: This revision was merged to the branch mainline in revision 12910.
  • Revision ID: danilo@canonical.com-20110422140229-zhq4d4c2k8jpglhf
Ignore hidden files when building combined JS file.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
 
1
# Copyright 2009-2010 Canonical Ltd.  This software is licensed under the
2
2
# GNU Affero General Public License version 3 (see the file LICENSE).
3
3
 
 
4
# pylint: disable-msg=C0103,W0613,R0911,F0401
 
5
#
4
6
"""Implementation of the lp: htmlform: fmt: namespaces in TALES."""
5
7
 
6
8
__metaclass__ = type
7
9
 
8
10
import bisect
9
11
import cgi
10
 
from datetime import (
11
 
    datetime,
12
 
    timedelta,
13
 
    )
14
12
from email.Utils import formatdate
15
13
import math
16
14
import os.path
17
15
import rfc822
18
16
import sys
19
 
from textwrap import dedent
20
17
import urllib
21
18
 
 
19
##import warnings
 
20
 
 
21
from datetime import datetime, timedelta
22
22
from lazr.enum import enumerated_type_registry
23
23
from lazr.uri import URI
24
 
import pytz
25
 
from z3c.ptcompat import ViewPageTemplateFile
 
24
 
 
25
from zope.interface import Interface, Attribute, implements
 
26
from zope.component import adapts, getUtility, queryAdapter, getMultiAdapter
26
27
from zope.app import zapi
27
 
from zope.component import (
28
 
    adapts,
29
 
    getMultiAdapter,
30
 
    getUtility,
31
 
    queryAdapter,
32
 
    )
33
 
from zope.error.interfaces import IErrorReportingUtility
34
 
from zope.interface import (
35
 
    Attribute,
36
 
    implements,
37
 
    Interface,
38
 
    )
39
28
from zope.publisher.browser import BrowserView
40
 
from zope.schema import TextLine
41
 
from zope.security.interfaces import Unauthorized
42
 
from zope.security.proxy import isinstance as zope_isinstance
43
29
from zope.traversing.interfaces import (
 
30
    ITraversable,
44
31
    IPathAdapter,
45
 
    ITraversable,
46
32
    TraversalError,
47
33
    )
48
 
 
49
 
from lp import _
50
 
from lp.app.browser.badge import IHasBadges
51
 
from lp.app.browser.stringformatter import (
52
 
    escape,
53
 
    FormattersAPI,
54
 
    )
55
 
from lp.app.interfaces.launchpad import (
56
 
    IHasIcon,
57
 
    IHasLogo,
58
 
    IHasMugshot,
59
 
    IPrivacy,
60
 
    )
 
34
from zope.security.interfaces import Unauthorized
 
35
from zope.security.proxy import isinstance as zope_isinstance
 
36
from zope.schema import TextLine
 
37
 
 
38
import pytz
 
39
from z3c.ptcompat import ViewPageTemplateFile
 
40
 
 
41
from canonical.launchpad import _
 
42
from canonical.launchpad.interfaces.launchpad import (
 
43
    IHasIcon, IHasLogo, IHasMugshot, IPrivacy)
 
44
from canonical.launchpad.layers import LaunchpadLayer
 
45
import canonical.launchpad.pagetitles
 
46
from canonical.launchpad.webapp import canonical_url, urlappend
 
47
from canonical.launchpad.webapp.authorization import check_permission
 
48
from canonical.launchpad.webapp.badge import IHasBadges
 
49
from canonical.launchpad.webapp.interfaces import (
 
50
    IApplicationMenu, IContextMenu, IFacetMenu, ILaunchBag, INavigationMenu,
 
51
    IPrimaryContext, NoCanonicalUrl)
 
52
from canonical.launchpad.webapp.menu import get_current_view, get_facet
 
53
from canonical.launchpad.webapp.publisher import (
 
54
    get_current_browser_request, LaunchpadView, nearest)
 
55
from canonical.launchpad.webapp.session import get_cookie_domain
 
56
from canonical.lazr.canonicalurl import nearest_adapter
 
57
from lp.app.browser.stringformatter import escape, FormattersAPI
61
58
from lp.blueprints.interfaces.specification import ISpecification
62
59
from lp.blueprints.interfaces.sprint import ISprint
63
60
from lp.bugs.interfaces.bug import IBug
64
61
from lp.buildmaster.enums import BuildStatus
65
62
from lp.code.interfaces.branch import IBranch
66
 
from lp.layers import LaunchpadLayer
 
63
from lp.soyuz.enums import ArchivePurpose
 
64
from lp.soyuz.interfaces.archive import IPPA
 
65
from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
67
66
from lp.registry.interfaces.distribution import IDistribution
68
67
from lp.registry.interfaces.distributionsourcepackage import (
69
68
    IDistributionSourcePackage,
71
70
from lp.registry.interfaces.person import IPerson
72
71
from lp.registry.interfaces.product import IProduct
73
72
from lp.registry.interfaces.projectgroup import IProjectGroup
74
 
from lp.services.features import getFeatureFlag
75
 
from lp.services.webapp import (
76
 
    canonical_url,
77
 
    urlappend,
78
 
    )
79
 
from lp.services.webapp.authorization import check_permission
80
 
from lp.services.webapp.canonicalurl import nearest_adapter
81
 
from lp.services.webapp.interfaces import (
82
 
    IApplicationMenu,
83
 
    IContextMenu,
84
 
    IFacetMenu,
85
 
    ILaunchBag,
86
 
    INavigationMenu,
87
 
    IPrimaryContext,
88
 
    NoCanonicalUrl,
89
 
    )
90
 
from lp.services.webapp.menu import (
91
 
    get_current_view,
92
 
    get_facet,
93
 
    )
94
 
from lp.services.webapp.publisher import (
95
 
    get_current_browser_request,
96
 
    LaunchpadView,
97
 
    nearest,
98
 
    )
99
 
from lp.services.webapp.session import get_cookie_domain
100
 
from lp.soyuz.enums import ArchivePurpose
101
 
from lp.soyuz.interfaces.archive import IPPA
102
 
from lp.soyuz.interfaces.archivesubscriber import IArchiveSubscriberSet
103
 
from lp.soyuz.interfaces.binarypackagename import IBinaryAndSourcePackageName
104
73
 
105
74
 
106
75
SEPARATOR = ' : '
528
497
        # We are interested in the default value (param2).
529
498
        result = ''
530
499
        for nm in self.allowed_names:
531
 
            if name.startswith(nm + ":"):
 
500
            if name.startswith(nm+":"):
532
501
                name_parts = name.split(":")
533
502
                name = name_parts[0]
534
503
                if len(name_parts) > 2:
669
638
        """The page title to be used.
670
639
 
671
640
        By default, reverse breadcrumbs are always used if they are available.
672
 
        If not available, then the view's .page_title attribut is used.
673
 
        If breadcrumbs are available, then a view can still choose to
674
 
        override them by setting the attribute .override_title_breadcrumbs
675
 
        to True.
 
641
        If not available, then the view's .page_title attribute or entry in
 
642
        pagetitles.py (deprecated) is used.  If breadcrumbs are available,
 
643
        then a view can still choose to override them by setting the attribute
 
644
        .override_title_breadcrumbs to True.
676
645
        """
677
 
        ROOT_TITLE = 'Launchpad'
678
646
        view = self._context
679
647
        request = get_current_browser_request()
 
648
        module = canonical.launchpad.pagetitles
680
649
        hierarchy_view = getMultiAdapter(
681
650
            (view.context, request), name='+hierarchy')
682
651
        override = getattr(view, 'override_title_breadcrumbs', False)
694
663
            if template is None:
695
664
                template = getattr(view, 'index', None)
696
665
                if template is None:
697
 
                    return ROOT_TITLE
 
666
                    return module.DEFAULT_LAUNCHPAD_TITLE
 
667
            # There is no .page_title attribute on the view, so fallback to
 
668
            # looking for an an entry in pagetitles.py.  This is deprecated
 
669
            # though, so issue a warning.
 
670
            filename = os.path.basename(template.filename)
 
671
            name, ext = os.path.splitext(filename)
 
672
            title_name = name.replace('-', '_')
 
673
            title_object = getattr(module, title_name, None)
 
674
            # Page titles are mandatory.
 
675
            assert title_object is not None, (
 
676
                'No .page_title or pagetitles.py found for %s'
 
677
                % template.filename)
 
678
            ## 2009-09-08 BarryWarsaw bug 426527: Enable this when we want to
 
679
            ## force conversions from pagetitles.py; however tests will fail
 
680
            ## because of this output.
 
681
            ## warnings.warn('Old style pagetitles.py entry found for %s. '
 
682
            ##               'Switch to using a .page_title attribute on the '
 
683
            ##               'view instead.' % template.filename,
 
684
            ##               DeprecationWarning)
 
685
            if isinstance(title_object, basestring):
 
686
                return title_object
 
687
            else:
 
688
                title = title_object(view.context, view)
 
689
                if title is None:
 
690
                    return module.DEFAULT_LAUNCHPAD_TITLE
 
691
                else:
 
692
                    return title
698
693
        # Use the reverse breadcrumbs.
699
694
        return SEPARATOR.join(
700
695
            breadcrumb.text for breadcrumb
721
716
        elif IProjectGroup.providedBy(context):
722
717
            return 'sprite project'
723
718
        elif IPerson.providedBy(context):
724
 
            if context.is_team:
 
719
            if context.isTeam():
725
720
                return 'sprite team'
726
721
            else:
727
722
                if context.is_valid_person:
745
740
            return 'sprite branch'
746
741
        elif ISpecification.providedBy(context):
747
742
            return 'sprite blueprint'
748
 
        elif IBinaryAndSourcePackageName.providedBy(context):
749
 
            return 'sprite package-source'
750
743
        return None
751
744
 
752
745
    def default_logo_resource(self, context):
756
749
        if IProjectGroup.providedBy(context):
757
750
            return '/@@/project-logo'
758
751
        elif IPerson.providedBy(context):
759
 
            if context.is_team:
 
752
            if context.isTeam():
760
753
                return '/@@/team-logo'
761
754
            else:
762
755
                if context.is_valid_person:
778
771
        if IProjectGroup.providedBy(context):
779
772
            return '/@@/project-mugshot'
780
773
        elif IPerson.providedBy(context):
781
 
            if context.is_team:
 
774
            if context.isTeam():
782
775
                return '/@@/team-mugshot'
783
776
            else:
784
777
                if context.is_valid_person:
793
786
            return '/@@/meeting-mugshot'
794
787
        return None
795
788
 
796
 
    def custom_icon_url(self):
 
789
    def _get_custom_icon_url(self):
797
790
        """Return the URL for this object's icon."""
798
791
        context = self._context
799
792
        if IHasIcon.providedBy(context) and context.icon is not None:
889
882
        '<span alt="%s" title="%s" class="%s">&nbsp;</span>')
890
883
 
891
884
    linked_icon_template = (
892
 
        '<a href="%s" alt="%s" title="%s" class="%s">&nbsp;</a>')
 
885
        '<a href="%s" alt="%s" title="%s" class="%s"></a>')
893
886
 
894
887
    def traverse(self, name, furtherPath):
895
888
        """Special-case traversal for icons with an optional rootsite."""
1084
1077
            BuildStatus.BUILDING: {'src': "/@@/processing"},
1085
1078
            BuildStatus.UPLOADING: {'src': "/@@/processing"},
1086
1079
            BuildStatus.FAILEDTOUPLOAD: {'src': "/@@/build-failedtoupload"},
1087
 
            BuildStatus.CANCELLING: {'src': "/@@/processing"},
1088
 
            BuildStatus.CANCELLED: {'src': "/@@/build-failed"},
1089
1080
            }
1090
1081
 
1091
1082
        alt = '[%s]' % self._context.status.name
1155
1146
                         'icon': 'icon',
1156
1147
                         'displayname': 'displayname',
1157
1148
                         'unique_displayname': 'unique_displayname',
1158
 
                         'link-display-name-id': 'link_display_name_id',
 
1149
                         'name_link': 'nameLink',
1159
1150
                         }
1160
1151
 
1161
1152
    final_traversable_names = {'local-time': 'local_time'}
1178
1169
    def _makeLink(self, view_name, rootsite, text):
1179
1170
        person = self._context
1180
1171
        url = self.url(view_name, rootsite)
1181
 
        custom_icon = ObjectImageDisplayAPI(person).custom_icon_url()
 
1172
        custom_icon = ObjectImageDisplayAPI(person)._get_custom_icon_url()
1182
1173
        if custom_icon is None:
1183
1174
            css_class = ObjectImageDisplayAPI(person).sprite_css()
1184
1175
            return (u'<a href="%s" class="%s">%s</a>') % (
1210
1201
    def icon(self, view_name):
1211
1202
        """Return the URL for the person's icon."""
1212
1203
        custom_icon = ObjectImageDisplayAPI(
1213
 
            self._context).custom_icon_url()
 
1204
            self._context)._get_custom_icon_url()
1214
1205
        if custom_icon is None:
1215
1206
            css_class = ObjectImageDisplayAPI(self._context).sprite_css()
1216
1207
            return '<span class="' + css_class + '"></span>'
1217
1208
        else:
1218
1209
            return '<img src="%s" width="14" height="14" />' % custom_icon
1219
1210
 
1220
 
    def link_display_name_id(self, view_name):
1221
 
        """Return a link to the user's profile page.
1222
 
 
1223
 
        The link text uses both the display name and Launchpad id to clearly
1224
 
        indicate which user profile is linked.
1225
 
        """
1226
 
        text = self.unique_displayname(None)
1227
 
        return self._makeLink(view_name, 'mainsite', text)
1228
 
 
1229
 
 
1230
 
class MixedVisibilityError(Exception):
1231
 
    """An informational error that visibility is being mixed."""
 
1211
    def nameLink(self, view_name):
 
1212
        """Return the Launchpad id of the person, linked to their profile."""
 
1213
        return self._makeLink(view_name, 'mainsite', self._context.name)
1232
1214
 
1233
1215
 
1234
1216
class TeamFormatterAPI(PersonFormatterAPI):
1242
1224
        The default URL for a team is to the mainsite. None is returned
1243
1225
        when the user does not have permission to review the team.
1244
1226
        """
1245
 
        if not check_permission('launchpad.LimitedView', self._context):
 
1227
        if not check_permission('launchpad.View', self._context):
1246
1228
            # This person has no permission to view the team details.
1247
 
            self._report_visibility_leak()
1248
1229
            return None
1249
1230
        return super(TeamFormatterAPI, self).url(view_name, rootsite)
1250
1231
 
1251
1232
    def api_url(self, context):
1252
1233
        """See `ObjectFormatterAPI`."""
1253
 
        if not check_permission('launchpad.LimitedView', self._context):
 
1234
        if not check_permission('launchpad.View', self._context):
1254
1235
            # This person has no permission to view the team details.
1255
 
            self._report_visibility_leak()
1256
1236
            return None
1257
1237
        return super(TeamFormatterAPI, self).api_url(context)
1258
1238
 
1263
1243
        when the user does not have permission to review the team.
1264
1244
        """
1265
1245
        person = self._context
1266
 
        if not check_permission('launchpad.LimitedView', person):
 
1246
        if not check_permission('launchpad.View', person):
1267
1247
            # This person has no permission to view the team details.
1268
 
            self._report_visibility_leak()
1269
1248
            return '<span class="sprite team">%s</span>' % cgi.escape(
1270
1249
                self.hidden)
1271
1250
        return super(TeamFormatterAPI, self).link(view_name, rootsite)
1272
1251
 
1273
 
    def icon(self, view_name):
1274
 
        team = self._context
1275
 
        if not check_permission('launchpad.LimitedView', team):
1276
 
            css_class = ObjectImageDisplayAPI(team).sprite_css()
1277
 
            return '<span class="' + css_class + '"></span>'
1278
 
        else:
1279
 
            return super(TeamFormatterAPI, self).icon(view_name)
1280
 
 
1281
1252
    def displayname(self, view_name, rootsite=None):
1282
1253
        """See `PersonFormatterAPI`."""
1283
1254
        person = self._context
1284
 
        if not check_permission('launchpad.LimitedView', person):
 
1255
        if not check_permission('launchpad.View', person):
1285
1256
            # This person has no permission to view the team details.
1286
 
            self._report_visibility_leak()
1287
1257
            return self.hidden
1288
1258
        return super(TeamFormatterAPI, self).displayname(view_name, rootsite)
1289
1259
 
1290
1260
    def unique_displayname(self, view_name):
1291
1261
        """See `PersonFormatterAPI`."""
1292
1262
        person = self._context
1293
 
        if not check_permission('launchpad.LimitedView', person):
 
1263
        if not check_permission('launchpad.View', person):
1294
1264
            # This person has no permission to view the team details.
1295
 
            self._report_visibility_leak()
1296
1265
            return self.hidden
1297
1266
        return super(TeamFormatterAPI, self).unique_displayname(view_name)
1298
1267
 
1299
 
    def _report_visibility_leak(self):
1300
 
        if bool(getFeatureFlag('disclosure.log_private_team_leaks.enabled')):
1301
 
            request = get_current_browser_request()
1302
 
            try:
1303
 
                raise MixedVisibilityError()
1304
 
            except MixedVisibilityError:
1305
 
                getUtility(IErrorReportingUtility).raising(
1306
 
                    sys.exc_info(), request)
1307
 
 
1308
1268
 
1309
1269
class CustomizableFormatter(ObjectFormatterAPI):
1310
1270
    """A ObjectFormatterAPI that is easy to customize.
1422
1382
    _link_summary_template = '%(displayname)s'
1423
1383
    _link_permission = 'zope.Public'
1424
1384
 
1425
 
    traversable_names = {
1426
 
        'api_url': 'api_url',
1427
 
        'link': 'link',
1428
 
        'url': 'url',
1429
 
        'link_with_displayname': 'link_with_displayname'
1430
 
        }
1431
 
 
1432
1385
    def _link_summary_values(self):
1433
1386
        displayname = self._context.displayname
1434
1387
        return {'displayname': displayname}
1440
1393
        """
1441
1394
        return super(PillarFormatterAPI, self).url(view_name, rootsite)
1442
1395
 
1443
 
    def _getLinkHTML(self, view_name, rootsite,
1444
 
        template, custom_icon_template):
1445
 
        """Generates html, mapping a link context to given templates.
1446
 
 
1447
 
        The html is generated using given `template` or `custom_icon_template`
1448
 
        based on the presence of a custom icon for Products/ProjectGroups.
1449
 
        Named string substitution is used to render the final html
1450
 
        (see below for a list of allowed keys).
1451
 
 
1452
 
        The link context is a dict containing info about current
1453
 
        Products or ProjectGroups.
1454
 
        Keys are `url`, `name`, `displayname`, `custom_icon` (if present),
1455
 
        `css_class` (if a custom icon does not exist),
1456
 
        'summary' (see CustomizableFormatter._make_link_summary()).
1457
 
        """
1458
 
        context = self._context
1459
 
        mapping = {
1460
 
            'url': self.url(view_name, rootsite),
1461
 
            'name': cgi.escape(context.name),
1462
 
            'displayname': cgi.escape(context.displayname),
1463
 
            'summary': self._make_link_summary(),
1464
 
            }
1465
 
        custom_icon = ObjectImageDisplayAPI(context).custom_icon_url()
1466
 
        if custom_icon is None:
1467
 
            mapping['css_class'] = ObjectImageDisplayAPI(context).sprite_css()
1468
 
            return template % mapping
1469
 
        mapping['custom_icon'] = custom_icon
1470
 
        return custom_icon_template % mapping
1471
 
 
1472
1396
    def link(self, view_name, rootsite='mainsite'):
1473
1397
        """The html to show a link to a Product, ProjectGroup or distribution.
1474
1398
 
1475
1399
        In the case of Products or ProjectGroups we display the custom
1476
1400
        icon, if one exists. The default URL for a pillar is to the mainsite.
1477
1401
        """
1478
 
        super(PillarFormatterAPI, self).link(view_name)
1479
 
        template = u'<a href="%(url)s" class="%(css_class)s">%(summary)s</a>'
1480
 
        custom_icon_template = (
1481
 
            u'<a href="%(url)s" class="bg-image" '
1482
 
            u'style="background-image: url(%(custom_icon)s)">%(summary)s</a>'
1483
 
            )
1484
 
        return self._getLinkHTML(
1485
 
            view_name, rootsite, template, custom_icon_template)
1486
 
 
1487
 
    def link_with_displayname(self, view_name, rootsite='mainsite'):
1488
 
        """The html to show a link to a Product, ProjectGroup or
1489
 
        distribution, including displayname and name.
1490
 
 
1491
 
        In the case of Products or ProjectGroups we display the custom
1492
 
        icon, if one exists. The default URL for a pillar is to the mainsite.
1493
 
        """
1494
 
        super(PillarFormatterAPI, self).link(view_name)
1495
 
        template = (
1496
 
            u'<a href="%(url)s" class="%(css_class)s">%(displayname)s</a>'
1497
 
            u'&nbsp;(<a href="%(url)s">%(name)s</a>)'
1498
 
            )
1499
 
        custom_icon_template = (
1500
 
            u'<a href="%(url)s" class="bg-image" '
1501
 
            u'style="background-image: url(%(custom_icon)s)">'
1502
 
            u'%(displayname)s</a>&nbsp;(<a href="%(url)s">%(name)s</a>)'
1503
 
            )
1504
 
        return self._getLinkHTML(
1505
 
            view_name, rootsite, template, custom_icon_template)
 
1402
 
 
1403
        html = super(PillarFormatterAPI, self).link(view_name)
 
1404
        context = self._context
 
1405
        custom_icon = ObjectImageDisplayAPI(
 
1406
            context)._get_custom_icon_url()
 
1407
        url = self.url(view_name, rootsite)
 
1408
        summary = self._make_link_summary()
 
1409
        if custom_icon is None:
 
1410
            css_class = ObjectImageDisplayAPI(context).sprite_css()
 
1411
            html = (u'<a href="%s" class="%s">%s</a>') % (
 
1412
                url, css_class, summary)
 
1413
        else:
 
1414
            html = (u'<a href="%s" class="bg-image" '
 
1415
                     'style="background-image: url(%s)">%s</a>') % (
 
1416
                url, custom_icon, summary)
 
1417
        return html
1506
1418
 
1507
1419
 
1508
1420
class DistroSeriesFormatterAPI(CustomizableFormatter):
1728
1640
    def link(self, view_name, rootsite=None):
1729
1641
        build = self._context
1730
1642
        if not check_permission('launchpad.View', build):
1731
 
            return 'private job'
 
1643
            return 'private source'
1732
1644
 
1733
1645
        url = self.url(view_name=view_name, rootsite=rootsite)
1734
1646
        title = cgi.escape(build.title)
2139
2051
        delta = abs(delta)
2140
2052
        days = delta.days
2141
2053
        hours = delta.seconds / 3600
2142
 
        minutes = (delta.seconds - (3600 * hours)) / 60
 
2054
        minutes = (delta.seconds - (3600*hours)) / 60
2143
2055
        seconds = delta.seconds % 60
2144
2056
        result = ''
2145
2057
        if future:
2154
2066
            amount = minutes
2155
2067
            unit = 'minute'
2156
2068
        else:
2157
 
            if seconds <= 10:
2158
 
                result += 'a moment'
2159
 
                if not future:
2160
 
                    result += ' ago'
2161
 
                return result
2162
 
            else:
2163
 
                amount = seconds
2164
 
                unit = 'second'
 
2069
            amount = seconds
 
2070
            unit = 'second'
2165
2071
        if amount != 1:
2166
2072
            unit += 's'
2167
2073
        result += '%s %s' % (amount, unit)
2179
2085
    def isodate(self):
2180
2086
        return self._datetime.isoformat()
2181
2087
 
2182
 
    @staticmethod
2183
 
    def _yearDelta(old, new):
2184
 
        """Return the difference in years between two datetimes.
2185
 
 
2186
 
        :param old: The old date
2187
 
        :param new: The new date
2188
 
        """
2189
 
        year_delta = new.year - old.year
2190
 
        year_timedelta = datetime(new.year, 1, 1) - datetime(old.year, 1, 1)
2191
 
        if new - old < year_timedelta:
2192
 
            year_delta -= 1
2193
 
        return year_delta
2194
 
 
2195
 
    def durationsince(self):
2196
 
        """How long since the datetime, as a string."""
2197
 
        now = self._now()
2198
 
        number = self._yearDelta(self._datetime, now)
2199
 
        unit = 'year'
2200
 
        if number < 1:
2201
 
            delta = now - self._datetime
2202
 
            if delta.days > 0:
2203
 
                number = delta.days
2204
 
                unit = 'day'
2205
 
            else:
2206
 
                number = delta.seconds / 60
2207
 
                if number == 0:
2208
 
                    return 'less than a minute'
2209
 
                unit = 'minute'
2210
 
                if number >= 60:
2211
 
                    number /= 60
2212
 
                    unit = 'hour'
2213
 
        if number != 1:
2214
 
            unit += 's'
2215
 
        return '%d %s' % (number, unit)
2216
 
 
2217
2088
 
2218
2089
class SeriesSourcePackageBranchFormatter(ObjectFormatterAPI):
2219
2090
    """Formatter for a SourcePackage, Pocket -> Branch link.
2244
2115
        if name == 'exactduration':
2245
2116
            return self.exactduration()
2246
2117
        elif name == 'approximateduration':
2247
 
            return self.approximateduration()
2248
 
        elif name == 'millisecondduration':
2249
 
            return self.millisecondduration()
 
2118
            use_words = True
 
2119
            if len(furtherPath) == 1:
 
2120
                if 'use-digits' == furtherPath[0]:
 
2121
                    furtherPath.pop()
 
2122
                    use_words = False
 
2123
            return self.approximateduration(use_words)
2250
2124
        else:
2251
2125
            raise TraversalError(name)
2252
2126
 
2255
2129
        parts = []
2256
2130
        minutes, seconds = divmod(self._duration.seconds, 60)
2257
2131
        hours, minutes = divmod(minutes, 60)
2258
 
        seconds = seconds + (float(self._duration.microseconds) / 10 ** 6)
 
2132
        seconds = seconds + (float(self._duration.microseconds) / 10**6)
2259
2133
        if self._duration.days > 0:
2260
2134
            if self._duration.days == 1:
2261
2135
                parts.append('%d day' % self._duration.days)
2276
2150
 
2277
2151
        return ', '.join(parts)
2278
2152
 
2279
 
    def approximateduration(self):
 
2153
    def approximateduration(self, use_words=True):
2280
2154
        """Return a nicely-formatted approximate duration.
2281
2155
 
2282
 
        E.g. '1 hour', '3 minutes', '1 hour 10 minutes' and so forth.
 
2156
        E.g. 'an hour', 'three minutes', '1 hour 10 minutes' and so
 
2157
        forth.
2283
2158
 
2284
2159
        See https://launchpad.canonical.com/PresentingLengthsOfTime.
 
2160
 
 
2161
        :param use_words: Specificly determines whether or not to use
 
2162
            words for numbers less than or equal to ten.  Expanding
 
2163
            numbers to words makes sense when the number is used in
 
2164
            prose or a singualar item on a page, but when used in
 
2165
            a table, the words do not work as well.
2285
2166
        """
2286
2167
        # NOTE: There are quite a few "magic numbers" in this
2287
2168
        # implementation; they are generally just figures pulled
2294
2175
        # including the decimal part.
2295
2176
        seconds = self._duration.days * (3600 * 24)
2296
2177
        seconds += self._duration.seconds
2297
 
        seconds += (float(self._duration.microseconds) / 10 ** 6)
 
2178
        seconds += (float(self._duration.microseconds) / 10**6)
2298
2179
 
2299
2180
        # First we'll try to calculate an approximate number of
2300
2181
        # seconds up to a minute. We'll start by defining a sorted
2314
2195
            (35, '30 seconds'),
2315
2196
            (45, '40 seconds'),
2316
2197
            (55, '50 seconds'),
2317
 
            (90, '1 minute'),
 
2198
            (90, 'a minute'),
2318
2199
        ]
 
2200
        if not use_words:
 
2201
            representation_in_seconds[-1] = (90, '1 minute')
2319
2202
 
2320
2203
        # Break representation_in_seconds into two pieces, to simplify
2321
2204
        # finding the correct display value, through the use of the
2323
2206
        second_boundaries, display_values = zip(*representation_in_seconds)
2324
2207
 
2325
2208
        # Is seconds small enough that we can produce a representation
2326
 
        # in seconds (up to '1 minute'?)
 
2209
        # in seconds (up to 'a minute'?)
2327
2210
        if seconds < second_boundaries[-1]:
2328
2211
            # Use the built-in bisection algorithm to locate the index
2329
2212
            # of the item which "seconds" sorts after.
2332
2215
            # Return the corresponding display value.
2333
2216
            return display_values[matching_element_index]
2334
2217
 
 
2218
        # More than a minute, approximately; our calculation strategy
 
2219
        # changes. From this point forward, we may also need a
 
2220
        # "verbal" representation of the number. (We never need a
 
2221
        # verbal representation of "1", because we tend to special
 
2222
        # case the number 1 for various approximations, and we usually
 
2223
        # use a word like "an", instead of "one", e.g. "an hour")
 
2224
        if use_words:
 
2225
            number_name = {
 
2226
                2: 'two', 3: 'three', 4: 'four', 5: 'five',
 
2227
                6: 'six', 7: 'seven', 8: 'eight', 9: 'nine',
 
2228
                10: 'ten'}
 
2229
        else:
 
2230
            number_name = dict((number, number) for number in range(2, 11))
 
2231
 
2335
2232
        # Convert seconds into minutes, and round it.
2336
2233
        minutes, remaining_seconds = divmod(seconds, 60)
2337
2234
        minutes += remaining_seconds / 60.0
2338
2235
        minutes = int(round(minutes))
2339
2236
 
2340
2237
        if minutes <= 59:
2341
 
            return "%d minutes" % minutes
 
2238
            return "%s minutes" % number_name.get(minutes, str(minutes))
2342
2239
 
2343
2240
        # Is the duration less than an hour and 5 minutes?
2344
2241
        if seconds < (60 + 5) * 60:
2345
 
            return "1 hour"
 
2242
            if use_words:
 
2243
                return "an hour"
 
2244
            else:
 
2245
                return "1 hour"
2346
2246
 
2347
2247
        # Next phase: try and calculate an approximate duration
2348
2248
        # greater than one hour, but fewer than ten hours, to a 10
2361
2261
                else:
2362
2262
                    return "%d hours %s minutes" % (hours, minutes)
2363
2263
            else:
2364
 
                return "%d hours" % hours
 
2264
                number_as_text = number_name.get(hours, str(hours))
 
2265
                return "%s hours" % number_as_text
2365
2266
 
2366
2267
        # Is the duration less than ten and a half hours?
2367
2268
        if seconds < (10.5 * 3600):
2368
 
            return '10 hours'
 
2269
            return '%s hours' % number_name[10]
2369
2270
 
2370
2271
        # Try to calculate the approximate number of hours, to a
2371
2272
        # maximum of 47.
2375
2276
 
2376
2277
        # Is the duration fewer than two and a half days?
2377
2278
        if seconds < (2.5 * 24 * 3600):
2378
 
            return '2 days'
 
2279
            return '%s days' % number_name[2]
2379
2280
 
2380
2281
        # Try to approximate to day granularity, up to a maximum of 13
2381
2282
        # days.
2382
2283
        days = int(round(seconds / (24 * 3600)))
2383
2284
        if days <= 13:
2384
 
            return "%s days" % days
 
2285
            return "%s days" % number_name.get(days, str(days))
2385
2286
 
2386
2287
        # Is the duration fewer than two and a half weeks?
2387
2288
        if seconds < (2.5 * 7 * 24 * 3600):
2388
 
            return '2 weeks'
 
2289
            return '%s weeks' % number_name[2]
2389
2290
 
2390
2291
        # If we've made it this far, we'll calculate the duration to a
2391
2292
        # granularity of weeks, once and for all.
2392
2293
        weeks = int(round(seconds / (7 * 24 * 3600.0)))
2393
 
        return "%d weeks" % weeks
2394
 
 
2395
 
    def millisecondduration(self):
2396
 
        return str(
2397
 
            (self._duration.days * 24 * 3600
2398
 
             + self._duration.seconds * 1000
2399
 
             + self._duration.microseconds // 1000)) + 'ms'
 
2294
        return "%s weeks" % number_name.get(weeks, str(weeks))
2400
2295
 
2401
2296
 
2402
2297
class LinkFormatterAPI(ObjectFormatterAPI):
2457
2352
    return clean_path_split
2458
2353
 
2459
2354
 
 
2355
class PageTemplateContextsAPI:
 
2356
    """Adapter from page tempate's CONTEXTS object to fmt:pagetitle.
 
2357
 
 
2358
    This is registered to be used for the dict type.
 
2359
    """
 
2360
    # 2009-09-08 BarryWarsaw bug 426532.  Remove this class, all references
 
2361
    # to it, and all instances of CONTEXTS/fmt:pagetitle
 
2362
    implements(ITraversable)
 
2363
 
 
2364
    def __init__(self, contextdict):
 
2365
        self.contextdict = contextdict
 
2366
 
 
2367
    def traverse(self, name, furtherPath):
 
2368
        if name == 'pagetitle':
 
2369
            return self.pagetitle()
 
2370
        else:
 
2371
            raise TraversalError(name)
 
2372
 
 
2373
    def pagetitle(self):
 
2374
        """Return the string title for the page template CONTEXTS dict.
 
2375
 
 
2376
        Take the simple filename without extension from
 
2377
        self.contextdict['template'].filename, replace any hyphens with
 
2378
        underscores, and use this to look up a string, unicode or
 
2379
        function in the module canonical.launchpad.pagetitles.
 
2380
 
 
2381
        If no suitable object is found in canonical.launchpad.pagetitles, emit
 
2382
        a warning that this page has no title, and return the default page
 
2383
        title.
 
2384
        """
 
2385
        template = self.contextdict['template']
 
2386
        filename = os.path.basename(template.filename)
 
2387
        name, ext = os.path.splitext(filename)
 
2388
        name = name.replace('-', '_')
 
2389
        titleobj = getattr(canonical.launchpad.pagetitles, name, None)
 
2390
        if titleobj is None:
 
2391
            raise AssertionError(
 
2392
                 "No page title in canonical.launchpad.pagetitles "
 
2393
                 "for %s" % name)
 
2394
        elif isinstance(titleobj, basestring):
 
2395
            return titleobj
 
2396
        else:
 
2397
            context = self.contextdict['context']
 
2398
            view = self.contextdict['view']
 
2399
            title = titleobj(context, view)
 
2400
            if title is None:
 
2401
                return canonical.launchpad.pagetitles.DEFAULT_LAUNCHPAD_TITLE
 
2402
            else:
 
2403
                return title
 
2404
 
 
2405
 
2460
2406
class PermissionRequiredQuery:
2461
2407
    """Check if the logged in user has a given permission on a given object.
2462
2408
 
2503
2449
        view/macro:pagehas/applicationtabs
2504
2450
        view/macro:pagehas/globalsearch
2505
2451
        view/macro:pagehas/portlets
2506
 
        view/macro:pagehas/main
2507
2452
 
2508
2453
        view/macro:pagetype
2509
2454
 
2510
 
        view/macro:is-page-contentless
2511
2455
    """
2512
2456
 
2513
2457
    implements(ITraversable)
2540
2484
            return self.haspage(layoutelement)
2541
2485
        elif name == 'pagetype':
2542
2486
            return self.pagetype()
2543
 
        elif name == 'is-page-contentless':
2544
 
            return self.isPageContentless()
 
2487
        elif name == 'show_actions_menu':
 
2488
            return self.show_actions_menu()
 
2489
        elif name == 'isbetauser':
 
2490
            return getattr(self.context, 'isBetaUser', False)
2545
2491
        else:
2546
2492
            raise TraversalError(name)
2547
2493
 
2557
2503
            pagetype = 'unset'
2558
2504
        return self._pagetypes[pagetype][layoutelement]
2559
2505
 
2560
 
    def isPageContentless(self):
2561
 
        """Should the template avoid rendering detailed information.
2562
 
 
2563
 
        Circumstances such as not possessing launchpad.View on a private
2564
 
        context require the template to not render detailed information. The
2565
 
        user may only know identifying information about the context.
2566
 
        """
2567
 
        view_context = self.context.context
2568
 
        privacy = IPrivacy(view_context, None)
2569
 
        if privacy is None or not privacy.private:
2570
 
            return False
2571
 
        can_view = check_permission('launchpad.View', view_context)
2572
 
        return not can_view
2573
 
 
2574
2506
    def pagetype(self):
2575
2507
        return getattr(self.context, '__pagetype__', 'unset')
2576
2508
 
2723
2655
            return getattr(self, name)(furtherPath)
2724
2656
        except AttributeError:
2725
2657
            raise TraversalError(name)
2726
 
 
2727
 
 
2728
 
class IRCNicknameFormatterAPI(ObjectFormatterAPI):
2729
 
    """Adapter from IrcID objects to a formatted string."""
2730
 
 
2731
 
    implements(ITraversable)
2732
 
 
2733
 
    traversable_names = {
2734
 
        'displayname': 'displayname',
2735
 
        'formatted_displayname': 'formatted_displayname',
2736
 
    }
2737
 
 
2738
 
    def displayname(self, view_name=None):
2739
 
        return "%s on %s" % (self._context.nickname, self._context.network)
2740
 
 
2741
 
    def formatted_displayname(self, view_name=None):
2742
 
        return dedent("""\
2743
 
            <strong>%s</strong>
2744
 
            <span class="discreet"> on </span>
2745
 
            <strong>%s</strong>
2746
 
        """ % (escape(self._context.nickname), escape(self._context.network)))