From 258b4a9852d8a7c5e16daf71fecd209d68df7026 Mon Sep 17 00:00:00 2001 From: Andreas <andreasv@fsmpi.rwth-aachen.de> Date: Tue, 30 Jan 2018 15:07:56 +0100 Subject: [PATCH] added module support and a basic api module --- modules/__init__.py | 4 + modules/mod_api/__init__.py | 13 ++ modules/mod_api/auth.py | 26 +++ modules/mod_api/autodoc.py | 194 ++++++++++++++++++ modules/mod_api/autodoc_LICENSE | 21 ++ modules/mod_api/errorhandlers.py | 15 ++ modules/mod_api/help.py | 8 + modules/mod_api/public.py | 36 ++++ .../mod_api/templates/autodoc_default.html | 82 ++++++++ server.py | 24 ++- 10 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 modules/__init__.py create mode 100644 modules/mod_api/__init__.py create mode 100644 modules/mod_api/auth.py create mode 100644 modules/mod_api/autodoc.py create mode 100644 modules/mod_api/autodoc_LICENSE create mode 100644 modules/mod_api/errorhandlers.py create mode 100644 modules/mod_api/help.py create mode 100644 modules/mod_api/public.py create mode 100644 modules/mod_api/templates/autodoc_default.html diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..faa8dc9 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,4 @@ +from server import app + +from modules.mod_api import api +app.register_blueprint(api) diff --git a/modules/mod_api/__init__.py b/modules/mod_api/__init__.py new file mode 100644 index 0000000..4b2da78 --- /dev/null +++ b/modules/mod_api/__init__.py @@ -0,0 +1,13 @@ +from flask import Blueprint, jsonify +from server import app, query +import json + +api = Blueprint('api', __name__, url_prefix='/api') + +from . import autodoc +doc = autodoc.Autodoc(app) + +from . import errorhandlers +from . import help +from . import auth +from . import public diff --git a/modules/mod_api/auth.py b/modules/mod_api/auth.py new file mode 100644 index 0000000..4371ecc --- /dev/null +++ b/modules/mod_api/auth.py @@ -0,0 +1,26 @@ +from . import * + +from server import dologin, ismod as session_ismod +from flask import make_response, request, session + +def ismod(): + return session_ismod() + +@api.route('/moderator/login', methods=['POST', 'GET']) +@doc.doc() +def modlogin(): + """Returns a access token in a cookie used for all other moderator functions. Accepts *user* and *password* as post data""" + return jsonify(dologin(request.values['user'], request.values['password'])) + +@api.route('/moderator/logout', methods=['POST', 'GET']) +@doc.doc() +def modlogout(): + """Removes a moderator token in a cookie""" + session.pop('user', None) + return jsonify(True) + +@api.route('/moderator/test') +@doc.doc() +def modtest(): + """Returns if you are logged in as moderator""" + return jsonify(ismod()) diff --git a/modules/mod_api/autodoc.py b/modules/mod_api/autodoc.py new file mode 100644 index 0000000..bb8f23b --- /dev/null +++ b/modules/mod_api/autodoc.py @@ -0,0 +1,194 @@ +from operator import attrgetter, itemgetter +import os +import re +from collections import defaultdict +import sys +import inspect + +from flask import current_app, render_template, render_template_string +from jinja2 import evalcontextfilter + + +try: + from flask import _app_ctx_stack as stack +except ImportError: + from flask import _request_ctx_stack as stack + + +if sys.version < '3': + get_function_code = attrgetter('func_code') +else: + get_function_code = attrgetter('__code__') + + +class Autodoc(object): + + def __init__(self, app=None): + self.app = app + self.func_groups = defaultdict(set) + self.func_props = defaultdict() + self.immutable_props = ['rule', 'endpoint'] + self.default_props = ['methods', 'docstring', + 'args', 'defaults', 'location'] + self.immutable_props + self.func_locations = defaultdict(dict) + if app is not None: + self.init_app(app) + + def init_app(self, app): + if hasattr(app, 'teardown_appcontext'): + app.teardown_appcontext(self.teardown) + else: + app.teardown_request(self.teardown) + self.add_custom_template_filters(app) + + def teardown(self, exception): + ctx = stack.top + + def add_custom_template_filters(self, app): + """Add custom filters to jinja2 templating engine""" + self.add_custom_nl2br_filters(app) + + def add_custom_nl2br_filters(self, app): + """Add a custom filter nl2br to jinja2 + Replaces all newline to <BR> + """ + _paragraph_re = re.compile(r'(?:\r\n|\r|\n){3,}') + + @app.template_filter() + @evalcontextfilter + def nl2br(eval_ctx, value): + result = '\n\n'.join('%s' % p.replace('\n', '<br>\n') + for p in _paragraph_re.split(value)) + return result + + def doc(self, groups=None, set_location=True, **properties): + """Add flask route to autodoc for automatic documentation + + Any route decorated with this method will be added to the list of + routes to be documented by the generate() or html() methods. + + By default, the route is added to the 'all' group. + By specifying group or groups argument, the route can be added to one + or multiple other groups as well, besides the 'all' group. + + If set_location is True, the location of the function will be stored. + NOTE: this assumes that the decorator is placed just before the + function (in the normal way). + + Custom parameters may also be passed in beyond groups, if they are + named something not already in the dict descibed in the docstring for + the generare() function, they will be added to the route's properties, + which can be accessed from the template. + + If a parameter is passed in with a name that is already in the dict, but + not of a reserved name, the passed parameter overrides that dict value. + """ + def decorator(f): + # Get previous group list (if any) + if f in self.func_groups: + groupset = self.func_groups[f] + else: + groupset = set() + + # Set group[s] + if type(groups) is list: + groupset.update(groups) + elif type(groups) is str: + groupset.add(groups) + groupset.add('all') + self.func_groups[f] = groupset + self.func_props[f] = properties + + # Set location + if set_location: + caller_frame = inspect.stack()[1] + self.func_locations[f] = { + 'filename': caller_frame[1], + 'line': caller_frame[2], + } + + return f + return decorator + + def generate(self, groups='all', sort=None): + """Return a list of dict describing the routes specified by the + doc() method + + Each dict contains: + - methods: the set of allowed methods (ie ['GET', 'POST']) + - rule: relative url (ie '/user/<int:id>') + - endpoint: function name (ie 'show_user') + - doc: docstring of the function + - args: function arguments + - defaults: defaults values for the arguments + + By specifying the group or groups arguments, only routes belonging to + those groups will be returned. + + Routes are sorted alphabetically based on the rule. + """ + groups_to_generate = list() + if type(groups) is list: + groups_to_generate = groups + elif type(groups) is str: + groups_to_generate.append(groups) + + links = [] + for rule in current_app.url_map.iter_rules(): + + if rule.endpoint == 'static': + continue + + func = current_app.view_functions[rule.endpoint] + arguments = rule.arguments if rule.arguments else ['None'] + func_groups = self.func_groups[func] + func_props = self.func_props[func] if func in self.func_props \ + else {} + location = self.func_locations.get(func, None) + + if func_groups.intersection(groups_to_generate): + props = dict( + methods=rule.methods, + rule="%s" % rule, + endpoint=rule.endpoint, + docstring=func.__doc__, + args=arguments, + defaults=rule.defaults, + location=location, + ) + for p in func_props: + if p not in self.immutable_props: + props[p] = func_props[p] + links.append(props) + if sort: + return sort(links) + else: + return sorted(links, key=itemgetter('rule')) + + def html(self, groups='all', template=None, **context): + """Return an html string of the routes specified by the doc() method + + A template can be specified. A list of routes is available under the + 'autodoc' value (refer to the documentation for the generate() for a + description of available values). If no template is specified, a + default template is used. + + By specifying the group or groups arguments, only routes belonging to + those groups will be returned. + """ + context['autodoc'] = context['autodoc'] if 'autodoc' in context \ + else self.generate(groups=groups) + context['defaults'] = context['defaults'] if 'defaults' in context \ + else self.default_props + if template: + return render_template(template, **context) + else: + filename = os.path.join( + os.path.dirname(__file__), + 'templates', + 'autodoc_default.html' + ) + with open(filename) as file: + content = file.read() + with current_app.app_context(): + return render_template_string(content, **context) diff --git a/modules/mod_api/autodoc_LICENSE b/modules/mod_api/autodoc_LICENSE new file mode 100644 index 0000000..69ec11e --- /dev/null +++ b/modules/mod_api/autodoc_LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Arnaud Coomans + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/modules/mod_api/errorhandlers.py b/modules/mod_api/errorhandlers.py new file mode 100644 index 0000000..af86bae --- /dev/null +++ b/modules/mod_api/errorhandlers.py @@ -0,0 +1,15 @@ +from . import * +import traceback + +@api.errorhandler(404) +def handle_not_found(e=None): + return 'Error', 404 + +@api.errorhandler(500) +@api.errorhandler(Exception) +def handle_internal_error(e): + if not e: + return 'Error', 505 + else: + traceback.print_exc() + return 'Error', 500 diff --git a/modules/mod_api/help.py b/modules/mod_api/help.py new file mode 100644 index 0000000..d281b22 --- /dev/null +++ b/modules/mod_api/help.py @@ -0,0 +1,8 @@ +from . import * + +@api.route('/help') +@api.route('/') +@doc.doc() +def help(): + """Prints an API description """ + return doc.html() diff --git a/modules/mod_api/public.py b/modules/mod_api/public.py new file mode 100644 index 0000000..62c05c1 --- /dev/null +++ b/modules/mod_api/public.py @@ -0,0 +1,36 @@ +from . import * +from server import genlive + +@api.route('/courses') +@doc.doc() +def courses(): + """Returns a list of all courses without the lectures, optionaly takes a moderator token as request parameter *token* """ + return jsonify(query('SELECT title, description, handle, id, organizer, semester, short, subject FROM courses WHERE (? OR (visible AND listed)) ORDER BY lower(semester), lower(title)'), auth.ismod()) + +@api.route('/<int:id>') +@api.route('/<handle>') +@doc.doc() +def course(id=None, handle=None): + """Returns data to a single course, including the lectures, optionaly takes a moderator token as request parameter *token* """ + coursedata = query('SELECT title, description, handle, id, organizer, semester, short, subject FROM courses WHERE (? OR (visible AND listed)) AND (id = ? OR handle = ?) ORDER BY lower(semester), lower(title)', auth.ismod(), id, handle) + if len(coursedata) == 1: + coursedata[0]['lectures'] = query('SELECT id, title, time, comment, speaker, duration, place FROM lectures WHERE course_id = ? AND (visible or ?)', coursedata[0]['id'], auth.ismod()) + for lecture in coursedata[0]['lectures']: + videos = query(''' + SELECT videos.id, videos.comment, videos.file_size, videos.hash, videos.path, "formats" AS sep, formats.description, formats.resolution + FROM videos + JOIN lectures ON (videos.lecture_id = lectures.id) + JOIN formats ON (videos.video_format = formats.id) + JOIN courses ON (lectures.course_id = courses.id) + WHERE lectures.course_id= ? AND (? OR videos.visible) + ORDER BY lectures.time, formats.prio DESC + ''', coursedata[0]['id'], auth.ismod()) + livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, "formats" AS sep, formats.* + FROM streams + JOIN lectures ON lectures.id = streams.lecture_id + JOIN formats ON formats.keywords = "hls" + WHERE streams.active AND (? OR streams.visible) AND lectures.course_id = ? + ''', auth.ismod(), coursedata[0]['id']) + videos += genlive(livestreams) + lecture['videos'] = videos + return jsonify(coursedata[0] if len(coursedata) == 1 else []) diff --git a/modules/mod_api/templates/autodoc_default.html b/modules/mod_api/templates/autodoc_default.html new file mode 100644 index 0000000..247448f --- /dev/null +++ b/modules/mod_api/templates/autodoc_default.html @@ -0,0 +1,82 @@ +<html> + <head> + <title> + {% if title is defined -%} + {{title}} + {% else -%} + Documentation + {% endif -%} + </title> + <style> + * { + margin: 0; + padding: 0; + font-family: Verdana, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif; + } + + body { + margin: 10px; + } + + div.mapping { + margin: 20px 20px; + } + + ul.methods:before { content: "Methods: "; } + ul.methods li { + display: inline; + list-style: none; + } + ul.methods li:after { content: ","; } + ul.methods li:last-child:after { content: ""; } + + ul.arguments:before { content: "Arguments: "; } + ul.arguments li { + display: inline; + list-style: none; + } + ul.arguments .argument { font-style:italic } + ul.arguments .default:not(:empty):before { content: "("; } + ul.arguments .default:not(:empty):after { content: ")"; } + ul.arguments li:after { content: ","; } + ul.arguments li:last-child:after { content: ""; } + + .docstring:before { content: "Description: "; } + </style> + </head> + <body> + <h1> + {% if title is defined -%} + {{title}} + {% else -%} + Documentation + {% endif -%} + </h1> + + {% for doc in autodoc %} + <div class="mapping"> + <a id="rule-{{doc.rule|urlencode}}" class="rule"><h2>{{doc.rule|escape}}</h2></a> + <ul class="methods"> + {% for method in doc.methods -%} + {% if method == 'GET' and doc.args == ['None'] %} + <a href="{{doc.rule}}" class="getmethod"> + <li class="method">{{method}}</li> + </a> + {% else %} + <li class="method">{{method}}</li> + {% endif %} + {% endfor %} + </ul> + <ul class="arguments"> + {% for arg in doc.args %} + <li> + <span class="argument">{{arg}}</span> + <span class="default">{{doc.defaults[arg]}}</span> + </li> + {% endfor %} + </ul> + <p class="docstring">{{doc.docstring|urlize|nl2br}}</p> + </div> + {% endfor %} + </body> +</html> diff --git a/server.py b/server.py index e484976..f2635a6 100644 --- a/server.py +++ b/server.py @@ -341,15 +341,11 @@ def check_mod(user, groups): return True return False -@app.route('/internal/login', methods=['GET', 'POST']) -def login(): - if request.method == 'GET': - return render_template('login.html') - userinfo, groups = ldapauth(request.form.get('user'), request.form.get('password')) +def dologin(username, password): + userinfo, groups = ldapauth(username, password) user = userinfo.get('uid') if not check_mod(user, groups): - flash('Login fehlgeschlagen!') - return make_response(render_template('login.html'), 403) + return False session['user'] = userinfo dbuser = query('SELECT * FROM users WHERE name = ?', user) if not dbuser: @@ -358,7 +354,17 @@ def login(): session['user']['dbid'] = dbuser[0]['id'] session['_csrf_token'] = ''.join(random.SystemRandom().choice(string.ascii_letters + string.digits) for _ in range(64)) session.permanent = True - return redirect(request.values.get('ref', url_for('index'))) + return True + +@app.route('/internal/login', methods=['GET', 'POST']) +def login(): + if request.method == 'GET': + return render_template('login.html') + if not dologin (request.form.get('user'), request.form.get('password')): + flash('Login fehlgeschlagen!') + return make_response(render_template('login.html'), 403) + else: + return redirect(request.values.get('ref', url_for('index'))) @app.route('/internal/logout', methods=['GET', 'POST']) def logout(): @@ -490,3 +496,5 @@ import chapters import icalexport import livestreams import cutprogress + +import modules -- GitLab