1# -*- coding: utf-8 -*- 2""" 3 sphinxcontrib.websupport.core 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 5 6 Base Module for web support functions. 7 8 :copyright: Copyright 2007-2016 by the Sphinx team, see AUTHORS. 9 :license: BSD, see LICENSE for details. 10""" 11 12import html 13import sys 14import pickle 15import posixpath 16from os import path 17 18from jinja2 import Environment, FileSystemLoader 19from docutils.core import publish_parts 20 21from sphinx.locale import _ 22from sphinx.util.docutils import docutils_namespace 23from sphinx.util.osutil import ensuredir 24from sphinxcontrib.websupport import errors 25from sphinxcontrib.websupport import package_dir 26from sphinxcontrib.websupport.search import BaseSearch, SEARCH_ADAPTERS 27from sphinxcontrib.websupport.storage import StorageBackend 28 29try: 30 from sphinxcontrib.serializinghtml.jsonimpl import dumps as dump_json 31except ImportError: 32 from sphinx.util.jsonimpl import dumps as dump_json 33 34if False: 35 # For type annotation 36 from typing import Dict # NOQA 37 38 39class WebSupport(object): 40 """The main API class for the web support package. All interactions 41 with the web support package should occur through this class. 42 """ 43 def __init__(self, 44 srcdir=None, # only required for building 45 builddir='', # the dir with data/static/doctrees subdirs 46 datadir=None, # defaults to builddir/data 47 staticdir=None, # defaults to builddir/static 48 doctreedir=None, # defaults to builddir/doctrees 49 search=None, # defaults to no search 50 storage=None, # defaults to SQLite in datadir 51 buildername='websupport', 52 confoverrides={}, 53 status=sys.stdout, 54 warning=sys.stderr, 55 moderation_callback=None, 56 allow_anonymous_comments=True, 57 docroot='', 58 staticroot='static', 59 ): 60 # directories 61 self.srcdir = srcdir 62 self.builddir = builddir 63 self.outdir = path.join(builddir, 'data') 64 self.datadir = datadir or self.outdir 65 self.staticdir = staticdir or path.join(self.builddir, 'static') 66 self.doctreedir = doctreedir or path.join(self.builddir, 'doctrees') 67 # web server virtual paths 68 self.staticroot = staticroot.strip('/') 69 self.docroot = docroot.strip('/') 70 71 self.buildername = buildername 72 self.confoverrides = confoverrides 73 74 self.status = status 75 self.warning = warning 76 self.moderation_callback = moderation_callback 77 self.allow_anonymous_comments = allow_anonymous_comments 78 79 self._init_templating() 80 self._init_search(search) 81 self._init_storage(storage) 82 83 self._globalcontext = None # type: ignore 84 85 self._make_base_comment_options() 86 87 extensions = self.confoverrides.setdefault('extensions', []) 88 extensions.append('sphinxcontrib.websupport.builder') 89 90 def _init_storage(self, storage): 91 if isinstance(storage, StorageBackend): 92 self.storage = storage 93 else: 94 # If a StorageBackend isn't provided, use the default 95 # SQLAlchemy backend. 96 from sphinxcontrib.websupport.storage.sqlalchemystorage \ 97 import SQLAlchemyStorage 98 if not storage: 99 # no explicit DB path given; create default sqlite database 100 db_path = path.join(self.datadir, 'db', 'websupport.db') 101 ensuredir(path.dirname(db_path)) 102 storage = 'sqlite:///' + db_path 103 self.storage = SQLAlchemyStorage(storage) 104 105 def _init_templating(self): 106 loader = FileSystemLoader(path.join(package_dir, 'templates')) 107 self.template_env = Environment(loader=loader) 108 109 def _init_search(self, search): 110 if isinstance(search, BaseSearch): 111 self.search = search 112 else: 113 mod, cls = SEARCH_ADAPTERS[search or 'null'] 114 mod = 'sphinxcontrib.websupport.search.' + mod 115 SearchClass = getattr(__import__(mod, None, None, [cls]), cls) 116 search_path = path.join(self.datadir, 'search') 117 self.search = SearchClass(search_path) 118 self.results_template = \ 119 self.template_env.get_template('searchresults.html') 120 121 def build(self): 122 """Build the documentation. Places the data into the `outdir` 123 directory. Use it like this:: 124 125 support = WebSupport(srcdir, builddir, search='xapian') 126 support.build() 127 128 This will read reStructured text files from `srcdir`. Then it will 129 build the pickles and search index, placing them into `builddir`. 130 It will also save node data to the database. 131 """ 132 if not self.srcdir: 133 raise RuntimeError('No srcdir associated with WebSupport object') 134 135 with docutils_namespace(): 136 from sphinx.application import Sphinx 137 app = Sphinx(self.srcdir, self.srcdir, self.outdir, self.doctreedir, 138 self.buildername, self.confoverrides, status=self.status, 139 warning=self.warning) 140 app.builder.set_webinfo(self.staticdir, self.staticroot, # type: ignore 141 self.search, self.storage) 142 143 self.storage.pre_build() 144 app.build() 145 self.storage.post_build() 146 147 def get_globalcontext(self): 148 """Load and return the "global context" pickle.""" 149 if not self._globalcontext: 150 infilename = path.join(self.datadir, 'globalcontext.pickle') 151 with open(infilename, 'rb') as f: 152 self._globalcontext = pickle.load(f) 153 return self._globalcontext 154 155 def get_document(self, docname, username='', moderator=False): 156 """Load and return a document from a pickle. The document will 157 be a dict object which can be used to render a template:: 158 159 support = WebSupport(datadir=datadir) 160 support.get_document('index', username, moderator) 161 162 In most cases `docname` will be taken from the request path and 163 passed directly to this function. In Flask, that would be something 164 like this:: 165 166 @app.route('/<path:docname>') 167 def index(docname): 168 username = g.user.name if g.user else '' 169 moderator = g.user.moderator if g.user else False 170 try: 171 document = support.get_document(docname, username, 172 moderator) 173 except DocumentNotFoundError: 174 abort(404) 175 render_template('doc.html', document=document) 176 177 The document dict that is returned contains the following items 178 to be used during template rendering. 179 180 * **body**: The main body of the document as HTML 181 * **sidebar**: The sidebar of the document as HTML 182 * **relbar**: A div containing links to related documents 183 * **title**: The title of the document 184 * **css**: Links to css files used by Sphinx 185 * **script**: Javascript containing comment options 186 187 This raises :class:`~sphinxcontrib.websupport.errors.DocumentNotFoundError` 188 if a document matching `docname` is not found. 189 190 :param docname: the name of the document to load. 191 """ 192 docpath = path.join(self.datadir, 'pickles', docname) 193 if path.isdir(docpath): 194 infilename = docpath + '/index.fpickle' 195 if not docname: 196 docname = 'index' 197 else: 198 docname += '/index' 199 else: 200 infilename = docpath + '.fpickle' 201 202 try: 203 with open(infilename, 'rb') as f: 204 document = pickle.load(f) 205 except IOError: 206 raise errors.DocumentNotFoundError( 207 'The document "%s" could not be found' % docname) 208 209 comment_opts = self._make_comment_options(username, moderator) 210 comment_meta = self._make_metadata( 211 self.storage.get_metadata(docname, moderator)) 212 213 document['script'] = comment_opts + comment_meta + document['script'] 214 return document 215 216 def get_search_results(self, q): 217 """Perform a search for the query `q`, and create a set 218 of search results. Then render the search results as html and 219 return a context dict like the one created by 220 :meth:`get_document`:: 221 222 document = support.get_search_results(q) 223 224 :param q: the search query 225 """ 226 results = self.search.query(q) 227 ctx = { 228 'q': q, 229 'search_performed': True, 230 'search_results': results, 231 'docroot': '../', # XXX 232 '_': _, 233 } 234 document = { 235 'body': self.results_template.render(ctx), 236 'title': 'Search Results', 237 'sidebar': '', 238 'relbar': '' 239 } 240 return document 241 242 def get_data(self, node_id, username=None, moderator=False): 243 """Get the comments and source associated with `node_id`. If 244 `username` is given vote information will be included with the 245 returned comments. The default CommentBackend returns a dict with 246 two keys, *source*, and *comments*. *source* is raw source of the 247 node and is used as the starting point for proposals a user can 248 add. *comments* is a list of dicts that represent a comment, each 249 having the following items: 250 251 ============= ====================================================== 252 Key Contents 253 ============= ====================================================== 254 text The comment text. 255 username The username that was stored with the comment. 256 id The comment's unique identifier. 257 rating The comment's current rating. 258 age The time in seconds since the comment was added. 259 time A dict containing time information. It contains the 260 following keys: year, month, day, hour, minute, second, 261 iso, and delta. `iso` is the time formatted in ISO 262 8601 format. `delta` is a printable form of how old 263 the comment is (e.g. "3 hours ago"). 264 vote If `user_id` was given, this will be an integer 265 representing the vote. 1 for an upvote, -1 for a 266 downvote, or 0 if unvoted. 267 node The id of the node that the comment is attached to. 268 If the comment's parent is another comment rather than 269 a node, this will be null. 270 parent The id of the comment that this comment is attached 271 to if it is not attached to a node. 272 children A list of all children, in this format. 273 proposal_diff An HTML representation of the differences between the 274 the current source and the user's proposed source. 275 ============= ====================================================== 276 277 :param node_id: the id of the node to get comments for. 278 :param username: the username of the user viewing the comments. 279 :param moderator: whether the user is a moderator. 280 """ 281 return self.storage.get_data(node_id, username, moderator) 282 283 def delete_comment(self, comment_id, username='', moderator=False): 284 """Delete a comment. 285 286 If `moderator` is True, the comment and all descendants will be deleted 287 from the database, and the function returns ``True``. 288 289 If `moderator` is False, the comment will be marked as deleted (but not 290 removed from the database so as not to leave any comments orphaned), but 291 only if the `username` matches the `username` on the comment. The 292 username and text files are replaced with "[deleted]" . In this case, 293 the function returns ``False``. 294 295 This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError` 296 if moderator is False and `username` doesn't match username on the 297 comment. 298 299 :param comment_id: the id of the comment to delete. 300 :param username: the username requesting the deletion. 301 :param moderator: whether the requestor is a moderator. 302 """ 303 return self.storage.delete_comment(comment_id, username, moderator) 304 305 def add_comment(self, text, node_id='', parent_id='', displayed=True, 306 username=None, time=None, proposal=None, 307 moderator=False): 308 """Add a comment to a node or another comment. Returns the comment 309 in the same format as :meth:`get_comments`. If the comment is being 310 attached to a node, pass in the node's id (as a string) with the 311 node keyword argument:: 312 313 comment = support.add_comment(text, node_id=node_id) 314 315 If the comment is the child of another comment, provide the parent's 316 id (as a string) with the parent keyword argument:: 317 318 comment = support.add_comment(text, parent_id=parent_id) 319 320 If you would like to store a username with the comment, pass 321 in the optional `username` keyword argument:: 322 323 comment = support.add_comment(text, node=node_id, 324 username=username) 325 326 :param parent_id: the prefixed id of the comment's parent. 327 :param text: the text of the comment. 328 :param displayed: for moderation purposes 329 :param username: the username of the user making the comment. 330 :param time: the time the comment was created, defaults to now. 331 """ 332 if username is None: 333 if self.allow_anonymous_comments: 334 username = 'Anonymous' 335 else: 336 raise errors.UserNotAuthorizedError() 337 parsed = self._parse_comment_text(text) 338 comment = self.storage.add_comment(parsed, displayed, username, 339 time, proposal, node_id, 340 parent_id, moderator) 341 comment['original_text'] = text 342 if not displayed and self.moderation_callback: 343 self.moderation_callback(comment) 344 return comment 345 346 def process_vote(self, comment_id, username, value): 347 """Process a user's vote. The web support package relies 348 on the API user to perform authentication. The API user will 349 typically receive a comment_id and value from a form, and then 350 make sure the user is authenticated. A unique username must be 351 passed in, which will also be used to retrieve the user's past 352 voting data. An example, once again in Flask:: 353 354 @app.route('/docs/process_vote', methods=['POST']) 355 def process_vote(): 356 if g.user is None: 357 abort(401) 358 comment_id = request.form.get('comment_id') 359 value = request.form.get('value') 360 if value is None or comment_id is None: 361 abort(400) 362 support.process_vote(comment_id, g.user.name, value) 363 return "success" 364 365 :param comment_id: the comment being voted on 366 :param username: the unique username of the user voting 367 :param value: 1 for an upvote, -1 for a downvote, 0 for an unvote. 368 """ 369 value = int(value) 370 if not -1 <= value <= 1: 371 raise ValueError('vote value %s out of range (-1, 1)' % value) 372 self.storage.process_vote(comment_id, username, value) 373 374 def update_username(self, old_username, new_username): 375 """To remain decoupled from a webapp's authentication system, the 376 web support package stores a user's username with each of their 377 comments and votes. If the authentication system allows a user to 378 change their username, this can lead to stagnate data in the web 379 support system. To avoid this, each time a username is changed, this 380 method should be called. 381 382 :param old_username: The original username. 383 :param new_username: The new username. 384 """ 385 self.storage.update_username(old_username, new_username) 386 387 def accept_comment(self, comment_id, moderator=False): 388 """Accept a comment that is pending moderation. 389 390 This raises :class:`~sphinxcontrib.websupport.errors.UserNotAuthorizedError` 391 if moderator is False. 392 393 :param comment_id: The id of the comment that was accepted. 394 :param moderator: Whether the user making the request is a moderator. 395 """ 396 if not moderator: 397 raise errors.UserNotAuthorizedError() 398 self.storage.accept_comment(comment_id) 399 400 def _make_base_comment_options(self): 401 """Helper method to create the part of the COMMENT_OPTIONS javascript 402 that remains the same throughout the lifetime of the 403 :class:`~sphinxcontrib.websupport.WebSupport` object. 404 """ 405 self.base_comment_opts = {} # type: Dict[str, str] 406 407 if self.docroot != '': 408 comment_urls = [ 409 ('addCommentURL', '_add_comment'), 410 ('getCommentsURL', '_get_comments'), 411 ('processVoteURL', '_process_vote'), 412 ('acceptCommentURL', '_accept_comment'), 413 ('deleteCommentURL', '_delete_comment') 414 ] 415 for key, value in comment_urls: 416 self.base_comment_opts[key] = \ 417 '/' + posixpath.join(self.docroot, value) 418 if self.staticroot != 'static': 419 static_urls = [ 420 ('commentImage', 'comment.png'), 421 ('closeCommentImage', 'comment-close.png'), 422 ('loadingImage', 'ajax-loader.gif'), 423 ('commentBrightImage', 'comment-bright.png'), 424 ('upArrow', 'up.png'), 425 ('upArrowPressed', 'up-pressed.png'), 426 ('downArrow', 'down.png'), 427 ('downArrowPressed', 'down-pressed.png') 428 ] 429 for key, value in static_urls: 430 self.base_comment_opts[key] = \ 431 '/' + posixpath.join(self.staticroot, '_static', value) 432 433 def _make_comment_options(self, username, moderator): 434 """Helper method to create the parts of the COMMENT_OPTIONS 435 javascript that are unique to each request. 436 437 :param username: The username of the user making the request. 438 :param moderator: Whether the user making the request is a moderator. 439 """ 440 rv = self.base_comment_opts.copy() 441 if username: 442 rv.update({ 443 'voting': True, 444 'username': username, 445 'moderator': moderator, 446 }) 447 return '''\ 448 <script type="text/javascript"> 449 var COMMENT_OPTIONS = %s; 450 </script> 451 ''' % dump_json(rv) 452 453 def _make_metadata(self, data): 454 return '''\ 455 <script type="text/javascript"> 456 var COMMENT_METADATA = %s; 457 </script> 458 ''' % dump_json(data) 459 460 def _parse_comment_text(self, text): 461 settings = {'file_insertion_enabled': False, 462 'raw_enabled': False, 463 'output_encoding': 'unicode'} 464 try: 465 ret = publish_parts(text, writer_name='html', 466 settings_overrides=settings)['fragment'] 467 except Exception: 468 ret = html.escape(text) 469 return ret 470