From a9c7ad69931e8ad8b7548af5ff3cdbd1c221eb1b Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Mon, 1 May 2017 21:23:16 -0700 Subject: [PATCH] Improve separation between front & back with etiquette_flask package Move flask-specific operations out of etiquette's files and into new etiquette_flask. In etiquette_site.py, etiquette calls are fully qualified. --- etiquette/__init__.py | 9 +- etiquette/decorators.py | 33 ---- etiquette/jsonify.py | 6 - etiquette_flask/__init__.py | 3 + etiquette_flask/decorators.py | 35 ++++ etiquette_flask/jsonify.py | 9 + {etiquette => etiquette_flask}/sessions.py | 5 +- etiquette_site.py | 188 ++++++++++----------- 8 files changed, 151 insertions(+), 137 deletions(-) create mode 100644 etiquette_flask/__init__.py create mode 100644 etiquette_flask/decorators.py create mode 100644 etiquette_flask/jsonify.py rename {etiquette => etiquette_flask}/sessions.py (98%) diff --git a/etiquette/__init__.py b/etiquette/__init__.py index 2ae2839..9202351 100644 --- a/etiquette/__init__.py +++ b/etiquette/__init__.py @@ -1 +1,8 @@ -pass +from . import constants +from . import decorators +from . import exceptions +from . import helpers +from . import jsonify +from . import objects +from . import photodb +from . import searchhelpers diff --git a/etiquette/decorators.py b/etiquette/decorators.py index a267054..49cc689 100644 --- a/etiquette/decorators.py +++ b/etiquette/decorators.py @@ -1,11 +1,7 @@ -import flask -from flask import request import functools import time import warnings -from . import jsonify - def required_feature(features): ''' @@ -31,35 +27,6 @@ def required_feature(features): return wrapped return wrapper -def required_fields(fields, forbid_whitespace=False): - ''' - Declare that the endpoint requires certain POST body fields. Without them, - we respond with 400 and a message. - - forbid_whitespace: - If True, then providing the field is not good enough. It must also - contain at least some non-whitespace characters. - ''' - def wrapper(function): - @functools.wraps(function) - def wrapped(*args, **kwargs): - for requirement in fields: - missing = ( - requirement not in request.form or - (forbid_whitespace and request.form[requirement].strip() == '') - ) - if missing: - response = { - 'error_type': 'MISSING_FIELDS', - 'error_message': 'Required fields: %s' % ', '.join(fields), - } - response = jsonify.make_json_response(response, status=400) - return response - - return function(*args, **kwargs) - return wrapped - return wrapper - def not_implemented(function): ''' Decorator to remember what needs doing. diff --git a/etiquette/jsonify.py b/etiquette/jsonify.py index a3ac0a2..b3d829d 100644 --- a/etiquette/jsonify.py +++ b/etiquette/jsonify.py @@ -1,11 +1,5 @@ -import flask import json -def make_json_response(j, *args, **kwargs): - dumped = json.dumps(j) - response = flask.Response(dumped, *args, **kwargs) - response.headers['Content-Type'] = 'application/json;charset=utf-8' - return response def album(a, minimal=False): j = { diff --git a/etiquette_flask/__init__.py b/etiquette_flask/__init__.py new file mode 100644 index 0000000..334daa4 --- /dev/null +++ b/etiquette_flask/__init__.py @@ -0,0 +1,3 @@ +from . import decorators +from . import jsonify +from . import sessions diff --git a/etiquette_flask/decorators.py b/etiquette_flask/decorators.py new file mode 100644 index 0000000..4614846 --- /dev/null +++ b/etiquette_flask/decorators.py @@ -0,0 +1,35 @@ +import flask +from flask import request +import functools + +from etiquette import jsonify + + +def required_fields(fields, forbid_whitespace=False): + ''' + Declare that the endpoint requires certain POST body fields. Without them, + we respond with 400 and a message. + + forbid_whitespace: + If True, then providing the field is not good enough. It must also + contain at least some non-whitespace characters. + ''' + def wrapper(function): + @functools.wraps(function) + def wrapped(*args, **kwargs): + for requirement in fields: + missing = ( + requirement not in request.form or + (forbid_whitespace and request.form[requirement].strip() == '') + ) + if missing: + response = { + 'error_type': 'MISSING_FIELDS', + 'error_message': 'Required fields: %s' % ', '.join(fields), + } + response = jsonify.make_json_response(response, status=400) + return response + + return function(*args, **kwargs) + return wrapped + return wrapper diff --git a/etiquette_flask/jsonify.py b/etiquette_flask/jsonify.py new file mode 100644 index 0000000..5999d17 --- /dev/null +++ b/etiquette_flask/jsonify.py @@ -0,0 +1,9 @@ +import flask +import json + + +def make_json_response(j, *args, **kwargs): + dumped = json.dumps(j) + response = flask.Response(dumped, *args, **kwargs) + response.headers['Content-Type'] = 'application/json;charset=utf-8' + return response diff --git a/etiquette/sessions.py b/etiquette_flask/sessions.py similarity index 98% rename from etiquette/sessions.py rename to etiquette_flask/sessions.py index fee4878..c97e7f6 100644 --- a/etiquette/sessions.py +++ b/etiquette_flask/sessions.py @@ -3,7 +3,7 @@ from flask import request import functools import uuid -from . import helpers +from etiquette import helpers def _generate_token(): token = str(uuid.uuid4()) @@ -13,7 +13,8 @@ def _generate_token(): def _normalize_token(token): if isinstance(token, flask.Request): token = token.cookies.get('etiquette_session', None) - + + class SessionManager: def __init__(self): self.sessions = {} diff --git a/etiquette_site.py b/etiquette_site.py index 2785398..db0f98f 100644 --- a/etiquette_site.py +++ b/etiquette_site.py @@ -9,24 +9,22 @@ import urllib.parse import warnings import zipstream -from etiquette import constants -from etiquette import decorators -from etiquette import exceptions -from etiquette import helpers -from etiquette import jsonify -from etiquette import objects -from etiquette import photodb -from etiquette import searchhelpers -from etiquette import sessions +import etiquette +import etiquette_flask + +from voussoirkit import pathclass -TEMPLATE_DIR = 'C:\\git\\Etiquette\\templates' -STATIC_DIR = 'C:\\git\\Etiquette\\static' +root_dir = pathclass.Path(__file__).parent + +TEMPLATE_DIR = root_dir.with_child('templates') +STATIC_DIR = root_dir.with_child('static') +FAVICON_PATH = STATIC_DIR.with_child('favicon.png') site = flask.Flask( __name__, - template_folder=TEMPLATE_DIR, - static_folder=STATIC_DIR, + template_folder=TEMPLATE_DIR.absolute_path, + static_folder=STATIC_DIR.absolute_path, ) site.config.update( SEND_FILE_MAX_AGE_DEFAULT=180, @@ -37,9 +35,9 @@ site.jinja_env.trim_blocks = True site.jinja_env.lstrip_blocks = True site.debug = True -P = photodb.PhotoDB() +P = etiquette.photodb.PhotoDB() -session_manager = sessions.SessionManager() +session_manager = etiquette_flask.sessions.SessionManager() #################################################################################################### #################################################################################################### @@ -67,8 +65,8 @@ def delete_synonym(synonym): try: master_tag = P.get_tag(synonym) - except exceptions.NoSuchTag as e: - raise exceptions.NoSuchSynonym(*e.given_args, **e.given_kwargs) + except etiquette.exceptions.NoSuchTag as e: + raise etiquette.exceptions.NoSuchSynonym(*e.given_args, **e.given_kwargs) master_tag.remove_synonym(synonym) return {'action':'delete_synonym', 'synonym': synonym} @@ -78,8 +76,8 @@ def P_wrapper(function): try: return function(thingid) - except exceptions.EtiquetteException as e: - if isinstance(e, exceptions.NoSuch): + except etiquette.exceptions.EtiquetteException as e: + if isinstance(e, etiquette.exceptions.NoSuch): status = 404 else: status = 400 @@ -87,8 +85,8 @@ def P_wrapper(function): if response_type == 'html': flask.abort(status, e.error_message) else: - response = jsonify.exception(e) - response = jsonify.make_json_response(response, status=status) + response = etiquette.jsonify.exception(e) + response = etiquette_flask.jsonify.make_json_response(response, status=status) flask.abort(response) except Exception as e: @@ -96,7 +94,7 @@ def P_wrapper(function): if response_type == 'html': flask.abort(500) else: - flask.abort(jsonify.make_response({}, status=500)) + flask.abort(etiquette.jsonify.make_json_response({}, status=500)) return P_wrapped @@ -175,7 +173,7 @@ def send_file(filepath, override_mimetype=None): if request.method == 'HEAD': outgoing_data = bytes() else: - outgoing_data = helpers.read_filebytes( + outgoing_data = etiquette.helpers.read_filebytes( filepath, range_min=range_min, range_max=range_max, @@ -215,12 +213,12 @@ def get_register(): @site.route('/login', methods=['POST']) @session_manager.give_token -@decorators.required_fields(['username', 'password']) +@etiquette_flask.decorators.required_fields(['username', 'password']) def post_login(): if session_manager.get(request): - e = exceptions.AlreadySignedIn() - response = jsonify.exception(e) - return jsonify.make_json_response(response, status=403) + e = etiquette.exceptions.AlreadySignedIn() + response = etiquette.jsonify.exception(e) + return etiquette_flask.jsonify.make_json_response(response, status=403) username = request.form['username'] password = request.form['password'] @@ -231,22 +229,22 @@ def post_login(): # page 404s anyway. user = P.get_user(username=username) user = P.login(user.id, password) - except (exceptions.NoSuchUser, exceptions.WrongLogin): - e = exceptions.WrongLogin() - response = jsonify.exception(e) - return jsonify.make_json_response(response, status=422) - session = sessions.Session(request, user) + except (etiquette.exceptions.NoSuchUser, etiquette.exceptions.WrongLogin): + e = etiquette.exceptions.WrongLogin() + response = etiquette.jsonify.exception(e) + return etiquette_flask.jsonify.make_json_response(response, status=422) + session = etiquette_flask.sessions.Session(request, user) session_manager.add(session) - return jsonify.make_json_response({}) + return etiquette_flask.jsonify.make_json_response({}) @site.route('/register', methods=['POST']) @session_manager.give_token -@decorators.required_fields(['username', 'password_1', 'password_2']) +@etiquette_flask.decorators.required_fields(['username', 'password_1', 'password_2']) def post_register(): if session_manager.get(request): - e = exceptions.AlreadySignedIn() - response = jsonify.exception(e) - return jsonify.make_json_response(response, status=403) + e = etiquette.exceptions.AlreadySignedIn() + response = etiquette.jsonify.exception(e) + return etiquette_flask.jsonify.make_json_response(response, status=403) username = request.form['username'] password_1 = request.form['password_1'] @@ -257,17 +255,17 @@ def post_register(): 'error_type': 'PASSWORDS_DONT_MATCH', 'error_message': 'Passwords do not match.', } - return jsonify.make_json_response(response, status=422) + return etiquette_flask.jsonify.make_json_response(response, status=422) try: user = P.register_user(username, password_1) - except exceptions.EtiquetteException as e: - response = jsonify.exception(e) - return jsonify.make_json_response(response, status=400) + except etiquette.exceptions.EtiquetteException as e: + response = etiquette.jsonify.exception(e) + return etiquette_flask.jsonify.make_json_response(response, status=400) - session = sessions.Session(request, user) + session = etiquette_flask.sessions.Session(request, user) session_manager.add(session) - return jsonify.make_json_response({}) + return etiquette_flask.jsonify.make_json_response({}) @site.route('/logout', methods=['GET', 'POST']) @session_manager.give_token @@ -280,8 +278,7 @@ def logout(): @site.route('/favicon.ico') @site.route('/favicon.png') def favicon(): - filename = os.path.join(STATIC_DIR, 'favicon.png') - return flask.send_file(filename) + return flask.send_file(FAVICON_PATH.absolute_path) def get_album_core(albumid): @@ -305,11 +302,11 @@ def get_album_html(albumid): @session_manager.give_token def get_album_json(albumid): album = get_album_core(albumid) - album = jsonify.album(album) + album = etiquette.jsonify.album(album) album['sub_albums'] = [P_album(x) for x in album['sub_albums']] album['sub_albums'].sort(key=lambda x: (x.title or x.id).lower()) - album['sub_albums'] = [jsonify.album(x, minimal=True) for x in album['sub_albums']] - return jsonify.make_json_response(album) + album['sub_albums'] = [etiquette.jsonify.album(x, minimal=True) for x in album['sub_albums']] + return etiquette_flask.jsonify.make_json_response(album) @site.route('/album/.zip') @@ -317,16 +314,16 @@ def get_album_zip(albumid): album = P_album(albumid) recursive = request.args.get('recursive', True) - recursive = helpers.truthystring(recursive) + recursive = etiquette.helpers.truthystring(recursive) - arcnames = helpers.album_zip_filenames(album, recursive=recursive) + arcnames = etiquette.helpers.album_zip_filenames(album, recursive=recursive) streamed_zip = zipstream.ZipFile() for (real_filepath, arcname) in arcnames.items(): streamed_zip.write(real_filepath, arcname=arcname) # Add the album metadata as an {id}.txt file within each directory. - directories = helpers.album_zip_directories(album, recursive=recursive) + directories = etiquette.helpers.album_zip_directories(album, recursive=recursive) for (inner_album, directory) in directories.items(): text = [] if inner_album.title: @@ -346,7 +343,7 @@ def get_album_zip(albumid): else: download_as = 'album %s.zip' % album.id - download_as = helpers.normalize_filepath(download_as) + download_as = etiquette.helpers.normalize_filepath(download_as) download_as = urllib.parse.quote(download_as) outgoing_headers = { 'Content-Type': 'application/octet-stream', @@ -372,8 +369,8 @@ def get_albums_html(): @session_manager.give_token def get_albums_json(): albums = get_albums_core() - albums = [jsonify.album(album, minimal=True) for album in albums] - return jsonify.make_json_response(albums) + albums = [etiquette.jsonify.album(album, minimal=True) for album in albums] + return etiquette_flask.jsonify.make_json_response(albums) @site.route('/bookmarks') @@ -390,10 +387,10 @@ def get_file(photoid): photo = P.get_photo(photoid) do_download = request.args.get('download', False) - do_download = helpers.truthystring(do_download) + do_download = etiquette.helpers.truthystring(do_download) use_original_filename = request.args.get('original_filename', False) - use_original_filename = helpers.truthystring(use_original_filename) + use_original_filename = etiquette.helpers.truthystring(use_original_filename) if do_download: if use_original_filename: @@ -401,7 +398,7 @@ def get_file(photoid): else: download_as = photo.id + photo.dot_extension - download_as = helpers.normalize_filepath(download_as) + download_as = etiquette.helpers.normalize_filepath(download_as) download_as = urllib.parse.quote(download_as) response = flask.make_response(send_file(photo.real_filepath)) response.headers['Content-Disposition'] = 'attachment; filename*=UTF-8\'\'%s' % download_as @@ -421,12 +418,12 @@ def get_photo_html(photoid): @session_manager.give_token def get_photo_json(photoid): photo = P_photo(photoid, response_type='json') - photo = jsonify.photo(photo) - photo = jsonify.make_json_response(photo) + photo = etiquette.jsonify.photo(photo) + photo = etiquette_flask.jsonify.make_json_response(photo) return photo def get_search_core(): - warning_bag = objects.WarningBag() + warning_bag = etiquette.objects.WarningBag() has_tags = request.args.get('has_tags') tag_musts = request.args.get('tag_musts') @@ -442,7 +439,7 @@ def get_search_core(): limit = request.args.get('limit') # This is being pre-processed because the site enforces a maximum value # which the PhotoDB api does not. - limit = searchhelpers.normalize_limit(limit, warning_bag=warning_bag) + limit = etiquette.searchhelpers.normalize_limit(limit, warning_bag=warning_bag) if limit is None: limit = 50 @@ -515,7 +512,7 @@ def get_search_core(): warnings = set() photos = [] for item in search_results: - if isinstance(item, objects.WarningBag): + if isinstance(item, etiquette.objects.WarningBag): warnings.update(item.warnings) else: photos.append(item) @@ -534,7 +531,7 @@ def get_search_core(): if len(photos) == limit: next_params = original_params.copy() next_params['offset'] = offset + limit - next_params = helpers.dict_to_params(next_params) + next_params = etiquette.helpers.dict_to_params(next_params) next_page_url = '/search' + next_params else: next_page_url = None @@ -542,7 +539,7 @@ def get_search_core(): if offset > 0: prev_params = original_params.copy() prev_params['offset'] = max(0, offset - limit) - prev_params = helpers.dict_to_params(prev_params) + prev_params = etiquette.helpers.dict_to_params(prev_params) prev_page_url = '/search' + prev_params else: prev_page_url = None @@ -565,7 +562,7 @@ def get_search_core(): def get_search_html(): search_results = get_search_core() search_kwargs = search_results['search_kwargs'] - qualname_map = P.export_tags(exporter=photodb.tag_export_qualname_map) + qualname_map = P.export_tags(exporter=etiquette.photodb.tag_export_qualname_map) session = session_manager.get(request) response = flask.render_template( 'search.html', @@ -585,9 +582,9 @@ def get_search_html(): def get_search_json(): search_results = get_search_core() search_results['photos'] = [ - jsonify.photo(photo, include_albums=False) for photo in search_results['photos'] + etiquette.jsonify.photo(photo, include_albums=False) for photo in search_results['photos'] ] - return jsonify.make_json_response(search_results) + return etiquette_flask.jsonify.make_json_response(search_results) def get_tags_core(specific_tag=None): @@ -608,7 +605,7 @@ def get_tags_html(specific_tag=None): tags = get_tags_core(specific_tag) session = session_manager.get(request) include_synonyms = request.args.get('synonyms') - include_synonyms = include_synonyms is None or helpers.truthystring(include_synonyms) + include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms) response = flask.render_template( 'tags.html', include_synonyms=include_synonyms, @@ -625,9 +622,9 @@ def get_tags_json(specific_tag=None): specific_tag = P_tag(specific_tag, response_type='json') tags = get_tags_core(specific_tag) include_synonyms = request.args.get('synonyms') - include_synonyms = include_synonyms is None or helpers.truthystring(include_synonyms) - tags = [jsonify.tag(tag, include_synonyms=include_synonyms) for tag in tags] - return jsonify.make_json_response(tags) + include_synonyms = include_synonyms is None or etiquette.helpers.truthystring(include_synonyms) + tags = [etiquette.jsonify.tag(tag, include_synonyms=include_synonyms) for tag in tags] + return etiquette_flask.jsonify.make_json_response(tags) @site.route('/thumbnail/') @@ -656,8 +653,8 @@ def get_user_html(username): @session_manager.give_token def get_user_json(username): user = get_user_core(username) - user = jsonify.user(user) - user = jsonify.make_json_response(user) + user = etiquette.jsonify.user(user) + user = etiquette_flask.jsonify.make_json_response(user) return user @@ -673,15 +670,15 @@ def post_album_add_tag(albumid): tag = request.form['tagname'].strip() try: tag = P_tag(tag) - except exceptions.NoSuchTag as e: - response = jsonify.exception(e) - return jsonify.make_json_response(response, status=404) + except etiquette.exceptions.NoSuchTag as e: + response = etiquette.jsonify.exception(e) + return etiquette_flask.jsonify.make_json_response(response, status=404) recursive = request.form.get('recursive', False) - recursive = helpers.truthystring(recursive) + recursive = etiquette.helpers.truthystring(recursive) album.add_tag_to_all(tag, nested_children=recursive) response['action'] = 'add_tag' response['tagname'] = tag.name - return jsonify.make_json_response(response) + return etiquette_flask.jsonify.make_json_response(response) @site.route('/album//edit', methods=['POST']) @@ -696,7 +693,7 @@ def post_album_edit(albumid): description = request.form.get('description', None) album.edit(title=title, description=description) response = {'title': album.title, 'description': album.description} - return jsonify.make_json_response(response) + return etiquette_flask.jsonify.make_json_response(response) def post_photo_add_remove_tag_core(photoid, tagname, add_or_remove): @@ -708,16 +705,16 @@ def post_photo_add_remove_tag_core(photoid, tagname, add_or_remove): photo.add_tag(tag) elif add_or_remove == 'remove': photo.remove_tag(tag) - except exceptions.EtiquetteException as e: - response = jsonify.exception(e) - response = jsonify.make_json_response(response, status=400) + except etiquette.exceptions.EtiquetteException as e: + response = etiquette.jsonify.exception(e) + response = etiquette_flask.jsonify.make_json_response(response, status=400) flask.abort(response) response = {'tagname': tag.name} - return jsonify.make_json_response(response) + return etiquette_flask.jsonify.make_json_response(response) @site.route('/photo//add_tag', methods=['POST']) -@decorators.required_fields(['tagname'], forbid_whitespace=True) +@etiquette_flask.decorators.required_fields(['tagname'], forbid_whitespace=True) def post_photo_add_tag(photoid): ''' Add a tag to this photo. @@ -725,7 +722,7 @@ def post_photo_add_tag(photoid): return post_photo_add_remove_tag_core(photoid, request.form['tagname'], 'add') @site.route('/photo//remove_tag', methods=['POST']) -@decorators.required_fields(['tagname'], forbid_whitespace=True) +@etiquette_flask.decorators.required_fields(['tagname'], forbid_whitespace=True) def post_photo_remove_tag(photoid): ''' Remove a tag from this photo. @@ -737,29 +734,30 @@ def post_photo_refresh_metadata(photoid): ''' Refresh the file metadata. ''' + P.caches['photo'].remove(photoid) photo = P_photo(photoid, response_type='json') try: photo.reload_metadata() - except exceptions.EtiquetteException as e: - response = jsonify.exception(e) - response = jsonify.make_json_response(response, status=400) + except etiquette.exceptions.EtiquetteException as e: + response = etiquette.jsonify.exception(e) + response = etiquette_flask.jsonify.make_json_response(response, status=400) flask.abort(response) - return jsonify.make_json_response({}) + return etiquette_flask.jsonify.make_json_response({}) def post_tag_create_delete_core(tagname, function): try: response = function(tagname) status = 200 - except exceptions.EtiquetteException as e: - response = jsonify.exception(e) + except etiquette.exceptions.EtiquetteException as e: + response = etiquette.jsonify.exception(e) status = 400 #print(response) - return jsonify.make_json_response(response, status=status) + return etiquette_flask.jsonify.make_json_response(response, status=status) @site.route('/tags/create_tag', methods=['POST']) -@decorators.required_fields(['tagname'], forbid_whitespace=True) +@etiquette_flask.decorators.required_fields(['tagname'], forbid_whitespace=True) def post_tag_create(): ''' Create a tag. @@ -767,7 +765,7 @@ def post_tag_create(): return post_tag_create_delete_core(request.form['tagname'], create_tag) @site.route('/tags/delete_tag', methods=['POST']) -@decorators.required_fields(['tagname'], forbid_whitespace=True) +@etiquette_flask.decorators.required_fields(['tagname'], forbid_whitespace=True) def post_tag_delete(): ''' Delete a tag. @@ -775,7 +773,7 @@ def post_tag_delete(): return post_tag_create_delete_core(request.form['tagname'], delete_tag) @site.route('/tags/delete_synonym', methods=['POST']) -@decorators.required_fields(['tagname'], forbid_whitespace=True) +@etiquette_flask.decorators.required_fields(['tagname'], forbid_whitespace=True) def post_tag_delete_synonym(): ''' Delete a synonym.