Merge http://www.physics.drexel.edu/~wking/code/git/be
[be.git] / libbe / command / html.py
1 # Copyright (C) 2009-2011 Chris Ball <cjb@laptop.org>
2 #                         Gianluca Montecchi <gian@grys.it>
3 #                         Mathieu Clabaut <mathieu.clabaut@gmail.com>
4 #                         W. Trevor King <wking@drexel.edu>
5 #
6 # This file is part of Bugs Everywhere.
7 #
8 # Bugs Everywhere is free software; you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by the
10 # Free Software Foundation, either version 2 of the License, or (at your
11 # option) any later version.
12 #
13 # Bugs Everywhere is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16 # General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with Bugs Everywhere.  If not, see <http://www.gnu.org/licenses/>.
20
21 import codecs
22 import htmlentitydefs
23 import os
24 import os.path
25 import re
26 import string
27 import time
28 import xml.sax.saxutils
29
30 from jinja2 import Environment, FileSystemLoader, DictLoader, ChoiceLoader
31
32 import libbe
33 import libbe.command
34 import libbe.command.util
35 import libbe.comment
36 import libbe.util.encoding
37 import libbe.util.id
38 import libbe.command.depend
39
40
41 class HTML (libbe.command.Command):
42     """Generate a static HTML dump of the current repository status
43
44     >>> import sys
45     >>> import libbe.bugdir
46     >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
47     >>> io = libbe.command.StringInputOutput()
48     >>> io.stdout = sys.stdout
49     >>> ui = libbe.command.UserInterface(io=io)
50     >>> ui.storage_callbacks.set_storage(bd.storage)
51     >>> cmd = HTML(ui=ui)
52
53     >>> ret = ui.run(cmd, {'output':os.path.join(bd.storage.repo, 'html_export')})
54     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export'))
55     True
56     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index.html'))
57     True
58     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'index_inactive.html'))
59     True
60     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs'))
61     True
62     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'a', 'index.html'))
63     True
64     >>> os.path.exists(os.path.join(bd.storage.repo, 'html_export', 'bugs', 'b', 'index.html'))
65     True
66     >>> ui.cleanup()
67     >>> bd.cleanup()
68     """
69     name = 'html'
70
71     def __init__(self, *args, **kwargs):
72         libbe.command.Command.__init__(self, *args, **kwargs)
73         self.options.extend([
74                 libbe.command.Option(name='output', short_name='o',
75                     help='Set the output path (%default)',
76                     arg=libbe.command.Argument(
77                         name='output', metavar='DIR', default='./html_export',
78                         completion_callback=libbe.command.util.complete_path)),
79                 libbe.command.Option(name='template-dir', short_name='t',
80                     help='Use a different template.  Defaults to internal templates',
81                     arg=libbe.command.Argument(
82                         name='template-dir', metavar='DIR',
83                         completion_callback=libbe.command.util.complete_path)),
84                 libbe.command.Option(name='title',
85                     help='Set the bug repository title (%default)',
86                     arg=libbe.command.Argument(
87                         name='title', metavar='STRING',
88                         default='Bugs Everywhere Issue Tracker')),
89                 libbe.command.Option(name='index-header',
90                     help='Set the index page headers (%default)',
91                     arg=libbe.command.Argument(
92                         name='index-header', metavar='STRING',
93                         default='Bugs Everywhere Bug List')),
94                 libbe.command.Option(name='export-template', short_name='e',
95                     help='Export the default template and exit.'),
96                 libbe.command.Option(name='export-template-dir', short_name='d',
97                     help='Set the directory for the template export (%default)',
98                     arg=libbe.command.Argument(
99                         name='export-template-dir', metavar='DIR',
100                         default='./default-templates/',
101                         completion_callback=libbe.command.util.complete_path)),
102                 libbe.command.Option(name='min-id-length', short_name='l',
103                     help='Attempt to truncate bug and comment IDs to this length.  Set to -1 for non-truncated IDs (%default)',
104                     arg=libbe.command.Argument(
105                         name='min-id-length', metavar='INT',
106                         default=-1, type='int')),
107                 libbe.command.Option(name='verbose', short_name='v',
108                     help='Verbose output, default is %default'),
109                 ])
110
111     def _run(self, **params):
112         if params['export-template'] == True:
113             bugdir = None
114         else:
115             bugdir = self._get_bugdir()
116             bugdir.load_all_bugs()
117         html_gen = HTMLGen(bugdir,
118                            template_dir=params['template-dir'],
119                            title=params['title'],
120                            header=params['index-header'],
121                            min_id_length=params['min-id-length'],
122                            verbose=params['verbose'],
123                            stdout=self.stdout)
124         if params['export-template'] == True:
125             html_gen.write_default_template(params['export-template-dir'])
126         else:
127             html_gen.run(params['output'])
128
129     def _long_help(self):
130         return """
131 Generate a set of html pages representing the current state of the bug
132 directory.
133 """
134
135 Html = HTML # alias for libbe.command.base.get_command_class()
136
137 class HTMLGen (object):
138     def __init__(self, bd, template_dir=None,
139                  title="Site Title", header="Header",
140                  min_id_length=-1,
141                  verbose=False, encoding=None, stdout=None,
142                  ):
143         self.generation_time = time.ctime()
144         self.bd = bd
145         self.title = title
146         self.header = header
147         self.verbose = verbose
148         self.stdout = stdout
149         if encoding != None:
150             self.encoding = encoding
151         else:
152             self.encoding = libbe.util.encoding.get_text_file_encoding()
153         self._load_templates(template_dir)
154         self.min_id_length = min_id_length
155
156     def run(self, out_dir):
157         if self.verbose == True:
158             print >> self.stdout, \
159                 'Creating the html output in %s using templates in %s' \
160                 % (out_dir, self.template)
161
162         bugs_active = []
163         bugs_inactive = []
164         bugs_target = []
165         bugs = [b for b in self.bd]
166         bugs.sort()
167         
168         for b in bugs:
169             if  b.active == True and b.severity != 'target':
170                 bugs_active.append(b)
171             if b.active != True and b.severity != 'target':
172                 bugs_inactive.append(b)
173             if b.severity == 'target':
174                 bugs_target.append(b)
175         
176         self._create_output_directories(out_dir)
177         self._write_css_file()
178         for b in bugs:
179             if b.severity == 'target':
180                 up_link = '../../index_target.html'
181             elif b.active:
182                 up_link = '../../index.html'
183             else:
184                 up_link = '../../index_inactive.html'                
185             self._write_bug_file(
186                 b, title=self.title, header=self.header,
187                 up_link=up_link)
188         self._write_index_file(
189             bugs_active, title=self.title,
190             header=self.header, bug_type='active')
191         self._write_index_file(
192             bugs_inactive, title=self.title,
193             header=self.header, bug_type='inactive')
194         self._write_index_file(
195             bugs_target, title=self.title,
196             header=self.header, bug_type='target')
197
198     def _truncated_bug_id(self, bug):
199         return libbe.util.id._truncate(
200             bug.uuid, bug.sibling_uuids(),
201             min_length=self.min_id_length)
202
203     def _truncated_comment_id(self, comment):
204         return libbe.util.id._truncate(
205             comment.uuid, comment.sibling_uuids(),
206             min_length=self.min_id_length)
207
208     def _create_output_directories(self, out_dir):
209         if self.verbose:
210             print >> self.stdout, 'Creating output directories'
211         self.out_dir = self._make_dir(out_dir)
212         self.out_dir_bugs = self._make_dir(
213             os.path.join(self.out_dir, 'bugs'))
214
215     def _write_css_file(self):
216         if self.verbose:
217             print >> self.stdout, 'Writing css file'
218         assert hasattr(self, 'out_dir'), \
219             'Must run after ._create_output_directories()'
220         template = self.template.get_template('style.css')
221         self._write_file(template.render(), [self.out_dir, 'style.css'])
222
223     def _write_bug_file(self, bug, title, header, up_link):
224         if self.verbose:
225             print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
226         assert hasattr(self, 'out_dir_bugs'), \
227             'Must run after ._create_output_directories()'
228         index_type = ''
229             
230         if bug.active == True:
231             index_type = 'Active'
232         else:
233             index_type = 'Inactive'
234         if bug.severity == 'target':
235             index_type = 'Target'
236                 
237         bug.load_comments(load_full=True)
238         bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
239         dirname = self._truncated_bug_id(bug)
240         fullpath = os.path.join(self.out_dir_bugs, dirname, 'index.html')
241         template_info = {
242             'title': title,
243             'charset': self.encoding,
244             'stylesheet': '../../style.css',
245             'header': header,
246             'backlinks': self.template.get_template('bug_backlinks.html'),
247             'up_link': up_link,
248             'index_type': index_type,
249             'bug': bug,
250             'comment_entry': self.template.get_template(
251                 'bug_comment_entry.html'),
252             'comments': [(depth,comment) for depth,comment
253                          in bug.comment_root.thread(flatten=False)],
254             'comment_dir': self._truncated_comment_id,
255             'format_body': self._format_comment_body,
256             'div_close': _DivCloser(),
257             'generation_time': self.generation_time,
258             }
259         fulldir = os.path.join(self.out_dir_bugs, dirname)
260         if not os.path.exists(fulldir):
261             os.mkdir(fulldir)
262         template = self.template.get_template('bug.html')
263         self._write_file(template.render(template_info), [fullpath])
264
265     def _write_index_file(self, bugs, title, header, bug_type='active'):
266         if self.verbose:
267             print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
268         assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
269
270         if bug_type == 'active':
271             filename = 'index.html'
272         elif bug_type == 'inactive':
273             filename = 'index_inactive.html'
274         elif bug_type == 'target':
275             filename = 'index_by_target.html'
276         else:
277             raise ValueError('unrecognized bug_type: "%s"' % bug_type)
278
279         template_info = {
280             'title': title,
281             'charset': self.encoding,
282             'stylesheet': 'style.css',
283             'header': header,
284             'active_class': 'tab nsel',
285             'inactive_class': 'tab nsel',
286             'target_class': 'tab nsel',
287             'bugs': bugs,
288             'bug_entry': self.template.get_template('index_bug_entry.html'),
289             'bug_dir': self._truncated_bug_id,
290             'generation_time': self.generation_time,
291             }
292         template_info['%s_class' % bug_type] = 'tab sel'
293         if bug_type == 'target':
294             template = self.template.get_template('target_index.html')
295             template_info['targets'] = [
296                 (target, sorted(libbe.command.depend.get_blocked_by(
297                             self.bd, target)))
298                 for target in bugs]
299         else:
300             template = self.template.get_template('standard_index.html')           
301         self._write_file(
302             template.render(template_info)+'\n', [self.out_dir,filename])
303
304     def _long_to_linked_user(self, text):
305         """
306         >>> import libbe.bugdir
307         >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
308         >>> h = HTMLGen(bd)
309         >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
310         'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.'
311         >>> bd.cleanup()
312         """
313         replacer = libbe.util.id.IDreplacer(
314             [self.bd], self._long_to_linked_user_replacer, wrap=False)
315         return re.sub(
316             libbe.util.id.REGEXP, replacer, text)
317
318     def _long_to_linked_user_replacer(self, bugdirs, long_id):
319         """
320         >>> import libbe.bugdir
321         >>> import libbe.util.id
322         >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
323         >>> a = bd.bug_from_uuid('a')
324         >>> uuid_gen = libbe.util.id.uuid_gen
325         >>> libbe.util.id.uuid_gen = lambda : '0123'
326         >>> c = a.new_comment('comment for link testing')
327         >>> libbe.util.id.uuid_gen = uuid_gen
328         >>> c.uuid
329         '0123'
330         >>> h = HTMLGen(bd)
331         >>> h._long_to_linked_user_replacer([bd], 'abc123')
332         '#abc123#'
333         >>> h._long_to_linked_user_replacer([bd], 'abc123/a')
334         '<a href="./a/">abc/a</a>'
335         >>> h._long_to_linked_user_replacer([bd], 'abc123/a/0123')
336         '<a href="./a/#0123">abc/a/012</a>'
337         >>> h._long_to_linked_user_replacer([bd], 'x')
338         '#x#'
339         >>> h._long_to_linked_user_replacer([bd], '')
340         '##'
341         >>> bd.cleanup()
342         """
343         try:
344             p = libbe.util.id.parse_user(bugdirs[0], long_id)
345         except (libbe.util.id.MultipleIDMatches,
346                 libbe.util.id.NoIDMatches,
347                 libbe.util.id.InvalidIDStructure), e:
348             return '#%s#' % long_id # re-wrap failures
349         if p['type'] == 'bugdir':
350             return '#%s#' % long_id
351         elif p['type'] == 'bug':
352             bug,comment = libbe.command.util.bug_comment_from_user_id(
353                 bugdirs[0], long_id)
354             return '<a href="./%s/">%s</a>' \
355                 % (self._truncated_bug_id(bug), bug.id.user())
356         elif p['type'] == 'comment':
357             bug,comment = libbe.command.util.bug_comment_from_user_id(
358                 bugdirs[0], long_id)
359             return '<a href="./%s/#%s">%s</a>' \
360                 % (self._truncated_bug_id(bug),
361                    self._truncated_comment_id(comment),
362                    comment.id.user())
363         raise Exception('Invalid id type %s for "%s"'
364                         % (p['type'], long_id))
365
366     def _format_comment_body(self, bug, comment):
367         link_long_ids = False
368         save_body = False
369         value = comment.body
370         if comment.content_type == 'text/html':
371             link_long_ids = True
372         elif comment.content_type.startswith('text/'):
373             value = '<pre>\n'+self._escape(value)+'\n</pre>'
374             link_long_ids = True
375         elif comment.content_type.startswith('image/'):
376             save_body = True
377             value = '<img src="./%s/%s" />' % (
378                 self._truncated_bug_id(bug),
379                 self._truncated_comment_id(comment))
380         else:
381             save_body = True
382             value = '<a href="./%s/%s">Link to %s file</a>.' % (
383                 self._truncated_bug_id(bug),
384                 self._truncated_comment_id(comment),
385                 comment.content_type)
386         if link_long_ids == True:
387             value = self._long_to_linked_user(value)
388         if save_body == True:
389             per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
390             if not os.path.exists(per_bug_dir):
391                 os.mkdir(per_bug_dir)
392             comment_path = os.path.join(per_bug_dir, comment.uuid)
393             self._write_file(
394                 '<Files %s>\n  ForceType %s\n</Files>' \
395                     % (comment.uuid, comment.content_type),
396                 [per_bug_dir, '.htaccess'], mode='a')
397             self._write_file(comment.body,
398                              [per_bug_dir, comment.uuid], mode='wb')
399         return value
400
401     def _escape(self, string):
402         if string == None:
403             return ''
404         return xml.sax.saxutils.escape(string)
405
406     def _make_dir(self, dir_path):
407         dir_path = os.path.abspath(os.path.expanduser(dir_path))
408         if not os.path.exists(dir_path):
409             try:
410                 os.makedirs(dir_path)
411             except:
412                 raise libbe.command.UserError(
413                     'Cannot create output directory "%s".' % dir_path)
414         return dir_path
415
416     def _write_file(self, content, path_array, mode='w'):
417         return libbe.util.encoding.set_file_contents(
418             os.path.join(*path_array), content, mode, self.encoding)
419
420     def _read_file(self, path_array, mode='r'):
421         return libbe.util.encoding.get_file_contents(
422             os.path.join(*path_array), mode, self.encoding, decode=True)
423
424     def write_default_template(self, out_dir):
425         if self.verbose:
426             print >> self.stdout, 'Creating output directories'
427         self.out_dir = self._make_dir(out_dir)
428         for filename,text in self.template_dict.iteritems():
429             if self.verbose:
430                 print >> self.stdout, 'Creating %s file'
431             self._write_file(text, [self.out_dir, filename])
432
433     def _load_templates(self, template_dir=None):
434         if template_dir is not None:
435             template_dir = os.path.abspath(os.path.expanduser(template_dir))
436
437         self.template_dict = {
438 ##
439             'style.css':
440 """body {
441   font-family: "lucida grande", "sans serif";
442   font-size: 14px;
443   color: #333;
444   width: auto;
445   margin: auto;
446 }
447
448 div.main {
449   padding: 20px;
450   margin: auto;
451   padding-top: 0;
452   margin-top: 1em;
453   background-color: #fcfcfc;
454   -moz-border-radius: 10px;
455   
456 }
457
458 div.footer {
459   font-size: small;
460   padding-left: 20px;
461   padding-right: 20px;
462   padding-top: 5px;
463   padding-bottom: 5px;
464   margin: auto;
465   background: #305275;
466   color: #fffee7;
467   -moz-border-radius: 10px;
468 }
469
470 div.header {
471     font-size: xx-large;
472     padding-left: 20px;
473     padding-right: 20px;
474     padding-top: 10px;
475     font-weight:bold;
476     padding-bottom: 10px;
477     background: #305275;
478     color: #fffee7;
479     -moz-border-radius: 10px;
480 }
481
482 th.target_name {
483     text-align:left;
484     border: 1px solid;
485     border-color: #305275;
486     background-color: #305275;
487     color: #fff;
488     width: auto%;
489     -moz-border-radius-topleft: 8px;
490     -moz-border-radius-topright: 8px;
491     padding-left: 5px;
492     padding-right: 5px;
493 }
494
495 table {
496   border-style: solid;
497   border: 1px #c3d9ff;
498   border-spacing: 0px 0px;
499   width: auto;
500   padding: 0px;
501   
502   }
503
504 tb { border: 1px; }
505
506 tr {
507   vertical-align: top;
508   border: 1px #c3d9ff;
509   border-style: dotted;
510   width: auto;
511   padding: 0px;
512 }
513
514 th {
515     border-width: 1px;
516     border-style: solid;
517     border-color: #c3d9ff;
518     border-collapse: collapse;
519     padding-left: 5px;
520     padding-right: 5px;
521 }
522
523
524 td {
525     border-width: 1px;
526     border-color: #c3d9ff;
527     border-collapse: collapse;
528     padding-left: 5px;
529     padding-right: 5px;
530     width: auto%;
531 }
532
533 img { border-style: none; }
534
535 ul {
536   list-style-type: none;
537   padding: 0;
538 }
539
540 p { width: auto; }
541
542 p.backlink {
543   width: auto;
544   font-weight: bold;
545 }
546
547 a {
548   background: inherit;
549   text-decoration: none;
550 }
551
552 a { color: #553d41; }
553 a:hover { color: #003d41; }
554 a:visited { color: #305275; }
555 .footer a { color: #508d91; }
556
557 /* bug index pages */
558
559 td.tab {
560   padding-right: 1em;
561   padding-left: 1em;
562 }
563
564 td.sel.tab {
565     background-color: #c3d9ff ;
566     border: 1px solid #c3d9ff;
567     font-weight:bold;    
568     -moz-border-radius-topleft: 15px;
569     -moz-border-radius-topright: 15px;
570 }
571
572 td.nsel.tab { 
573     border: 1px solid #c3d9ff;
574     font-weight:bold;    
575     -moz-border-radius-topleft: 5px;
576     -moz-border-radius-topright: 5px;
577 }
578
579 table.bug_list {
580     border-width: 1px;
581     border-style: solid;
582     border-color: #c3d9ff;
583     padding: 0px;
584     width: 100%;            
585     border: 1px solid #c3d9ff;
586 }
587
588 table.target_list {
589     padding: 0px;
590     width: 100%;
591     margin-bottom: 10px;
592 }
593
594 table.target_list.td {
595     border-width: 1px;
596 }
597
598 tr.wishlist { background-color: #DCFAFF;}
599 tr.wishlist:hover { background-color: #C2DCE1; }
600
601 tr.minor { background-color: #FFFFA6; }
602 tr.minor:hover { background-color: #E6E696; }
603
604 tr.serious { background-color: #FF9077;}
605 tr.serious:hover { background-color: #E6826B; }
606
607 tr.critical { background-color: #FF752A; }
608 tr.critical:hover { background-color: #D63905;}
609
610 tr.fatal { background-color: #FF3300;}
611 tr.fatal:hover { background-color: #D60000;}
612
613 td.uuid { width: 5%; border-style: dotted;}
614 td.status { width: 5%; border-style: dotted;}
615 td.severity { width: 5%; border-style: dotted;}
616 td.summary { border-style: dotted;}
617 td.date { width: 25%; border-style: dotted;}
618
619 /* bug detail pages */
620
621 td.bug_detail_label { text-align: right; border: none;}
622 td.bug_detail { border: none;}
623 td.bug_comment_label { text-align: right; vertical-align: top; }
624 td.bug_comment { }
625
626 div.comment {
627   padding: 20px;
628   padding-top: 20px;
629   margin: auto;
630   margin-top: 0;
631 }
632
633 div.root.comment {
634   padding: 0px;
635   /* padding-top: 0px; */
636   padding-bottom: 20px;
637 }
638 """,
639 ##
640             'base.html':
641 """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
642   "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
643 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
644   <head>
645     <title>{{ title }}</title>
646     <meta http-equiv="Content-Type" content="text/html; charset={{ charset }}" />
647     <link rel="stylesheet" href="{{ stylesheet }}" type="text/css" />
648   </head>
649   <body>
650     <div class="header">{{ header }}</div>
651     <div class="main">
652       {% block content %}{% endblock %}
653     </div>
654     <div class="footer">
655       <p>Generated by <a href="http://www.bugseverywhere.org/">
656       Bugs Everywhere</a> on {{ generation_time }}</p>
657       <p>
658         <a href="http://validator.w3.org/check?uri=referer">
659           Validate XHTML</a>&nbsp;|&nbsp;
660         <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">
661           Validate CSS</a>
662       </p>
663     </div>
664   </body>
665 </html>
666 """,
667             'index.html':
668 """{% extends "base.html" %}
669
670 {% block content %}
671 <table>
672   <tbody>
673     <tr>
674       <td class="{{ active_class }}"><a href="index.html">Active Bugs</a></td>
675       <td class="{{ inactive_class }}"><a href="index_inactive.html">Inactive Bugs</a></td>
676       <td class="{{ target_class }}"><a href="index_by_target.html">Divided by target</a></td>
677     </tr>
678   </tbody>
679 </table>
680 {% if bugs %}
681 {% block bug_table %}{% endblock %}
682 {% else %}
683 <p>No bugs.</p>
684 {% endif %}
685 {% endblock %}
686 """,
687 ##
688             'standard_index.html':
689 """{% extends "index.html" %}
690
691 {% block bug_table %}
692 <table class="bug_list">
693   <thead>
694     <tr>
695       <th>UUID</th>
696       <th>Status</th>
697       <th>Severity</th>
698       <th>Summary</th>
699       <th>Date</th>
700     </tr>
701   </thead>
702   <tbody>
703     {% for bug in bugs %}
704     {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug)}) }}
705     {% endfor %}
706   </tbody>
707 </table>
708 {% endblock %}
709 """,
710 ##
711         'target_index.html':
712 """{% extends "index.html" %}
713
714 {% block bug_table %}
715 {% for target,bugs in targets %}
716 <table class="target_list">
717   <thead>
718     <tr>
719       <th class="target_name" colspan="5">
720         Target: {{ target.summary|e }} ({{ target.status|e }})
721       </th>
722     </tr>
723     <tr>
724       <th>UUID</th>
725       <th>Status</th>
726       <th>Severity</th>
727       <th>Summary</th>
728       <th>Date</th>
729     </tr>
730   </thead>
731   <tbody>
732     {% for bug in bugs %}
733     {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug)}) }}
734     {% endfor %}
735   </tbody>
736 </table>
737 {% endfor %}
738 {% endblock %}
739 """,
740 ##
741             'index_bug_entry.html':
742 """<tr class="{{ bug.severity }}">
743   <td class="uuid"><a href="bugs/{{ dir }}/index.html">{{ bug.id.user()|e }}</a></td>
744   <td class="status"><a href="bugs/{{ dir }}/index.html">{{ bug.status|e }}</a></td>
745   <td class="severity"><a href="bugs/{{ dir }}/index.html">{{ bug.severity|e }}</a></td>
746   <td class="summary"><a href="bugs/{{ dir }}/index.html">{{ bug.summary|e }}</a></td>
747   <td class="date"><a href="bugs/{{ dir }}/index.html">{{ (bug.time_string or '')|e }}</a></td>
748 </tr>
749 """,
750 ##
751         'bug.html':
752 """{% extends "base.html" %}
753
754 {% block content %}
755 {{ backlinks.render({'up_link': up_link, 'index_type':index_type}) }}
756 <h1>Bug: {{ bug.id.user()|e }}</h1>
757
758 <table>
759   <tbody>
760     <tr><td class="bug_detail_label">ID :</td>
761         <td class="bug_detail">{{ bug.uuid|e }}</td></tr>
762     <tr><td class="bug_detail_label">Short name :</td>
763         <td class="bug_detail">{{ bug.id.user()|e }}</td></tr>
764     <tr><td class="bug_detail_label">Status :</td>
765         <td class="bug_detail">{{ bug.status|e }}</td></tr>
766     <tr><td class="bug_detail_label">Severity :</td>
767         <td class="bug_detail">{{ bug.severity|e }}</td></tr>
768     <tr><td class="bug_detail_label">Assigned :</td>
769         <td class="bug_detail">{{ (bug.assigned or '')|e }}</td></tr>
770     <tr><td class="bug_detail_label">Reporter :</td>
771         <td class="bug_detail">{{ (bug.reporter or '')|e }}</td></tr>
772     <tr><td class="bug_detail_label">Creator :</td>
773         <td class="bug_detail">{{ (bug.creator or '')|e }}</td></tr>
774     <tr><td class="bug_detail_label">Created :</td>
775         <td class="bug_detail">{{ (bug.time_string or '')|e }}</td></tr>
776     <tr><td class="bug_detail_label">Summary :</td>
777         <td class="bug_detail">{{ bug.summary|e }}</td></tr>
778   </tbody>
779 </table>
780
781 <hr/>
782
783 {% if comments %}
784 {% for depth,comment in comments %}
785 {% if depth == 0 %}
786 <div class="comment root" id="C{{ comment_dir(comment) }}">
787 {% else %}
788 <div class="comment" id="C{{ comment_dir(comment) }}">
789 {% endif %}
790 {{ comment_entry.render({
791        'depth':depth, 'bug': bug, 'comment':comment, 'comment_dir':comment_dir,
792        'format_body': format_body, 'div_close': div_close}) }}
793 {{ div_close(depth) }}
794 {% endfor %}
795 {% if comments[-1][0] > 0 %}
796 {{ div_close(0) }}
797 {% endif %}
798 {% else %}
799 <p>No comments.</p>
800 {% endif %}
801 {{ backlinks.render({'up_link': up_link, 'index_type': index_type}) }}
802 {% endblock %}
803 """,
804 ##
805             'bug_backlinks.html':
806 """<p class="backlink"><a href="{{ up_link }}">Back to {{ index_type }} Index</a></p>
807 <p class="backlink"><a href="../../index_by_target.html">Back to Target Index</a></p>
808 """,
809 ##
810             'bug_comment_entry.html':
811 """<table>
812   <tbody>
813     <tr>
814       <td class="bug_comment_label">Comment:</td>
815       <td class="bug_comment">
816         --------- Comment ---------<br/>
817         ID: {{ comment.uuid }}<br/>
818         Short name: {{ comment.id.user() }}<br/>
819         From: {{ (comment.author or '')|e }}<br/>
820         Date: {{ (comment.date or '')|e }}<br/>
821         <br/>
822         {{ format_body(bug, comment) }}
823       </td>
824     </tr>
825   </tbody>
826 </table>
827 """,
828             }
829
830         loader = DictLoader(self.template_dict)
831
832         if template_dir:
833             file_system_loader = FileSystemLoader(template_dir)
834             loader = ChoiceLoader([file_system_loader, loader])
835
836         self.template = Environment(loader=loader)
837
838
839 class _DivCloser (object):
840     def __init__(self, depth=0):
841         self.depth = depth
842
843     def __call__(self, depth):
844         ret = []
845         while self.depth >= depth:
846             self.depth -= 1
847             ret.append('</div>')
848         self.depth = depth
849         return '\n'.join(ret)