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