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