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).
4
# pylint: disable-msg=C0103,W0613,R0911,F0401
4
6
"""Implementation of the lp: htmlform: fmt: namespaces in TALES."""
10
from datetime import (
14
12
from email.Utils import formatdate
19
from textwrap import dedent
21
from datetime import datetime, timedelta
22
22
from lazr.enum import enumerated_type_registry
23
23
from lazr.uri import URI
25
from z3c.ptcompat import ViewPageTemplateFile
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 (
33
from zope.error.interfaces import IErrorReportingUtility
34
from zope.interface import (
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 (
50
from lp.app.browser.badge import IHasBadges
51
from lp.app.browser.stringformatter import (
55
from lp.app.interfaces.launchpad import (
34
from zope.security.interfaces import Unauthorized
35
from zope.security.proxy import isinstance as zope_isinstance
36
from zope.schema import TextLine
39
from z3c.ptcompat import ViewPageTemplateFile
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 (
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 (
90
from lp.services.webapp.menu import (
94
from lp.services.webapp.publisher import (
95
get_current_browser_request,
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
669
638
"""The page title to be used.
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
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.
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:
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'
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):
688
title = title_object(view.context, view)
690
return module.DEFAULT_LAUNCHPAD_TITLE
698
693
# Use the reverse breadcrumbs.
699
694
return SEPARATOR.join(
700
695
breadcrumb.text for breadcrumb
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>'
1218
1209
return '<img src="%s" width="14" height="14" />' % custom_icon
1220
def link_display_name_id(self, view_name):
1221
"""Return a link to the user's profile page.
1223
The link text uses both the display name and Launchpad id to clearly
1224
indicate which user profile is linked.
1226
text = self.unique_displayname(None)
1227
return self._makeLink(view_name, 'mainsite', text)
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)
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.
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()
1249
1230
return super(TeamFormatterAPI, self).url(view_name, rootsite)
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()
1257
1237
return super(TeamFormatterAPI, self).api_url(context)
1263
1243
when the user does not have permission to review the team.
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(
1271
1250
return super(TeamFormatterAPI, self).link(view_name, rootsite)
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>'
1279
return super(TeamFormatterAPI, self).icon(view_name)
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)
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)
1299
def _report_visibility_leak(self):
1300
if bool(getFeatureFlag('disclosure.log_private_team_leaks.enabled')):
1301
request = get_current_browser_request()
1303
raise MixedVisibilityError()
1304
except MixedVisibilityError:
1305
getUtility(IErrorReportingUtility).raising(
1306
sys.exc_info(), request)
1309
1269
class CustomizableFormatter(ObjectFormatterAPI):
1310
1270
"""A ObjectFormatterAPI that is easy to customize.
1441
1394
return super(PillarFormatterAPI, self).url(view_name, rootsite)
1443
def _getLinkHTML(self, view_name, rootsite,
1444
template, custom_icon_template):
1445
"""Generates html, mapping a link context to given templates.
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).
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()).
1458
context = self._context
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(),
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
1472
1396
def link(self, view_name, rootsite='mainsite'):
1473
1397
"""The html to show a link to a Product, ProjectGroup or distribution.
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.
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>'
1484
return self._getLinkHTML(
1485
view_name, rootsite, template, custom_icon_template)
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.
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.
1494
super(PillarFormatterAPI, self).link(view_name)
1496
u'<a href="%(url)s" class="%(css_class)s">%(displayname)s</a>'
1497
u' (<a href="%(url)s">%(name)s</a>)'
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> (<a href="%(url)s">%(name)s</a>)'
1504
return self._getLinkHTML(
1505
view_name, rootsite, template, custom_icon_template)
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)
1414
html = (u'<a href="%s" class="bg-image" '
1415
'style="background-image: url(%s)">%s</a>') % (
1416
url, custom_icon, summary)
1508
1420
class DistroSeriesFormatterAPI(CustomizableFormatter):
2179
2085
def isodate(self):
2180
2086
return self._datetime.isoformat()
2183
def _yearDelta(old, new):
2184
"""Return the difference in years between two datetimes.
2186
:param old: The old date
2187
:param new: The new date
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:
2195
def durationsince(self):
2196
"""How long since the datetime, as a string."""
2198
number = self._yearDelta(self._datetime, now)
2201
delta = now - self._datetime
2206
number = delta.seconds / 60
2208
return 'less than a minute'
2215
return '%d %s' % (number, unit)
2218
2089
class SeriesSourcePackageBranchFormatter(ObjectFormatterAPI):
2219
2090
"""Formatter for a SourcePackage, Pocket -> Branch link.
2277
2151
return ', '.join(parts)
2279
def approximateduration(self):
2153
def approximateduration(self, use_words=True):
2280
2154
"""Return a nicely-formatted approximate duration.
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
2284
2159
See https://launchpad.canonical.com/PresentingLengthsOfTime.
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.
2286
2167
# NOTE: There are quite a few "magic numbers" in this
2287
2168
# implementation; they are generally just figures pulled
2332
2215
# Return the corresponding display value.
2333
2216
return display_values[matching_element_index]
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")
2226
2: 'two', 3: 'three', 4: 'four', 5: 'five',
2227
6: 'six', 7: 'seven', 8: 'eight', 9: 'nine',
2230
number_name = dict((number, number) for number in range(2, 11))
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))
2340
2237
if minutes <= 59:
2341
return "%d minutes" % minutes
2238
return "%s minutes" % number_name.get(minutes, str(minutes))
2343
2240
# Is the duration less than an hour and 5 minutes?
2344
2241
if seconds < (60 + 5) * 60:
2347
2247
# Next phase: try and calculate an approximate duration
2348
2248
# greater than one hour, but fewer than ten hours, to a 10
2376
2277
# Is the duration fewer than two and a half days?
2377
2278
if seconds < (2.5 * 24 * 3600):
2279
return '%s days' % number_name[2]
2380
2281
# Try to approximate to day granularity, up to a maximum of 13
2382
2283
days = int(round(seconds / (24 * 3600)))
2384
return "%s days" % days
2285
return "%s days" % number_name.get(days, str(days))
2386
2287
# Is the duration fewer than two and a half weeks?
2387
2288
if seconds < (2.5 * 7 * 24 * 3600):
2289
return '%s weeks' % number_name[2]
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
2395
def millisecondduration(self):
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))
2402
2297
class LinkFormatterAPI(ObjectFormatterAPI):
2457
2352
return clean_path_split
2355
class PageTemplateContextsAPI:
2356
"""Adapter from page tempate's CONTEXTS object to fmt:pagetitle.
2358
This is registered to be used for the dict type.
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)
2364
def __init__(self, contextdict):
2365
self.contextdict = contextdict
2367
def traverse(self, name, furtherPath):
2368
if name == 'pagetitle':
2369
return self.pagetitle()
2371
raise TraversalError(name)
2373
def pagetitle(self):
2374
"""Return the string title for the page template CONTEXTS dict.
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.
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
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 "
2394
elif isinstance(titleobj, basestring):
2397
context = self.contextdict['context']
2398
view = self.contextdict['view']
2399
title = titleobj(context, view)
2401
return canonical.launchpad.pagetitles.DEFAULT_LAUNCHPAD_TITLE
2460
2406
class PermissionRequiredQuery:
2461
2407
"""Check if the logged in user has a given permission on a given object.
2557
2503
pagetype = 'unset'
2558
2504
return self._pagetypes[pagetype][layoutelement]
2560
def isPageContentless(self):
2561
"""Should the template avoid rendering detailed information.
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.
2567
view_context = self.context.context
2568
privacy = IPrivacy(view_context, None)
2569
if privacy is None or not privacy.private:
2571
can_view = check_permission('launchpad.View', view_context)
2574
2506
def pagetype(self):
2575
2507
return getattr(self.context, '__pagetype__', 'unset')
2723
2655
return getattr(self, name)(furtherPath)
2724
2656
except AttributeError:
2725
2657
raise TraversalError(name)
2728
class IRCNicknameFormatterAPI(ObjectFormatterAPI):
2729
"""Adapter from IrcID objects to a formatted string."""
2731
implements(ITraversable)
2733
traversable_names = {
2734
'displayname': 'displayname',
2735
'formatted_displayname': 'formatted_displayname',
2738
def displayname(self, view_name=None):
2739
return "%s on %s" % (self._context.nickname, self._context.network)
2741
def formatted_displayname(self, view_name=None):
2744
<span class="discreet"> on </span>
2746
""" % (escape(self._context.nickname), escape(self._context.network)))