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