| [3149] | 1 | # -*- coding: utf-8 -*- |
|---|
| 2 | """ |
|---|
| [7911] | 3 | A Trac plugin which interfaces with the Hudson Continuous integration server |
|---|
| 4 | |
|---|
| 5 | You can configure this component via the |
|---|
| 6 | [wiki:TracIni#hudson-section "[hudson]"] |
|---|
| 7 | section in the trac.ini file. |
|---|
| 8 | |
|---|
| 9 | See also: |
|---|
| 10 | - http://hudson-ci.org/ |
|---|
| 11 | - http://wiki.hudson-ci.org/display/HUDSON/Trac+Plugin |
|---|
| [3149] | 12 | """ |
|---|
| 13 | |
|---|
| 14 | import time |
|---|
| [5698] | 15 | import urllib2 |
|---|
| [7895] | 16 | import base64 |
|---|
| [4593] | 17 | from datetime import datetime |
|---|
| [14251] | 18 | |
|---|
| 19 | from genshi.builder import tag |
|---|
| [3149] | 20 | from trac.core import * |
|---|
| [9471] | 21 | from trac.config import Option, BoolOption, ListOption |
|---|
| [6684] | 22 | from trac.perm import IPermissionRequestor |
|---|
| [6683] | 23 | from trac.util import Markup, format_datetime, pretty_timedelta |
|---|
| [7911] | 24 | from trac.util.text import unicode_quote |
|---|
| 25 | from trac.web.chrome import INavigationContributor, ITemplateProvider |
|---|
| 26 | from trac.web.chrome import add_stylesheet |
|---|
| [9470] | 27 | from trac.wiki.formatter import wiki_to_oneliner |
|---|
| [4593] | 28 | try: |
|---|
| 29 | from trac.timeline.api import ITimelineEventProvider |
|---|
| 30 | except ImportError: |
|---|
| 31 | from trac.Timeline import ITimelineEventProvider |
|---|
| [9468] | 32 | try: |
|---|
| 33 | from ast import literal_eval |
|---|
| 34 | except ImportError: |
|---|
| 35 | def literal_eval(str): |
|---|
| 36 | return eval(str, {"__builtins__":None}, {"True":True, "False":False}) |
|---|
| [3149] | 37 | |
|---|
| 38 | class HudsonTracPlugin(Component): |
|---|
| [7911] | 39 | """ |
|---|
| 40 | Display Hudson results in the timeline and an entry in the main navigation |
|---|
| 41 | bar. |
|---|
| 42 | """ |
|---|
| [3149] | 43 | |
|---|
| [7911] | 44 | implements(INavigationContributor, ITimelineEventProvider, |
|---|
| 45 | ITemplateProvider, IPermissionRequestor) |
|---|
| 46 | |
|---|
| [6683] | 47 | disp_mod = BoolOption('hudson', 'display_modules', 'false', |
|---|
| [9467] | 48 | 'Display status of modules in the timeline too. ') |
|---|
| [6683] | 49 | job_url = Option('hudson', 'job_url', 'http://localhost/hudson/', |
|---|
| [7911] | 50 | 'The url of the top-level hudson page if you want to ' |
|---|
| 51 | 'display all jobs, or a job or module url (such as ' |
|---|
| 52 | 'http://localhost/hudson/job/build_foo/) if you want ' |
|---|
| 53 | 'only display builds from a single job or module. ' |
|---|
| [6683] | 54 | 'This must be an absolute url.') |
|---|
| [5698] | 55 | username = Option('hudson', 'username', '', |
|---|
| [6683] | 56 | 'The username to use to access hudson') |
|---|
| [5698] | 57 | password = Option('hudson', 'password', '', |
|---|
| [11432] | 58 | 'The password to use to access hudson - but see also ' |
|---|
| 59 | 'the api_token field.') |
|---|
| 60 | api_token = Option('hudson', 'api_token', '', |
|---|
| 61 | 'The API Token to use to access hudson. This takes ' |
|---|
| 62 | 'precendence over any password and is the preferred ' |
|---|
| 63 | 'mechanism if you are running Jenkins 1.426 or later ' |
|---|
| 64 | 'and Jenkins is enforcing authentication (as opposed ' |
|---|
| 65 | 'to, for example, a proxy in front of Jenkins).') |
|---|
| [3149] | 66 | nav_url = Option('hudson', 'main_page', '/hudson/', |
|---|
| [7911] | 67 | 'The url of the hudson main page to which the trac nav ' |
|---|
| 68 | 'entry should link; if empty, no entry is created in ' |
|---|
| [4843] | 69 | 'the nav bar. This may be a relative url.') |
|---|
| [11431] | 70 | tl_label = Option('hudson', 'timeline_opt_label', 'Hudson Builds', |
|---|
| 71 | 'The label for the timeline option to display builds') |
|---|
| [4592] | 72 | disp_tab = BoolOption('hudson', 'display_in_new_tab', 'false', |
|---|
| [4843] | 73 | 'Open hudson page in new tab/window') |
|---|
| [6006] | 74 | alt_succ = BoolOption('hudson', 'alternate_success_icon', 'false', |
|---|
| [7911] | 75 | 'Use an alternate success icon (green ball instead ' |
|---|
| [6007] | 76 | 'of blue)') |
|---|
| 77 | use_desc = BoolOption('hudson', 'display_build_descriptions', 'true', |
|---|
| [7911] | 78 | 'Whether to display the build descriptions for ' |
|---|
| 79 | 'each build instead of the canned "Build finished ' |
|---|
| [6683] | 80 | 'successfully" etc messages.') |
|---|
| [9469] | 81 | disp_building = BoolOption('hudson', 'display_building', False, |
|---|
| 82 | 'Also show in-progress builds') |
|---|
| [9470] | 83 | list_changesets = BoolOption('hudson', 'list_changesets', False, |
|---|
| 84 | 'List the changesets for each build') |
|---|
| [9471] | 85 | disp_culprit = ListOption('hudson', 'display_culprit', [], doc = |
|---|
| 86 | 'Display the culprit(s) for each build. This is ' |
|---|
| 87 | 'a comma-separated list of zero or more of the ' |
|---|
| 88 | 'following tokens: `starter`, `author`, ' |
|---|
| 89 | '`authors`, `culprit`, `culprits`. `starter` is ' |
|---|
| 90 | 'the user that started the build, if any; ' |
|---|
| 91 | '`author` is the author of the first commit, if ' |
|---|
| 92 | 'any; `authors` is the list of authors of all ' |
|---|
| 93 | 'commits; `culprit` is the first of what hudson ' |
|---|
| 94 | 'thinks are the culprits that caused the build; ' |
|---|
| 95 | 'and `culprits` is the list of all culprits. If ' |
|---|
| 96 | 'given a list, the first non-empty value is used.' |
|---|
| 97 | ' Example: `starter,authors` (this would show ' |
|---|
| 98 | 'who started the build if it was started ' |
|---|
| 99 | 'manually, else list the authors of the commits ' |
|---|
| 100 | 'that triggered the build if any, else show no ' |
|---|
| 101 | 'author for the build).') |
|---|
| [3149] | 102 | |
|---|
| [5698] | 103 | def __init__(self): |
|---|
| [9467] | 104 | # get base api url |
|---|
| [7911] | 105 | api_url = unicode_quote(self.job_url, '/%:@') |
|---|
| 106 | if api_url and api_url[-1] != '/': |
|---|
| [6683] | 107 | api_url += '/' |
|---|
| [9468] | 108 | api_url += 'api/python' |
|---|
| [6100] | 109 | |
|---|
| [9467] | 110 | # set up http authentication |
|---|
| [11432] | 111 | if self.username and self.api_token: |
|---|
| 112 | handlers = [ |
|---|
| 113 | self.HTTPOpenHandlerBasicAuthNoChallenge(self.username, |
|---|
| 114 | self.api_token) |
|---|
| 115 | ] |
|---|
| 116 | elif self.username and self.password: |
|---|
| 117 | pwd_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm() |
|---|
| 118 | pwd_mgr.add_password(None, api_url, self.username, self.password) |
|---|
| [5698] | 119 | |
|---|
| [11432] | 120 | b_auth = urllib2.HTTPBasicAuthHandler(pwd_mgr) |
|---|
| 121 | d_auth = urllib2.HTTPDigestAuthHandler(pwd_mgr) |
|---|
| [5698] | 122 | |
|---|
| [11432] | 123 | handlers = [ b_auth, d_auth, self.HudsonFormLoginHandler(self) ] |
|---|
| 124 | else: |
|---|
| 125 | handlers = [] |
|---|
| [5698] | 126 | |
|---|
| [11432] | 127 | self.url_opener = urllib2.build_opener(*handlers) |
|---|
| 128 | if handlers: |
|---|
| 129 | self.env.log.debug("registered auth-handlers for '%s', " \ |
|---|
| 130 | "username='%s'", api_url, self.username) |
|---|
| [6100] | 131 | |
|---|
| [9467] | 132 | # construct tree=... parameter to query for the desired items |
|---|
| 133 | tree = '%(b)s' |
|---|
| 134 | if self.disp_mod: |
|---|
| 135 | tree += ',modules[%(b)s]' |
|---|
| 136 | if '/job/' not in api_url: |
|---|
| 137 | tree = 'jobs[' + tree + ']' |
|---|
| [6683] | 138 | |
|---|
| [9470] | 139 | items = 'builds[building,timestamp,duration,result,description,url,' \ |
|---|
| 140 | 'fullDisplayName' |
|---|
| [9471] | 141 | |
|---|
| 142 | elems = [] |
|---|
| [9470] | 143 | if self.list_changesets: |
|---|
| [9471] | 144 | elems.append('revision') |
|---|
| 145 | elems.append('id') |
|---|
| 146 | if 'author' in self.disp_culprit or 'authors' in self.disp_culprit: |
|---|
| 147 | elems.append('user') |
|---|
| 148 | elems.append('author[fullName]') |
|---|
| 149 | if elems: |
|---|
| 150 | items += ',changeSet[items[%s]]' % ','.join(elems) |
|---|
| 151 | |
|---|
| 152 | if 'culprit' in self.disp_culprit or 'culprits' in self.disp_culprit: |
|---|
| 153 | items += ',culprits[fullName]' |
|---|
| 154 | |
|---|
| 155 | if 'starter' in self.disp_culprit: |
|---|
| 156 | items += ',actions[causes[userName]]' |
|---|
| 157 | |
|---|
| [9470] | 158 | items += ']' |
|---|
| [6683] | 159 | |
|---|
| [9467] | 160 | # assemble final url |
|---|
| [9470] | 161 | tree = tree % {'b': items} |
|---|
| [9467] | 162 | self.info_url = '%s?tree=%s' % (api_url, tree) |
|---|
| 163 | |
|---|
| [7911] | 164 | self.env.log.debug("Build-info url: '%s'", self.info_url) |
|---|
| [6683] | 165 | |
|---|
| [9472] | 166 | def __get_info(self): |
|---|
| 167 | """Retrieve build information from Hudson""" |
|---|
| 168 | try: |
|---|
| 169 | local_exc = False |
|---|
| 170 | try: |
|---|
| 171 | resp = self.url_opener.open(self.info_url) |
|---|
| 172 | cset = resp.info().getparam('charset') or 'ISO-8859-1' |
|---|
| 173 | |
|---|
| 174 | ct = resp.info().gettype() |
|---|
| 175 | if ct != 'text/x-python': |
|---|
| 176 | local_exc = True |
|---|
| 177 | raise IOError( |
|---|
| 178 | "Error getting build info from '%s': returned document " |
|---|
| 179 | "has unexpected type '%s' (expected 'text/x-python'). " |
|---|
| 180 | "The returned text is:\n%s" % |
|---|
| 181 | (self.info_url, ct, unicode(resp.read(), cset))) |
|---|
| 182 | |
|---|
| 183 | info = literal_eval(resp.read()) |
|---|
| 184 | |
|---|
| 185 | return info, cset |
|---|
| 186 | except Exception: |
|---|
| 187 | if local_exc: |
|---|
| 188 | raise |
|---|
| 189 | |
|---|
| 190 | import sys |
|---|
| 191 | self.env.log.exception("Error getting build info from '%s'", |
|---|
| 192 | self.info_url) |
|---|
| 193 | raise IOError( |
|---|
| 194 | "Error getting build info from '%s': %s: %s. This most " |
|---|
| 195 | "likely means you configured a wrong job_url, username, " |
|---|
| 196 | "or password." % |
|---|
| 197 | (self.info_url, sys.exc_info()[0].__name__, |
|---|
| 198 | str(sys.exc_info()[1]))) |
|---|
| 199 | finally: |
|---|
| 200 | self.url_opener.close() |
|---|
| 201 | |
|---|
| 202 | def __find_all(self, d, paths): |
|---|
| 203 | """Find and return a list of all items with the given paths.""" |
|---|
| 204 | if not isinstance(paths, basestring): |
|---|
| 205 | for path in paths: |
|---|
| 206 | for item in self.__find_all(d, path): |
|---|
| 207 | yield item |
|---|
| 208 | return |
|---|
| 209 | |
|---|
| 210 | parts = paths.split('.', 1) |
|---|
| 211 | key = parts[0] |
|---|
| 212 | if key in d: |
|---|
| 213 | if len(parts) > 1: |
|---|
| 214 | for item in self.__find_all(d[key], parts[1]): |
|---|
| 215 | yield item |
|---|
| 216 | else: |
|---|
| 217 | yield d[key] |
|---|
| 218 | elif not isinstance(d, dict) and not isinstance(d, basestring): |
|---|
| 219 | for elem in d: |
|---|
| 220 | for item in self.__find_all(elem, paths): |
|---|
| 221 | yield item |
|---|
| 222 | |
|---|
| 223 | def __find_first(self, d, paths): |
|---|
| 224 | """Similar to __find_all, but return only the first item or None""" |
|---|
| 225 | l = list(self.__find_all(d, paths)) |
|---|
| 226 | return len(l) > 0 and l[0] or None |
|---|
| 227 | |
|---|
| 228 | def __extract_builds(self, info): |
|---|
| 229 | """Extract individual builds from the info returned by Hudson. |
|---|
| 230 | What we may get from Hudson is zero or more of the following: |
|---|
| 231 | {'jobs': [{'modules': [{'builds': [{'building': False, ... |
|---|
| 232 | {'jobs': [{'builds': [{'building': False, ... |
|---|
| 233 | {'modules': [{'builds': [{'building': False, ... |
|---|
| 234 | {'builds': [{'building': False, ... |
|---|
| 235 | """ |
|---|
| 236 | p = ['builds', 'modules.builds', 'jobs.builds', 'jobs.modules.builds'] |
|---|
| 237 | for arr in self.__find_all(info, p): |
|---|
| 238 | for item in arr: |
|---|
| 239 | yield item |
|---|
| 240 | |
|---|
| [6684] | 241 | # IPermissionRequestor methods |
|---|
| 242 | |
|---|
| 243 | def get_permission_actions(self): |
|---|
| 244 | return ['BUILD_VIEW'] |
|---|
| 245 | |
|---|
| [3149] | 246 | # INavigationContributor methods |
|---|
| 247 | |
|---|
| 248 | def get_active_navigation_item(self, req): |
|---|
| 249 | return 'builds' |
|---|
| 250 | |
|---|
| 251 | def get_navigation_items(self, req): |
|---|
| [7278] | 252 | if self.nav_url and req.perm.has_permission('BUILD_VIEW'): |
|---|
| [14251] | 253 | yield ('mainnav', 'builds', |
|---|
| 254 | tag.a('Builds', href=self.nav_url, |
|---|
| [14252] | 255 | target='hudson' if self.disp_tab else None)) |
|---|
| [3149] | 256 | |
|---|
| 257 | # ITemplateProvider methods |
|---|
| 258 | def get_templates_dirs(self): |
|---|
| [7912] | 259 | return [] |
|---|
| [3149] | 260 | |
|---|
| 261 | def get_htdocs_dirs(self): |
|---|
| 262 | from pkg_resources import resource_filename |
|---|
| 263 | return [('HudsonTrac', resource_filename(__name__, 'htdocs'))] |
|---|
| 264 | |
|---|
| 265 | # ITimelineEventProvider methods |
|---|
| 266 | |
|---|
| 267 | def get_timeline_filters(self, req): |
|---|
| [7278] | 268 | if req.perm.has_permission('BUILD_VIEW'): |
|---|
| [11431] | 269 | yield ('build', self.tl_label) |
|---|
| [3149] | 270 | |
|---|
| [9470] | 271 | def __fmt_changeset(self, rev, req): |
|---|
| 272 | # use format_to_oneliner and drop num_args hack when we drop Trac 0.10 |
|---|
| 273 | # support |
|---|
| 274 | import inspect |
|---|
| 275 | num_args = len(inspect.getargspec(wiki_to_oneliner)[0]) |
|---|
| 276 | if num_args > 5: |
|---|
| 277 | return wiki_to_oneliner('[%s]' % rev, self.env, req=req) |
|---|
| 278 | else: |
|---|
| 279 | return wiki_to_oneliner('[%s]' % rev, self.env) |
|---|
| 280 | |
|---|
| [3149] | 281 | def get_timeline_events(self, req, start, stop, filters): |
|---|
| [7278] | 282 | if 'build' not in filters or not req.perm.has_permission('BUILD_VIEW'): |
|---|
| [6097] | 283 | return |
|---|
| 284 | |
|---|
| [6683] | 285 | # Support both Trac 0.10 and 0.11 |
|---|
| [4843] | 286 | if isinstance(start, datetime): # Trac>=0.11 |
|---|
| [6099] | 287 | from trac.util.datefmt import to_timestamp |
|---|
| 288 | start = to_timestamp(start) |
|---|
| 289 | stop = to_timestamp(stop) |
|---|
| [4593] | 290 | |
|---|
| [6097] | 291 | add_stylesheet(req, 'HudsonTrac/hudsontrac.css') |
|---|
| [3149] | 292 | |
|---|
| [6683] | 293 | # get and parse the build-info |
|---|
| [9472] | 294 | info, cset = self.__get_info() |
|---|
| [9468] | 295 | |
|---|
| [6683] | 296 | # extract all build entries |
|---|
| [9472] | 297 | for entry in self.__extract_builds(info): |
|---|
| [9469] | 298 | # get result, optionally ignoring builds that are still running |
|---|
| 299 | if entry['building']: |
|---|
| 300 | if self.disp_building: |
|---|
| 301 | result = 'IN-PROGRESS' |
|---|
| 302 | else: |
|---|
| 303 | continue |
|---|
| 304 | else: |
|---|
| 305 | result = entry['result'] |
|---|
| [3149] | 306 | |
|---|
| [9468] | 307 | # get start/stop times |
|---|
| [9469] | 308 | started = entry['timestamp'] / 1000 |
|---|
| [9467] | 309 | if started < start or started > stop: |
|---|
| 310 | continue |
|---|
| 311 | |
|---|
| [9469] | 312 | if result == 'IN-PROGRESS': |
|---|
| 313 | # we hope the clocks are close... |
|---|
| 314 | completed = time.time() |
|---|
| 315 | else: |
|---|
| 316 | completed = (entry['timestamp'] + entry['duration']) / 1000 |
|---|
| 317 | |
|---|
| [9468] | 318 | # get message |
|---|
| [7911] | 319 | message, kind = { |
|---|
| 320 | 'SUCCESS': ('Build finished successfully', |
|---|
| 321 | ('build-successful', |
|---|
| 322 | 'build-successful-alt')[self.alt_succ]), |
|---|
| 323 | 'UNSTABLE': ('Build unstable', 'build-unstable'), |
|---|
| 324 | 'ABORTED': ('Build aborted', 'build-aborted'), |
|---|
| [9469] | 325 | 'IN-PROGRESS': ('Build in progress', |
|---|
| 326 | ('build-inprogress', |
|---|
| 327 | 'build-inprogress-alt')[self.alt_succ]), |
|---|
| [7911] | 328 | }.get(result, ('Build failed', 'build-failed')) |
|---|
| [3149] | 329 | |
|---|
| [6097] | 330 | if self.use_desc: |
|---|
| [9468] | 331 | message = entry['description'] and \ |
|---|
| 332 | unicode(entry['description'], cset) or message |
|---|
| [4594] | 333 | |
|---|
| [9470] | 334 | # get changesets |
|---|
| 335 | changesets = '' |
|---|
| 336 | if self.list_changesets: |
|---|
| 337 | paths = ['changeSet.items.revision', 'changeSet.items.id'] |
|---|
| [9472] | 338 | revs = [unicode(str(r), cset) for r in \ |
|---|
| 339 | self.__find_all(entry, paths)] |
|---|
| [9470] | 340 | if revs: |
|---|
| 341 | revs = [self.__fmt_changeset(r, req) for r in revs] |
|---|
| 342 | changesets = '<br/>Changesets: ' + ', '.join(revs) |
|---|
| 343 | |
|---|
| [9471] | 344 | # get author(s) |
|---|
| 345 | author = None |
|---|
| 346 | for c in self.disp_culprit: |
|---|
| 347 | author = { |
|---|
| [9472] | 348 | 'starter': |
|---|
| 349 | self.__find_first(entry, 'actions.causes.userName'), |
|---|
| 350 | 'author': |
|---|
| 351 | self.__find_first(entry, ['changeSet.items.user', |
|---|
| [9471] | 352 | 'changeSet.items.author.fullName']), |
|---|
| [9472] | 353 | 'authors': |
|---|
| 354 | self.__find_all(entry, ['changeSet.items.user', |
|---|
| [9471] | 355 | 'changeSet.items.author.fullName']), |
|---|
| [9472] | 356 | 'culprit': |
|---|
| 357 | self.__find_first(entry, 'culprits.fullName'), |
|---|
| 358 | 'culprits': |
|---|
| 359 | self.__find_all(entry, 'culprits.fullName'), |
|---|
| [9471] | 360 | }.get(c) |
|---|
| 361 | |
|---|
| 362 | if author and not isinstance(author, basestring): |
|---|
| 363 | author = ', '.join(set(author)) |
|---|
| 364 | if author: |
|---|
| 365 | author = unicode(author, cset) |
|---|
| 366 | break |
|---|
| 367 | |
|---|
| [9468] | 368 | # format response |
|---|
| [9469] | 369 | if result == 'IN-PROGRESS': |
|---|
| [9470] | 370 | comment = Markup("%s since %s, duration %s%s" % ( |
|---|
| 371 | message, format_datetime(started), |
|---|
| 372 | pretty_timedelta(started, completed), |
|---|
| 373 | changesets)) |
|---|
| [9469] | 374 | else: |
|---|
| [9470] | 375 | comment = Markup("%s at %s, duration %s%s" % ( |
|---|
| 376 | message, format_datetime(completed), |
|---|
| 377 | pretty_timedelta(started, completed), |
|---|
| 378 | changesets)) |
|---|
| [4594] | 379 | |
|---|
| [9468] | 380 | href = entry['url'] |
|---|
| [7911] | 381 | title = 'Build "%s" (%s)' % \ |
|---|
| [9468] | 382 | (unicode(entry['fullDisplayName'], cset), result.lower()) |
|---|
| [6007] | 383 | |
|---|
| [9471] | 384 | yield kind, href, title, completed, author, comment |
|---|
| [3149] | 385 | |
|---|
| [7895] | 386 | class HudsonFormLoginHandler(urllib2.BaseHandler): |
|---|
| 387 | def __init__(self, parent): |
|---|
| 388 | self.p = parent |
|---|
| 389 | |
|---|
| 390 | def http_error_403(self, req, fp, code, msg, headers): |
|---|
| 391 | for h in self.p.url_opener.handlers: |
|---|
| 392 | if isinstance(h, self.p.HTTPOpenHandlerBasicAuthNoChallenge): |
|---|
| 393 | return |
|---|
| 394 | |
|---|
| 395 | self.p.url_opener.add_handler( |
|---|
| [7911] | 396 | self.p.HTTPOpenHandlerBasicAuthNoChallenge(self.p.username, |
|---|
| 397 | self.p.password)) |
|---|
| 398 | self.p.env.log.debug( |
|---|
| 399 | "registered auth-handler for form-based authentication") |
|---|
| [7895] | 400 | |
|---|
| 401 | fp.close() |
|---|
| 402 | return self.p.url_opener.open(req) |
|---|
| 403 | |
|---|
| 404 | class HTTPOpenHandlerBasicAuthNoChallenge(urllib2.BaseHandler): |
|---|
| 405 | |
|---|
| 406 | auth_header = 'Authorization' |
|---|
| 407 | |
|---|
| 408 | def __init__(self, username, password): |
|---|
| 409 | raw = "%s:%s" % (username, password) |
|---|
| 410 | self.auth = 'Basic %s' % base64.b64encode(raw).strip() |
|---|
| 411 | |
|---|
| 412 | def default_open(self, req): |
|---|
| 413 | req.add_header(self.auth_header, self.auth) |
|---|
| 414 | |
|---|