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