From be50451d1da591ab92f1c4ea8628a06ed45d637d Mon Sep 17 00:00:00 2001 From: rachmadani haryono Date: Thu, 28 Jun 2018 22:04:35 +0800 Subject: [PATCH] Feature/server (#289) * new: dev: version flag fix: dev: pylint error * chg: dev: sort route !cosmetic * new: dev: custom BaseModelView for buku bookmark * new: dev: formatted entry * new: dev: url render mode * new: dev: bookmark edit !wip * chg: dev: use existing form for bookmark * chg: dev: form name !refactor * new: dev: enable details views * new: dev: views module * new: dev: tag model view * chg: dev: only split page_size defined * chg: dev: use SelectMultipleField * fix: dev: Bookmark tags choices * chg dev: configure tags * chg: dev: tag edit form !wip * chg: dev: update bookmark model * chg: dev: remove unused function * new: dev: add flask wtf and admin * chg: dev: use SimpleNamespace instead namedtuple * new: dev: delete tags feature * new: dev: chatty parameter for delete_tag_at_index * fix: dev: skip confirmation when deleting tag * fix: dev: when update bookmark model * new: dev: update tag * chg: dev: use parse_tags method * new: dev: select2 field for tagsfield * chg: dev: remove unused code * fix: dev: syntax * fix: dev: update bookmark model * chg: dev: change api based on flask-api * fix: dev: new tags on tag_detail PUT * chg: dev: raise error when parsing failed * chg: dev: move server required package * new: dev: create_model * chg: dev: override abstract method model view class * chg: dev: delete model for bookmark * fix: dev: pylint ignore !cosmetic * new: dev: filter for tag * chg: dev: more filter for TagModel * new: dev: new filter for tag model * chg: dev: deduplicate filter * fix: dev: pylint !cosmetic * chg: dev: generalize tag, bookmark filter * chg: dev: add filters for bookmark * fix: dev: not equal filter * new: dev: url basic filter * chg: dev: configure bookmark model view * chg: dev: reorder bookmark view method * new: dev: tags number filter * chg: dev: bookmark url with unknown scheme * new: dev: network handle api * new: dev: modal edit/create for bookmark * chg: dev: link tag bookmark tag search * fix: dev: empty tag contain search * chg: dev: buku search option * new: dev: buku search to filter * chg: dev: front page search * chg: dev: move Statistic page to views module * fix: dev: bookmark search * new: dev: title filter * fix: dev: statistic label * fix: dev: link on statistic page * chg: dev: strip search value * fix: dev: bookmark entry fix * fix: dev: netloc modal on * fix: dev: pylint !cosmetic * chg: dev: remove duplicate package * chg: dev: move admin to root * fix: dev: link on statistic page * chg: dev: pin pyyaml package --- buku.py | 11 +- bukuserver/filters.py | 235 ++++++++ bukuserver/forms.py | 25 +- bukuserver/server.py | 131 ++-- bukuserver/static/bukuserver/js/bookmark.js | 8 + bukuserver/templates/bukuserver/base.html | 2 +- .../templates/bukuserver/bookmark_create.html | 6 + .../bukuserver/bookmark_create_modal.html | 6 + .../templates/bukuserver/bookmark_edit.html | 16 +- .../bukuserver/bookmark_edit_modal.html | 6 + bukuserver/templates/bukuserver/home.html | 39 ++ bukuserver/templates/bukuserver/index.html | 2 +- .../templates/bukuserver/statistic.html | 40 +- bukuserver/views.py | 562 ++++++++++++++++++ setup.py | 3 +- 15 files changed, 1007 insertions(+), 85 deletions(-) create mode 100644 bukuserver/filters.py create mode 100644 bukuserver/static/bukuserver/js/bookmark.js create mode 100644 bukuserver/templates/bukuserver/bookmark_create.html create mode 100644 bukuserver/templates/bukuserver/bookmark_create_modal.html create mode 100644 bukuserver/templates/bukuserver/bookmark_edit_modal.html create mode 100644 bukuserver/templates/bukuserver/home.html create mode 100644 bukuserver/views.py diff --git a/buku.py b/buku.py index 1367493..6325db1 100755 --- a/buku.py +++ b/buku.py @@ -697,7 +697,7 @@ class BukuDb: return True - def delete_tag_at_index(self, index, tags_in, delay_commit=False): + def delete_tag_at_index(self, index, tags_in, delay_commit=False, chatty=True): """Delete tags from bookmark tagset at index. Parameters @@ -709,6 +709,8 @@ class BukuDb: delay_commit : bool, optional True if record should not be committed to the DB, leaving commit responsibility to caller. Default is False. + chatty: bool, optional + Skip confirmation when set to False. Returns ------- @@ -719,9 +721,10 @@ class BukuDb: tags_to_delete = tags_in.strip(DELIM).split(DELIM) if index == 0: - resp = read_in('Delete the tag(s) from ALL bookmarks? (y/n): ') - if resp != 'y': - return False + if chatty: + resp = read_in('Delete the tag(s) from ALL bookmarks? (y/n): ') + if resp != 'y': + return False count = 0 match = "'%' || ? || '%'" diff --git a/bukuserver/filters.py b/bukuserver/filters.py new file mode 100644 index 0000000..6c139c5 --- /dev/null +++ b/bukuserver/filters.py @@ -0,0 +1,235 @@ +from enum import Enum + +from flask_admin.model import filters + + +class BookmarkField(Enum): + ID = 0 + URL = 1 + TITLE = 2 + TAGS = 3 + DESCRIPTION = 4 + + +def equal_func(query, value, index): + return filter(lambda x: x[index] == value, query) + + +def not_equal_func(query, value, index): + return filter(lambda x: x[index] != value, query) + + +def greater_func(query, value, index): + return filter(lambda x: x[index] > value, query) + + +def smaller_func(query, value, index): + return filter(lambda x: x[index] > value, query) + + +def in_list_func(query, value, index): + return filter(lambda x: x[index] in value, query) + + +def not_in_list_func(query, value, index): + return filter(lambda x: x[index] not in value, query) + + +def top_x_func(query, value, index): + items = sorted(set(x[index] for x in query), reverse=True) + top_x = items[:value] + return filter(lambda x: x[index] in top_x, query) + + +def bottom_x_func(query, value, index): + items = sorted(set(x[index] for x in query), reverse=False) + top_x = items[:value] + return filter(lambda x: x[index] in top_x, query) + + +class FilterType(Enum): + + EQUAL = {'func': equal_func, 'text':'equals'} + NOT_EQUAL = {'func': not_equal_func, 'text':'not equal'} + GREATER = {'func': greater_func, 'text':'greater than'} + SMALLER = {'func': smaller_func, 'text':'smaller than'} + IN_LIST = {'func': in_list_func, 'text':'in list'} + NOT_IN_LIST = {'func': not_in_list_func, 'text':'not in list'} + TOP_X = {'func': top_x_func, 'text': 'top x'} + BOTTOM_X = {'func': bottom_x_func, 'text': 'bottom x'} + + +class BaseFilter(filters.BaseFilter): + + def operation(self): + return getattr(self, 'operation_text') + + def apply(self, query, value): + return getattr(self, 'apply_func')(query, value, getattr(self, 'index')) + + +class TagBaseFilter(BaseFilter): + + def __init__(self, name, operation_text=None, apply_func=None, filter_type=None, options=None, data_type=None): + if operation_text in ('in list', 'not in list'): + super().__init__(name, options, data_type='select2-tags') + else: + super().__init__(name, options, data_type) + if name == 'name': + self.index = 0 + elif name == 'usage_count': + self.index = 1 + else: + raise ValueError('name: {}'.format(name)) + self.filter_type = None + if filter_type: + self.apply_func = filter_type.value['func'] + self.operation_text = filter_type.value['text'] + self.filter_type = filter_type + else: + self.apply_func = apply_func + self.operation_text = operation_text + + def clean(self, value): + if self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST) and self.name == 'usage_count': + value = [int(v.strip()) for v in value.split(',') if v.strip()] + elif self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST): + value = [v.strip() for v in value.split(',') if v.strip()] + elif self.name == 'usage_count': + value = int(value) + if self.filter_type in (FilterType.TOP_X, FilterType.BOTTOM_X) and value < 1: + raise ValueError + if isinstance(value, str): + return value.strip() + return value + + +class BookmarkBukuFilter(BaseFilter): + + def __init__(self, *args, **kwargs): + self.keys = { + 'all_keywords': 'match all', + 'deep': 'deep', + 'regex': 'regex' + } + for key, value in kwargs.items(): + if key in self.keys and value: + setattr(self, key, value) + else: + setattr(self, key, False) + list(map(lambda x: kwargs.pop(x), self.keys)) + super().__init__('buku', *args, **kwargs) + + def operation(self): + parts = [] + for key, value in self.keys.items(): + if getattr(self, key): + parts.append(value) + if not parts: + return 'search' + return 'search ' + ', '.join(parts) + + def apply(self, query, value): + return query + + +class BookmarkBaseFilter(BaseFilter): + + def __init__(self, name, operation_text=None, apply_func=None, filter_type=None, options=None, data_type=None): + if operation_text in ('in list', 'not in list'): + super().__init__(name, options, data_type='select2-tags') + else: + super().__init__(name, options, data_type) + bm_fields_dict = {x.name.lower(): x.value for x in BookmarkField} + if name in bm_fields_dict: + self.index = bm_fields_dict[name] + else: + raise ValueError('name: {}'.format(name)) + self.filter_type = None + if filter_type: + self.apply_func = filter_type.value['func'] + self.operation_text = filter_type.value['text'] + else: + self.apply_func = apply_func + self.operation_text = operation_text + + def clean(self, value): + if self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST) and self.name == BookmarkField.ID.name.lower(): + value = [int(v.strip()) for v in value.split(',') if v.strip()] + elif self.filter_type in (FilterType.IN_LIST, FilterType.NOT_IN_LIST): + value = [v.strip() for v in value.split(',') if v.strip()] + elif self.name == BookmarkField.ID.name.lower(): + value = int(value) + if self.filter_type in (FilterType.TOP_X, FilterType.BOTTOM_X) and value < 1: + raise ValueError + if isinstance(value, str): + return value.strip() + return value + + +class BookmarkTagNumberEqualFilter(BookmarkBaseFilter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply_func(query, value, index): + for item in query: + tags = [tag for tag in item[index].split(',') if tag] + if len(tags) == value: + yield item + + self.apply_func = apply_func + + def clean(self, value): + value = int(value) + if value < 0: + raise ValueError + return value + + +class BookmarkTagNumberGreaterFilter(BookmarkTagNumberEqualFilter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply_func(query, value, index): + for item in query: + tags = [tag for tag in item[index].split(',') if tag] + if len(tags) > value: + yield item + + self.apply_func = apply_func + + +class BookmarkTagNumberNotEqualFilter(BookmarkTagNumberEqualFilter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply_func(query, value, index): + for item in query: + tags = [tag for tag in item[index].split(',') if tag] + if len(tags) != value: + yield item + + self. apply_func = apply_func + + +class BookmarkTagNumberSmallerFilter(BookmarkBaseFilter): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def apply_func(query, value, index): + for item in query: + tags = [tag for tag in item[index].split(',') if tag] + if len(tags) < value: + yield item + + self.apply_func = apply_func + + def clean(self, value): + value = int(value) + if value < 1: + raise ValueError + return value diff --git a/bukuserver/forms.py b/bukuserver/forms.py index 5c8b45e..3f78adc 100644 --- a/bukuserver/forms.py +++ b/bukuserver/forms.py @@ -1,18 +1,23 @@ """Forms module.""" # pylint: disable=too-few-public-methods, missing-docstring from flask_wtf import FlaskForm -from wtforms import StringField, FieldList, BooleanField, validators +import wtforms class SearchBookmarksForm(FlaskForm): - keywords = FieldList(StringField('Keywords'), min_entries=1) - all_keywords = BooleanField('Match all keywords') - deep = BooleanField('Deep search') - regex = BooleanField('Regex') + keywords = wtforms.FieldList(wtforms.StringField('Keywords'), min_entries=1) + all_keywords = wtforms.BooleanField('Match all keywords') + deep = wtforms.BooleanField('Deep search') + regex = wtforms.BooleanField('Regex') -class CreateBookmarksForm(FlaskForm): - url = StringField(validators=[validators.required(), validators.URL(require_tld=False)]) - title = StringField() - tags = StringField() - description = StringField() +class HomeForm(SearchBookmarksForm): + keyword = wtforms.StringField('Keyword') + + +class BookmarkForm(FlaskForm): + url = wtforms.StringField( + validators=[wtforms.validators.required(), wtforms.validators.URL(require_tld=False)]) + title = wtforms.StringField() + tags = wtforms.StringField() + description = wtforms.TextAreaField() diff --git a/bukuserver/server.py b/bukuserver/server.py index c7ded26..8e9117f 100644 --- a/bukuserver/server.py +++ b/bukuserver/server.py @@ -2,12 +2,14 @@ # pylint: disable=wrong-import-order, ungrouped-imports """Server module.""" import os +import sys from collections import Counter from urllib.parse import urlparse -from buku import BukuDb +from buku import BukuDb, __version__, network_handler from flask.cli import FlaskGroup -from flask_api import status +from flask_admin import Admin +from flask_api import exceptions, FlaskAPI, status from flask_bootstrap import Bootstrap from flask_paginate import Pagination, get_page_parameter, get_per_page_parameter from markupsafe import Markup @@ -15,10 +17,10 @@ import arrow import click import flask from flask import ( + __version__ as flask_version, abort, current_app, flash, - Flask, jsonify, redirect, render_template, @@ -27,13 +29,11 @@ from flask import ( ) try: - from . import response, forms + from . import response, forms, views except ImportError: - from bukuserver import response, forms + from bukuserver import response, forms, views -DEFAULT_PER_PAGE = 10 -DEFAULT_URL_RENDER_MODE = 'full' STATISTIC_DATA = None @@ -50,6 +50,46 @@ def get_tags(): return res +def network_handle_detail(): + failed_resp = response.response_template['failure'], status.HTTP_400_BAD_REQUEST + url = request.data.get('url', None) + if not url: + return failed_resp + try: + res = network_handler(url) + return {'title': res[0], 'recognized mime': res[1], 'bad url': res[2]} + except Exception as e: + current_app.logger.debug(str(e)) + return failed_resp + + +def tag_list(): + tags = BukuDb().get_tag_all() + result = {'tags': tags[0]} + return result + + +def tag_detail(tag): + bukudb = BukuDb() + if request.method == 'GET': + tags = bukudb.get_tag_all() + if tag not in tags[1]: + raise exceptions.NotFound() + res = dict(name=tag, usage_count=tags[1][tag]) + elif request.method == 'PUT': + res = None + try: + new_tags = request.data.get('tags').split(',') + except AttributeError as e: + raise exceptions.ParseError(detail=str(e)) + result_flag = bukudb.replace_tag(tag, new_tags) + if result_flag: + res = response.response_template['success'], status.HTTP_200_OK + else: + res = response.response_template['failure'], status.HTTP_400_BAD_REQUEST + return res + + def update_tag(tag): res = None if request.method in ('PUT', 'POST'): @@ -71,11 +111,6 @@ def update_tag(tag): return res -def chunks(l, n): - n = max(1, n) - return (l[i:i+n] for i in range(0, len(l), n)) - - def bookmarks(): """Bookmarks.""" res = None @@ -85,10 +120,10 @@ def bookmarks(): get_per_page_parameter(), type=int, default=int( - current_app.config.get('BUKUSERVER_PER_PAGE', DEFAULT_PER_PAGE)) + current_app.config.get('BUKUSERVER_PER_PAGE', views.DEFAULT_PER_PAGE)) ) url_render_mode = current_app.config['BUKUSERVER_URL_RENDER_MODE'] - create_bookmarks_form = forms.CreateBookmarksForm() + create_bookmarks_form = forms.BookmarkForm() if request.method == 'GET': all_bookmarks = bukudb.get_rec_all() result = { @@ -116,7 +151,7 @@ def bookmarks(): current_app.logger.debug('total bookmarks:{}'.format(len(result['bookmarks']))) current_app.logger.debug('per page:{}'.format(per_page)) pagination_total = len(result['bookmarks']) - bms = list(chunks(result['bookmarks'], per_page)) + bms = list(views.chunks(result['bookmarks'], per_page)) try: result['bookmarks'] = bms[page-1] except IndexError as err: @@ -191,7 +226,7 @@ def bookmark_api(id): return jsonify(response.response_template['failure']), status.HTTP_400_BAD_REQUEST, \ {'ContentType': 'application/json'} bukudb = getattr(flask.g, 'bukudb', BukuDb()) - bookmark_form = forms.CreateBookmarksForm() + bookmark_form = forms.BookmarkForm() is_html_post_request = request.method == 'POST' and not request.path.startswith('/api/') if request.method == 'GET': bookmark = bukudb.get_rec_by_id(id) @@ -383,7 +418,7 @@ def search_bookmarks(): get_per_page_parameter(), type=int, default=int( - current_app.config.get('BUKUSERVER_PER_PAGE', DEFAULT_PER_PAGE)) + current_app.config.get('BUKUSERVER_PER_PAGE', views.DEFAULT_PER_PAGE)) ) res = None @@ -403,7 +438,7 @@ def search_bookmarks(): res = jsonify(result) else: pagination_total = len(result['bookmarks']) - bms = list(chunks(result['bookmarks'], per_page)) + bms = list(views.chunks(result['bookmarks'], per_page)) try: result['bookmarks'] = bms[page-1] except IndexError as err: @@ -418,7 +453,7 @@ def search_bookmarks(): 'bukuserver/bookmarks.html', result=result, pagination=pagination, search_bookmarks_form=search_bookmarks_form, - create_bookmarks_form=forms.CreateBookmarksForm(), + create_bookmarks_form=forms.BookmarkForm(), ) elif request.method == 'DELETE': if found_bookmarks is not None: @@ -520,13 +555,13 @@ def view_statistic(): def create_app(config_filename=None): """create app.""" - app = Flask(__name__) - per_page = int(os.getenv('BUKUSERVER_PER_PAGE', DEFAULT_PER_PAGE)) - per_page = per_page if per_page > 0 else DEFAULT_PER_PAGE + app = FlaskAPI(__name__) + per_page = int(os.getenv('BUKUSERVER_PER_PAGE', views.DEFAULT_PER_PAGE)) + per_page = per_page if per_page > 0 else views.DEFAULT_PER_PAGE app.config['BUKUSERVER_PER_PAGE'] = per_page - url_render_mode = os.getenv('BUKUSERVER_URL_RENDER_MODE', DEFAULT_URL_RENDER_MODE) + url_render_mode = os.getenv('BUKUSERVER_URL_RENDER_MODE', views.DEFAULT_URL_RENDER_MODE) if url_render_mode not in ('full', 'netloc'): - url_render_mode = DEFAULT_URL_RENDER_MODE + url_render_mode = views.DEFAULT_URL_RENDER_MODE app.config['BUKUSERVER_URL_RENDER_MODE'] = url_render_mode app.config['SECRET_KEY'] = os.getenv('BUKUSERVER_SECRET_KEY') or os.urandom(24) bukudb = BukuDb() @@ -541,16 +576,20 @@ def create_app(config_filename=None): app.jinja_env.filters['netloc'] = lambda x: urlparse(x).netloc # pylint: disable=no-member Bootstrap(app) + admin = Admin( + app, name='Buku Server', template_mode='bootstrap3', + index_view=views.CustomAdminIndexView( + template='bukuserver/home.html', url='/' + ) + ) # routing - app.add_url_rule('/api/tags', 'get_tags', get_tags, methods=['GET']) - app.add_url_rule('/tags', 'get_tags-html', get_tags, methods=['GET']) - app.add_url_rule('/api/tags/', 'update_tag', update_tag, methods=['PUT']) - app.add_url_rule('/tags/', 'update_tag-html', update_tag, methods=['POST']) + # api + app.add_url_rule('/api/tags', 'get_tags', tag_list, methods=['GET']) + app.add_url_rule('/api/tags/', 'update_tag', tag_detail, methods=['GET', 'PUT']) + app.add_url_rule('/api/network_handle', 'networkk_handle', network_handle_detail, methods=['POST']) app.add_url_rule('/api/bookmarks', 'bookmarks', bookmarks, methods=['GET', 'POST', 'DELETE']) - app.add_url_rule('/bookmarks', 'bookmarks-html', bookmarks, methods=['GET', 'POST', 'DELETE']) app.add_url_rule('/api/bookmarks/refresh', 'refresh_bookmarks', refresh_bookmarks, methods=['POST']) app.add_url_rule('/api/bookmarks/', 'bookmark_api', bookmark_api, methods=['GET', 'PUT', 'DELETE']) - app.add_url_rule('/bookmarks/', 'bookmark_api-html', bookmark_api, methods=['GET', 'POST']) app.add_url_rule('/api/bookmarks//refresh', 'refresh_bookmark', refresh_bookmark, methods=['POST']) app.add_url_rule('/api/bookmarks//tiny', 'get_tiny_url', get_tiny_url, methods=['GET']) app.add_url_rule('/api/bookmarks//long', 'get_long_url', get_long_url, methods=['GET']) @@ -558,14 +597,36 @@ def create_app(config_filename=None): '/api/bookmarks//', 'bookmark_range_operations', bookmark_range_operations, methods=['GET', 'PUT', 'DELETE']) app.add_url_rule('/api/bookmarks/search', 'search_bookmarks', search_bookmarks, methods=['GET', 'DELETE']) - app.add_url_rule('/bookmarks/search', 'search_bookmarks-html', search_bookmarks, methods=['GET']) - app.add_url_rule('/', 'index', lambda: render_template( - 'bukuserver/index.html', search_bookmarks_form=forms.SearchBookmarksForm())) - app.add_url_rule('/statistic', 'statistic', view_statistic, methods=['GET', 'POST']) + # non api + admin.add_view(views.BookmarkModelView( + bukudb, 'Bookmarks', page_size=per_page, url_render_mode=url_render_mode)) + admin.add_view(views.TagModelView( + bukudb, 'Tags', page_size=per_page)) + admin.add_view(views.StatisticView('Statistic', endpoint='statistic')) return app -@click.group(cls=FlaskGroup, create_app=create_app) +class CustomFlaskGroup(FlaskGroup): # pylint: disable=too-few-public-methods + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.params[0].help = 'Show the program version' + self.params[0].callback = get_custom_version + + +def get_custom_version(ctx, param, value): + if not value or ctx.resilient_parsing: + return + message = '%(app_name)s %(app_version)s\nFlask %(version)s\nPython %(python_version)s' + click.echo(message % { + 'app_name': 'Buku', + 'app_version': __version__, + 'version': flask_version, + 'python_version': sys.version, + }, color=ctx.color) + ctx.exit() + + +@click.group(cls=CustomFlaskGroup, create_app=create_app) def cli(): """This is a management script for the wiki application.""" diff --git a/bukuserver/static/bukuserver/js/bookmark.js b/bukuserver/static/bukuserver/js/bookmark.js new file mode 100644 index 0000000..7c6e57a --- /dev/null +++ b/bukuserver/static/bukuserver/js/bookmark.js @@ -0,0 +1,8 @@ +$(document).ready(function() { + $.getJSON( "/api/tags", function( json ) { + $('input#tags').select2({ + tags: json.tags, + tokenSeparators: [','], + }); + }); +}); diff --git a/bukuserver/templates/bukuserver/base.html b/bukuserver/templates/bukuserver/base.html index 4ef7e15..7ecc436 100644 --- a/bukuserver/templates/bukuserver/base.html +++ b/bukuserver/templates/bukuserver/base.html @@ -25,7 +25,7 @@
  • Home
  • Bookmarks
  • Tags
  • -
  • Statistics
  • +
  • Statistics