1 # Copyright (C) 2009-2012 Chris Ball <cjb@laptop.org>
2 # Gianluca Montecchi <gian@grys.it>
3 # Mathieu Clabaut <mathieu.clabaut@gmail.com>
4 # W. Trevor King <wking@drexel.edu>
6 # This file is part of Bugs Everywhere.
8 # Bugs Everywhere is free software: you can redistribute it and/or modify it
9 # under the terms of the GNU General Public License as published by the Free
10 # Software Foundation, either version 2 of the License, or (at your option) any
13 # Bugs Everywhere is distributed in the hope that it will be useful, but
14 # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
15 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
18 # You should have received a copy of the GNU General Public License along with
19 # Bugs Everywhere. If not, see <http://www.gnu.org/licenses/>.
29 import xml.sax.saxutils
31 from jinja2 import Environment, FileSystemLoader, DictLoader, ChoiceLoader
35 import libbe.command.util
37 import libbe.util.encoding
39 import libbe.command.depend
42 class HTML (libbe.command.Command):
43 """Generate a static HTML dump of the current repository status
46 >>> import libbe.bugdir
47 >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
48 >>> io = libbe.command.StringInputOutput()
49 >>> io.stdout = sys.stdout
50 >>> ui = libbe.command.UserInterface(io=io)
51 >>> ui.storage_callbacks.set_storage(bugdir.storage)
54 >>> ret = ui.run(cmd, {
55 ... 'output':os.path.join(bugdir.storage.repo, 'html_export')})
56 >>> os.path.exists(os.path.join(bugdir.storage.repo, 'html_export'))
58 >>> os.path.exists(os.path.join(
59 ... bugdir.storage.repo, 'html_export', 'index.html'))
61 >>> os.path.exists(os.path.join(
62 ... bugdir.storage.repo, 'html_export', 'index_inactive.html'))
64 >>> os.path.exists(os.path.join(
65 ... bugdir.storage.repo, 'html_export', 'bugs'))
67 >>> os.path.exists(os.path.join(
68 ... bugdir.storage.repo, 'html_export', 'bugs', 'a', 'index.html'))
70 >>> os.path.exists(os.path.join(
71 ... bugdir.storage.repo, 'html_export', 'bugs', 'b', 'index.html'))
78 def __init__(self, *args, **kwargs):
79 libbe.command.Command.__init__(self, *args, **kwargs)
81 libbe.command.Option(name='output', short_name='o',
82 help='Set the output path (%default)',
83 arg=libbe.command.Argument(
84 name='output', metavar='DIR', default='./html_export',
85 completion_callback=libbe.command.util.complete_path)),
86 libbe.command.Option(name='template-dir', short_name='t',
87 help='Use a different template. Defaults to internal templates',
88 arg=libbe.command.Argument(
89 name='template-dir', metavar='DIR',
90 completion_callback=libbe.command.util.complete_path)),
91 libbe.command.Option(name='title',
92 help='Set the bug repository title (%default)',
93 arg=libbe.command.Argument(
94 name='title', metavar='STRING',
95 default='Bugs Everywhere Issue Tracker')),
96 libbe.command.Option(name='index-header',
97 help='Set the index page headers (%default)',
98 arg=libbe.command.Argument(
99 name='index-header', metavar='STRING',
100 default='Bugs Everywhere Bug List')),
101 libbe.command.Option(name='export-template', short_name='e',
102 help='Export the default template and exit.'),
103 libbe.command.Option(name='export-template-dir', short_name='d',
104 help='Set the directory for the template export (%default)',
105 arg=libbe.command.Argument(
106 name='export-template-dir', metavar='DIR',
107 default='./default-templates/',
108 completion_callback=libbe.command.util.complete_path)),
109 libbe.command.Option(name='min-id-length', short_name='l',
110 help='Attempt to truncate bug and comment IDs to this length. Set to -1 for non-truncated IDs (%default)',
111 arg=libbe.command.Argument(
112 name='min-id-length', metavar='INT',
113 default=-1, type='int')),
114 libbe.command.Option(name='verbose', short_name='v',
115 help='Verbose output, default is %default'),
118 def _run(self, **params):
119 if params['export-template'] == True:
122 bugdirs = self._get_bugdirs()
123 for bugdir in bugdirs.values():
124 bugdir.load_all_bugs()
125 html_gen = HTMLGen(bugdirs,
126 template_dir=params['template-dir'],
127 title=params['title'],
128 header=params['index-header'],
129 min_id_length=params['min-id-length'],
130 verbose=params['verbose'],
132 if params['export-template'] == True:
133 html_gen.write_default_template(params['export-template-dir'])
135 html_gen.run(params['output'])
137 def _long_help(self):
139 Generate a set of html pages representing the current state of the bug
143 Html = HTML # alias for libbe.command.base.get_command_class()
145 class HTMLGen (object):
146 def __init__(self, bugdirs, template_dir=None,
147 title="Site Title", header="Header",
149 verbose=False, encoding=None, stdout=None,
151 self.generation_time = time.ctime()
152 self.bugdirs = bugdirs
155 self.verbose = verbose
158 self.encoding = encoding
160 self.encoding = libbe.util.encoding.get_text_file_encoding()
161 self._load_templates(template_dir)
162 self.min_id_length = min_id_length
164 def run(self, out_dir):
165 if self.verbose == True:
166 print >> self.stdout, \
167 'Creating the html output in %s using templates in %s' \
168 % (out_dir, self.template)
173 bugs = list(itertools.chain(*list(
174 [bug for bug in bugdir]
175 for bugdir in self.bugdirs.values())))
179 if b.active == True and b.severity != 'target':
180 bugs_active.append(b)
181 if b.active != True and b.severity != 'target':
182 bugs_inactive.append(b)
183 if b.severity == 'target':
184 bugs_target.append(b)
186 self._create_output_directories(out_dir)
187 self._write_css_file()
189 if b.severity == 'target':
190 up_link = '../../index_target.html'
192 up_link = '../../index.html'
194 up_link = '../../index_inactive.html'
195 self._write_bug_file(
196 b, title=self.title, header=self.header,
198 self._write_index_file(
199 bugs_active, title=self.title,
200 header=self.header, bug_type='active')
201 self._write_index_file(
202 bugs_inactive, title=self.title,
203 header=self.header, bug_type='inactive')
204 self._write_index_file(
205 bugs_target, title=self.title,
206 header=self.header, bug_type='target')
208 def _truncated_bug_id(self, bug):
209 return libbe.util.id._truncate(
210 bug.uuid, bug.sibling_uuids(),
211 min_length=self.min_id_length)
213 def _truncated_comment_id(self, comment):
214 return libbe.util.id._truncate(
215 comment.uuid, comment.sibling_uuids(),
216 min_length=self.min_id_length)
218 def _create_output_directories(self, out_dir):
220 print >> self.stdout, 'Creating output directories'
221 self.out_dir = self._make_dir(out_dir)
222 self.out_dir_bugs = self._make_dir(
223 os.path.join(self.out_dir, 'bugs'))
225 def _write_css_file(self):
227 print >> self.stdout, 'Writing css file'
228 assert hasattr(self, 'out_dir'), \
229 'Must run after ._create_output_directories()'
230 template = self.template.get_template('style.css')
231 self._write_file(template.render(), [self.out_dir, 'style.css'])
233 def _write_bug_file(self, bug, title, header, up_link):
235 print >> self.stdout, '\tCreating bug file for %s' % bug.id.user()
236 assert hasattr(self, 'out_dir_bugs'), \
237 'Must run after ._create_output_directories()'
240 if bug.active == True:
241 index_type = 'Active'
243 index_type = 'Inactive'
244 if bug.severity == 'target':
245 index_type = 'Target'
247 bug.load_comments(load_full=True)
248 bug.comment_root.sort(cmp=libbe.comment.cmp_time, reverse=True)
249 dirname = self._truncated_bug_id(bug)
250 fullpath = os.path.join(self.out_dir_bugs, dirname, 'index.html')
253 'charset': self.encoding,
254 'stylesheet': '../../style.css',
256 'backlinks': self.template.get_template('bug_backlinks.html'),
258 'index_type': index_type,
260 'comment_entry': self.template.get_template(
261 'bug_comment_entry.html'),
262 'comments': [(depth,comment) for depth,comment
263 in bug.comment_root.thread(flatten=False)],
264 'comment_dir': self._truncated_comment_id,
265 'format_body': self._format_comment_body,
266 'div_close': _DivCloser(),
267 'generation_time': self.generation_time,
269 fulldir = os.path.join(self.out_dir_bugs, dirname)
270 if not os.path.exists(fulldir):
272 template = self.template.get_template('bug.html')
273 self._write_file(template.render(template_info), [fullpath])
275 def _write_index_file(self, bugs, title, header, bug_type='active'):
277 print >> self.stdout, 'Writing %s index file for %d bugs' % (bug_type, len(bugs))
278 assert hasattr(self, 'out_dir'), 'Must run after ._create_output_directories()'
280 if bug_type == 'active':
281 filename = 'index.html'
282 elif bug_type == 'inactive':
283 filename = 'index_inactive.html'
284 elif bug_type == 'target':
285 filename = 'index_by_target.html'
287 raise ValueError('unrecognized bug_type: "%s"' % bug_type)
291 'charset': self.encoding,
292 'stylesheet': 'style.css',
294 'active_class': 'tab nsel',
295 'inactive_class': 'tab nsel',
296 'target_class': 'tab nsel',
298 'bug_entry': self.template.get_template('index_bug_entry.html'),
299 'bug_dir': self._truncated_bug_id,
300 'generation_time': self.generation_time,
302 template_info['%s_class' % bug_type] = 'tab sel'
303 if bug_type == 'target':
304 template = self.template.get_template('target_index.html')
305 template_info['targets'] = [
306 (target, sorted(libbe.command.depend.get_blocked_by(
307 target.bugdir, target)))
310 template = self.template.get_template('standard_index.html')
312 template.render(template_info)+'\n', [self.out_dir,filename])
314 def _long_to_linked_user(self, text):
316 >>> import libbe.bugdir
317 >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
318 >>> h = HTMLGen({bugdir.uuid: bugdir})
319 >>> h._long_to_linked_user('A link #abc123/a#, and a non-link #x#y#.')
320 'A link <a href="./a/">abc/a</a>, and a non-link #x#y#.'
323 replacer = libbe.util.id.IDreplacer(
324 self.bugdirs, self._long_to_linked_user_replacer, wrap=False)
326 libbe.util.id.REGEXP, replacer, text)
328 def _long_to_linked_user_replacer(self, bugdirs, long_id):
330 >>> import libbe.bugdir
331 >>> import libbe.util.id
332 >>> bugdir = libbe.bugdir.SimpleBugDir(memory=False)
333 >>> bugdirs = {bugdir.uuid: bugdir}
334 >>> a = bugdir.bug_from_uuid('a')
335 >>> uuid_gen = libbe.util.id.uuid_gen
336 >>> libbe.util.id.uuid_gen = lambda : '0123'
337 >>> c = a.new_comment('comment for link testing')
338 >>> libbe.util.id.uuid_gen = uuid_gen
341 >>> h = HTMLGen(bugdirs)
342 >>> h._long_to_linked_user_replacer(bugdirs, 'abc123')
344 >>> h._long_to_linked_user_replacer(bugdirs, 'abc123/a')
345 '<a href="./a/">abc/a</a>'
346 >>> h._long_to_linked_user_replacer(bugdirs, 'abc123/a/0123')
347 '<a href="./a/#0123">abc/a/012</a>'
348 >>> h._long_to_linked_user_replacer(bugdirs, 'x')
350 >>> h._long_to_linked_user_replacer(bugdirs, '')
355 p = libbe.util.id.parse_user(bugdirs, long_id)
356 except (libbe.util.id.MultipleIDMatches,
357 libbe.util.id.NoIDMatches,
358 libbe.util.id.InvalidIDStructure), e:
359 return '#%s#' % long_id # re-wrap failures
360 if p['type'] == 'bugdir':
361 return '#%s#' % long_id
362 elif p['type'] == 'bug':
363 bugdir,bug,comment = (
364 libbe.command.util.bugdir_bug_comment_from_user_id(
366 return '<a href="./%s/">%s</a>' \
367 % (self._truncated_bug_id(bug), bug.id.user())
368 elif p['type'] == 'comment':
369 bugdir,bug,comment = (
370 libbe.command.util.bugdir_bug_comment_from_user_id(
372 return '<a href="./%s/#%s">%s</a>' \
373 % (self._truncated_bug_id(bug),
374 self._truncated_comment_id(comment),
376 raise Exception('Invalid id type %s for "%s"'
377 % (p['type'], long_id))
379 def _format_comment_body(self, bug, comment):
380 link_long_ids = False
383 if comment.content_type == 'text/html':
385 elif comment.content_type.startswith('text/'):
386 value = '<pre>\n'+self._escape(value)+'\n</pre>'
388 elif comment.content_type.startswith('image/'):
390 value = '<img src="./%s/%s" />' % (
391 self._truncated_bug_id(bug),
392 self._truncated_comment_id(comment))
395 value = '<a href="./%s/%s">Link to %s file</a>.' % (
396 self._truncated_bug_id(bug),
397 self._truncated_comment_id(comment),
398 comment.content_type)
399 if link_long_ids == True:
400 value = self._long_to_linked_user(value)
401 if save_body == True:
402 per_bug_dir = os.path.join(self.out_dir_bugs, bug.uuid)
403 if not os.path.exists(per_bug_dir):
404 os.mkdir(per_bug_dir)
405 comment_path = os.path.join(per_bug_dir, comment.uuid)
407 '<Files %s>\n ForceType %s\n</Files>' \
408 % (comment.uuid, comment.content_type),
409 [per_bug_dir, '.htaccess'], mode='a')
410 self._write_file(comment.body,
411 [per_bug_dir, comment.uuid], mode='wb')
414 def _escape(self, string):
417 return xml.sax.saxutils.escape(string)
419 def _make_dir(self, dir_path):
420 dir_path = os.path.abspath(os.path.expanduser(dir_path))
421 if not os.path.exists(dir_path):
423 os.makedirs(dir_path)
425 raise libbe.command.UserError(
426 'Cannot create output directory "%s".' % dir_path)
429 def _write_file(self, content, path_array, mode='w'):
430 return libbe.util.encoding.set_file_contents(
431 os.path.join(*path_array), content, mode, self.encoding)
433 def _read_file(self, path_array, mode='r'):
434 return libbe.util.encoding.get_file_contents(
435 os.path.join(*path_array), mode, self.encoding, decode=True)
437 def write_default_template(self, out_dir):
439 print >> self.stdout, 'Creating output directories'
440 self.out_dir = self._make_dir(out_dir)
441 for filename,text in self.template_dict.iteritems():
443 print >> self.stdout, 'Creating %s file'
444 self._write_file(text, [self.out_dir, filename])
446 def _load_templates(self, template_dir=None):
447 if template_dir is not None:
448 template_dir = os.path.abspath(os.path.expanduser(template_dir))
450 self.template_dict = {
454 font-family: "lucida grande", "sans serif";
466 background-color: #fcfcfc;
467 -moz-border-radius: 10px;
480 -moz-border-radius: 10px;
489 padding-bottom: 10px;
492 -moz-border-radius: 10px;
498 border-color: #305275;
499 background-color: #305275;
502 -moz-border-radius-topleft: 8px;
503 -moz-border-radius-topright: 8px;
511 border-spacing: 0px 0px;
522 border-style: dotted;
530 border-color: #c3d9ff;
531 border-collapse: collapse;
539 border-color: #c3d9ff;
540 border-collapse: collapse;
546 img { border-style: none; }
549 list-style-type: none;
562 text-decoration: none;
565 a { color: #553d41; }
566 a:hover { color: #003d41; }
567 a:visited { color: #305275; }
568 .footer a { color: #508d91; }
570 /* bug index pages */
578 background-color: #c3d9ff ;
579 border: 1px solid #c3d9ff;
581 -moz-border-radius-topleft: 15px;
582 -moz-border-radius-topright: 15px;
586 border: 1px solid #c3d9ff;
588 -moz-border-radius-topleft: 5px;
589 -moz-border-radius-topright: 5px;
595 border-color: #c3d9ff;
598 border: 1px solid #c3d9ff;
607 table.target_list.td {
611 tr.wishlist { background-color: #DCFAFF;}
612 tr.wishlist:hover { background-color: #C2DCE1; }
614 tr.minor { background-color: #FFFFA6; }
615 tr.minor:hover { background-color: #E6E696; }
617 tr.serious { background-color: #FF9077;}
618 tr.serious:hover { background-color: #E6826B; }
620 tr.critical { background-color: #FF752A; }
621 tr.critical:hover { background-color: #D63905;}
623 tr.fatal { background-color: #FF3300;}
624 tr.fatal:hover { background-color: #D60000;}
626 td.uuid { width: 5%; border-style: dotted;}
627 td.status { width: 5%; border-style: dotted;}
628 td.severity { width: 5%; border-style: dotted;}
629 td.summary { border-style: dotted;}
630 td.date { width: 25%; border-style: dotted;}
632 /* bug detail pages */
634 td.bug_detail_label { text-align: right; border: none;}
635 td.bug_detail { border: none;}
636 td.bug_comment_label { text-align: right; vertical-align: top; }
648 /* padding-top: 0px; */
649 padding-bottom: 20px;
654 """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
655 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
656 <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
658 <title>{{ title }}</title>
659 <meta http-equiv="Content-Type" content="text/html; charset={{ charset }}" />
660 <link rel="stylesheet" href="{{ stylesheet }}" type="text/css" />
663 <div class="header">{{ header }}</div>
665 {% block content %}{% endblock %}
668 <p>Generated by <a href="http://www.bugseverywhere.org/">
669 Bugs Everywhere</a> on {{ generation_time }}</p>
671 <a href="http://validator.w3.org/check?uri=referer">
672 Validate XHTML</a> |
673 <a href="http://jigsaw.w3.org/css-validator/check?uri=referer">
681 """{% extends "base.html" %}
687 <td class="{{ active_class }}"><a href="index.html">Active Bugs</a></td>
688 <td class="{{ inactive_class }}"><a href="index_inactive.html">Inactive Bugs</a></td>
689 <td class="{{ target_class }}"><a href="index_by_target.html">Divided by target</a></td>
694 {% block bug_table %}{% endblock %}
701 'standard_index.html':
702 """{% extends "index.html" %}
704 {% block bug_table %}
705 <table class="bug_list">
716 {% for bug in bugs %}
717 {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug)}) }}
725 """{% extends "index.html" %}
727 {% block bug_table %}
728 {% for target,bugs in targets %}
729 <table class="target_list">
732 <th class="target_name" colspan="5">
733 Target: {{ target.summary|e }} ({{ target.status|e }})
745 {% for bug in bugs %}
746 {{ bug_entry.render({'bug':bug, 'dir':bug_dir(bug)}) }}
754 'index_bug_entry.html':
755 """<tr class="{{ bug.severity }}">
756 <td class="uuid"><a href="bugs/{{ dir }}/index.html">{{ bug.id.user()|e }}</a></td>
757 <td class="status"><a href="bugs/{{ dir }}/index.html">{{ bug.status|e }}</a></td>
758 <td class="severity"><a href="bugs/{{ dir }}/index.html">{{ bug.severity|e }}</a></td>
759 <td class="summary"><a href="bugs/{{ dir }}/index.html">{{ bug.summary|e }}</a></td>
760 <td class="date"><a href="bugs/{{ dir }}/index.html">{{ (bug.time_string or '')|e }}</a></td>
765 """{% extends "base.html" %}
768 {{ backlinks.render({'up_link': up_link, 'index_type':index_type}) }}
769 <h1>Bug: {{ bug.id.user()|e }}</h1>
773 <tr><td class="bug_detail_label">ID :</td>
774 <td class="bug_detail">{{ bug.uuid|e }}</td></tr>
775 <tr><td class="bug_detail_label">Short name :</td>
776 <td class="bug_detail">{{ bug.id.user()|e }}</td></tr>
777 <tr><td class="bug_detail_label">Status :</td>
778 <td class="bug_detail">{{ bug.status|e }}</td></tr>
779 <tr><td class="bug_detail_label">Severity :</td>
780 <td class="bug_detail">{{ bug.severity|e }}</td></tr>
781 <tr><td class="bug_detail_label">Assigned :</td>
782 <td class="bug_detail">{{ (bug.assigned or '')|e }}</td></tr>
783 <tr><td class="bug_detail_label">Reporter :</td>
784 <td class="bug_detail">{{ (bug.reporter or '')|e }}</td></tr>
785 <tr><td class="bug_detail_label">Creator :</td>
786 <td class="bug_detail">{{ (bug.creator or '')|e }}</td></tr>
787 <tr><td class="bug_detail_label">Created :</td>
788 <td class="bug_detail">{{ (bug.time_string or '')|e }}</td></tr>
789 <tr><td class="bug_detail_label">Summary :</td>
790 <td class="bug_detail">{{ bug.summary|e }}</td></tr>
797 {% for depth,comment in comments %}
799 <div class="comment root" id="C{{ comment_dir(comment) }}">
801 <div class="comment" id="C{{ comment_dir(comment) }}">
803 {{ comment_entry.render({
804 'depth':depth, 'bug': bug, 'comment':comment, 'comment_dir':comment_dir,
805 'format_body': format_body, 'div_close': div_close}) }}
806 {{ div_close(depth) }}
808 {% if comments[-1][0] > 0 %}
814 {{ backlinks.render({'up_link': up_link, 'index_type': index_type}) }}
818 'bug_backlinks.html':
819 """<p class="backlink"><a href="{{ up_link }}">Back to {{ index_type }} Index</a></p>
820 <p class="backlink"><a href="../../index_by_target.html">Back to Target Index</a></p>
823 'bug_comment_entry.html':
827 <td class="bug_comment_label">Comment:</td>
828 <td class="bug_comment">
829 --------- Comment ---------<br/>
830 ID: {{ comment.uuid }}<br/>
831 Short name: {{ comment.id.user() }}<br/>
832 From: {{ (comment.author or '')|e }}<br/>
833 Date: {{ (comment.date or '')|e }}<br/>
835 {{ format_body(bug, comment) }}
843 loader = DictLoader(self.template_dict)
846 file_system_loader = FileSystemLoader(template_dir)
847 loader = ChoiceLoader([file_system_loader, loader])
849 self.template = Environment(loader=loader)
852 class _DivCloser (object):
853 def __init__(self, depth=0):
856 def __call__(self, depth):
858 while self.depth >= depth:
862 return '\n'.join(ret)