diff --git a/cps/__init__.py b/cps/__init__.py index c804d801..f6bb0cf7 100644 --- a/cps/__init__.py +++ b/cps/__init__.py @@ -127,7 +127,7 @@ def get_locale(): user = getattr(g, 'user', None) # user = None if user is not None and hasattr(user, "locale"): - if user.nickname != 'Guest': # if the account is the guest account bypass the config lang settings + if user.name != 'Guest': # if the account is the guest account bypass the config lang settings return user.locale preferred = list() diff --git a/cps/admin.py b/cps/admin.py index 3b2c237d..1d4b5a84 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -35,13 +35,15 @@ from flask import Blueprint, flash, redirect, url_for, abort, request, make_resp from flask_login import login_required, current_user, logout_user, confirm_login from flask_babel import gettext as _ from sqlalchemy import and_ +from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.exc import IntegrityError, OperationalError, InvalidRequestError from sqlalchemy.sql.expression import func, or_ from . import constants, logger, helper, services from .cli import filepicker from . import db, calibre_db, ub, web_server, get_locale, config, updater_thread, babel, gdriveutils -from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash +from .helper import check_valid_domain, send_test_mail, reset_password, generate_password_hash, check_email, \ + valid_email, check_username from .gdriveutils import is_gdrive_ready, gdrive_support from .render_template import render_title_template, get_sidebar_config from . import debug_info @@ -57,12 +59,12 @@ feature_support = { 'ldap': bool(services.ldap), 'goodreads': bool(services.goodreads_support), 'kobo': bool(services.kobo), - 'updater': constants.UPDATER_AVAILABLE + 'updater': constants.UPDATER_AVAILABLE, + 'gmail': bool(services.gmail) } try: - # pylint: disable=unused-import - import rarfile + import rarfile # pylint: disable=unused-import feature_support['rar'] = True except (ImportError, SyntaxError): feature_support['rar'] = False @@ -150,7 +152,7 @@ def shutdown(): else: showtext['text'] = _(u'Performing shutdown of server, please close window') # stop gevent/tornado server - web_server.stop(task == 0) + web_server.stop(task==0) return json.dumps(showtext) if task == 2: @@ -185,10 +187,10 @@ def admin(): else: commit = version['version'] - all_user = ub.session.query(ub.User).all() + allUser = ub.session.query(ub.User).all() email_settings = config.get_mail_settings() kobo_support = feature_support['kobo'] and config.config_kobo_sync - return render_title_template("admin.html", allUser=all_user, email=email_settings, config=config, commit=commit, + return render_title_template("admin.html", allUser=allUser, email=email_settings, config=config, commit=commit, feature_support=feature_support, kobo_support=kobo_support, title=_(u"Admin page"), page="admin") @@ -214,6 +216,173 @@ def view_configuration(): restrictColumns=restrict_columns, title=_(u"UI Configuration"), page="uiconfig") +@admi.route("/admin/usertable") +@login_required +@admin_required +def edit_user_table(): + visibility = current_user.view_settings.get('useredit', {}) + languages = calibre_db.speaking_language() + translations = babel.list_translations() + [LC('en')] + allUser = ub.session.query(ub.User) + if not config.config_anonbrowse: + allUser = allUser.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS) + + return render_title_template("user_table.html", + users=allUser.all(), + translations=translations, + languages=languages, + visiblility=visibility, + all_roles=constants.ALL_ROLES, + sidebar_settings=constants.sidebar_settings, + title=_(u"Edit Users"), + page="usertable") + +@admi.route("/ajax/listusers") +@login_required +@admin_required +def list_users(): + off = request.args.get("offset") or 0 + limit = request.args.get("limit") or 10 + search = request.args.get("search") + all_user = ub.session.query(ub.User) + if not config.config_anonbrowse: + all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS) + total_count = all_user.count() + if search: + users = all_user.filter(or_(func.lower(ub.User.name).ilike("%" + search + "%"), + func.lower(ub.User.kindle_mail).ilike("%" + search + "%"), + func.lower(ub.User.email).ilike("%" + search + "%")))\ + .offset(off).limit(limit).all() + filtered_count = len(users) + else: + users = all_user.offset(off).limit(limit).all() + filtered_count = total_count + + for user in users: + if user.default_language == "all": + user.default = _("all") + else: + user.default = LC.parse(user.default_language).get_language_name(get_locale()) + + table_entries = {'totalNotFiltered': total_count, 'total': filtered_count, "rows": users} + js_list = json.dumps(table_entries, cls=db.AlchemyEncoder) + response = make_response(js_list) + response.headers["Content-Type"] = "application/json; charset=utf-8" + return response + +@admi.route("/ajax/deleteuser") +@login_required +@admin_required +def delete_user(): + # ToDo User delete check also not last one + return "" + +@admi.route("/ajax/getlocale") +@login_required +@admin_required +def table_get_locale(): + locale = babel.list_translations() + [LC('en')] + ret = list() + current_locale = get_locale() + for loc in locale: + ret.append({'value': str(loc), 'text': loc.get_language_name(current_locale)}) + return json.dumps(ret) + + +@admi.route("/ajax/getdefaultlanguage") +@login_required +@admin_required +def table_get_default_lang(): + languages = calibre_db.speaking_language() + ret = list() + ret.append({'value':'all','text':_('Show All')}) + for lang in languages: + ret.append({'value': lang.lang_code, 'text': lang.name}) + return json.dumps(ret) + + +@admi.route("/ajax/editlistusers/", methods=['POST']) +@login_required +@admin_required +def edit_list_user(param): + vals = request.form.to_dict(flat=False) + all_user = ub.session.query(ub.User) + if not config.config_anonbrowse: + all_user = all_user.filter(ub.User.role.op('&')(constants.ROLE_ANONYMOUS) != constants.ROLE_ANONYMOUS) + # only one user is posted + if "pk" in vals: + users = [all_user.filter(ub.User.id == vals['pk'][0]).one_or_none()] + else: + if "pk[]" in vals: + users = all_user.filter(ub.User.id.in_(vals['pk[]'])).all() + else: + return "" + if 'field_index' in vals: + vals['field_index'] = vals['field_index'][0] + if 'value' in vals: + vals['value'] = vals['value'][0] + else: + return "" + for user in users: + try: + vals['value'] = vals['value'].strip() + if param == 'name': + if user.name == "Guest": + raise Exception(_("Guest Name can't be changed")) + user.name = check_username(vals['value']) + elif param =='email': + user.email = check_email(vals['value']) + elif param == 'kindle_mail': + user.kindle_mail = valid_email(vals['value']) if vals['value'] else "" + elif param == 'role': + if vals['value'] == 'true': + user.role |= int(vals['field_index']) + else: + if int(vals['field_index']) == constants.ROLE_ADMIN: + if not ub.session.query(ub.User).\ + filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, + ub.User.id != user.id).count(): + return _(u"No admin user remaining, can't remove admin role", nick=user.name), 400 + user.role &= ~int(vals['field_index']) + elif param == 'sidebar_view': + if vals['value'] == 'true': + user.sidebar_view |= int(vals['field_index']) + else: + user.sidebar_view &= ~int(vals['field_index']) + elif param == 'denied_tags': + user.denied_tags = vals['value'] + elif param == 'allowed_tags': + user.allowed_tags = vals['value'] + elif param == 'allowed_column_value': + user.allowed_column_value = vals['value'] + elif param == 'denied_column_value': + user.denied_column_value = vals['value'] + elif param == 'locale': + user.locale = vals['value'] + elif param == 'default_language': + user.default_language = vals['value'] + except Exception as ex: + return str(ex), 400 + ub.session_commit() + return "" + + +@admi.route("/ajax/user_table_settings", methods=['POST']) +@login_required +@admin_required +def update_table_settings(): + current_user.view_settings['useredit'] = json.loads(request.data) + try: + try: + flag_modified(current_user, "view_settings") + except AttributeError: + pass + ub.session.commit() + except (InvalidRequestError, OperationalError): + log.error("Invalid request received: {}".format(request)) + return "Invalid request", 400 + return "" + @admi.route("/admin/viewconfig", methods=["POST"]) @login_required @@ -262,6 +431,14 @@ def load_dialogtexts(element_id): texts["main"] = _('Do you really want to delete this user?') elif element_id == "delete_shelf": texts["main"] = _('Are you sure you want to delete this shelf?') + elif element_id == "select_locale": + texts["main"] = _('Are you sure you want to change locales of selected user(s)?') + elif element_id == "select_default_language": + texts["main"] = _('Are you sure you want to change visible book languages for selected user(s)?') + elif element_id == "role": + texts["main"] = _('Are you sure you want to change the selected role for the selected user(s)?') + elif element_id == "sidebar_view": + texts["main"] = _('Are you sure you want to change the selected visibility restrictions for the selected user(s)?') return json.dumps(texts) @@ -348,7 +525,7 @@ def edit_restriction(res_type, user_id): elementlist = usr.list_allowed_tags() elementlist[int(element['id'][1:])] = element['Element'] usr.allowed_tags = ','.join(elementlist) - ub.session_commit("Changed allowed tags of user {} to {}".format(usr.nickname, usr.allowed_tags)) + ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.allowed_tags)) if res_type == 3: # CColumn per user if isinstance(user_id, int): usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() @@ -357,7 +534,7 @@ def edit_restriction(res_type, user_id): elementlist = usr.list_allowed_column_values() elementlist[int(element['id'][1:])] = element['Element'] usr.allowed_column_value = ','.join(elementlist) - ub.session_commit("Changed allowed columns of user {} to {}".format(usr.nickname, usr.allowed_column_value)) + ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.allowed_column_value)) if element['id'].startswith('d'): if res_type == 0: # Tags as template elementlist = config.list_denied_tags() @@ -377,7 +554,7 @@ def edit_restriction(res_type, user_id): elementlist = usr.list_denied_tags() elementlist[int(element['id'][1:])] = element['Element'] usr.denied_tags = ','.join(elementlist) - ub.session_commit("Changed denied tags of user {} to {}".format(usr.nickname, usr.denied_tags)) + ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.denied_tags)) if res_type == 3: # CColumn per user if isinstance(user_id, int): usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() @@ -386,7 +563,7 @@ def edit_restriction(res_type, user_id): elementlist = usr.list_denied_column_values() elementlist[int(element['id'][1:])] = element['Element'] usr.denied_column_value = ','.join(elementlist) - ub.session_commit("Changed denied columns of user {} to {}".format(usr.nickname, usr.denied_column_value)) + ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.denied_column_value)) return "" @@ -433,10 +610,10 @@ def add_restriction(res_type, user_id): usr = current_user if 'submit_allow' in element: usr.allowed_tags = restriction_addition(element, usr.list_allowed_tags) - ub.session_commit("Changed allowed tags of user {} to {}".format(usr.nickname, usr.list_allowed_tags)) + ub.session_commit("Changed allowed tags of user {} to {}".format(usr.name, usr.list_allowed_tags)) elif 'submit_deny' in element: usr.denied_tags = restriction_addition(element, usr.list_denied_tags) - ub.session_commit("Changed denied tags of user {} to {}".format(usr.nickname, usr.list_denied_tags)) + ub.session_commit("Changed denied tags of user {} to {}".format(usr.name, usr.list_denied_tags)) if res_type == 3: # CustomC per user if isinstance(user_id, int): usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() @@ -444,11 +621,11 @@ def add_restriction(res_type, user_id): usr = current_user if 'submit_allow' in element: usr.allowed_column_value = restriction_addition(element, usr.list_allowed_column_values) - ub.session_commit("Changed allowed columns of user {} to {}".format(usr.nickname, + ub.session_commit("Changed allowed columns of user {} to {}".format(usr.name, usr.list_allowed_column_values)) elif 'submit_deny' in element: usr.denied_column_value = restriction_addition(element, usr.list_denied_column_values) - ub.session_commit("Changed denied columns of user {} to {}".format(usr.nickname, + ub.session_commit("Changed denied columns of user {} to {}".format(usr.name, usr.list_denied_column_values)) return "" @@ -480,10 +657,10 @@ def delete_restriction(res_type, user_id): usr = current_user if element['id'].startswith('a'): usr.allowed_tags = restriction_deletion(element, usr.list_allowed_tags) - ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.nickname, usr.list_allowed_tags)) + ub.session_commit("Deleted allowed tags of user {}: {}".format(usr.name, usr.list_allowed_tags)) elif element['id'].startswith('d'): usr.denied_tags = restriction_deletion(element, usr.list_denied_tags) - ub.session_commit("Deleted denied tags of user {}: {}".format(usr.nickname, usr.list_allowed_tags)) + ub.session_commit("Deleted denied tags of user {}: {}".format(usr.name, usr.list_allowed_tags)) elif res_type == 3: # Columns per user if isinstance(user_id, int): usr = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() @@ -491,12 +668,12 @@ def delete_restriction(res_type, user_id): usr = current_user if element['id'].startswith('a'): usr.allowed_column_value = restriction_deletion(element, usr.list_allowed_column_values) - ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.nickname, + ub.session_commit("Deleted allowed columns of user {}: {}".format(usr.name, usr.list_allowed_column_values)) elif element['id'].startswith('d'): usr.denied_column_value = restriction_deletion(element, usr.list_denied_column_values) - ub.session_commit("Deleted denied columns of user {}: {}".format(usr.nickname, + ub.session_commit("Deleted denied columns of user {}: {}".format(usr.name, usr.list_denied_column_values)) return "" @@ -602,7 +779,6 @@ def pathchooser(): folders = [] files = [] - # locale = get_locale() for f in folders: try: data = {"name": f, "fullpath": os.path.join(cwd, f)} @@ -730,13 +906,35 @@ def _configuration_logfile_helper(to_save, gdrive_error): return reboot_required, None -def _configuration_ldap_check(reboot_required, to_save, gdrive_error): +def _configuration_ldap_helper(to_save, gdrive_error): + reboot_required = False + reboot_required |= _config_string(to_save, "config_ldap_provider_url") + reboot_required |= _config_int(to_save, "config_ldap_port") + reboot_required |= _config_int(to_save, "config_ldap_authentication") + reboot_required |= _config_string(to_save, "config_ldap_dn") + reboot_required |= _config_string(to_save, "config_ldap_serv_username") + reboot_required |= _config_string(to_save, "config_ldap_user_object") + reboot_required |= _config_string(to_save, "config_ldap_group_object_filter") + reboot_required |= _config_string(to_save, "config_ldap_group_members_field") + reboot_required |= _config_string(to_save, "config_ldap_member_user_object") + reboot_required |= _config_checkbox(to_save, "config_ldap_openldap") + reboot_required |= _config_int(to_save, "config_ldap_encryption") + reboot_required |= _config_string(to_save, "config_ldap_cacert_path") + reboot_required |= _config_string(to_save, "config_ldap_cert_path") + reboot_required |= _config_string(to_save, "config_ldap_key_path") + _config_string(to_save, "config_ldap_group_name") + if to_save.get("config_ldap_serv_password", "") != "": + reboot_required |= 1 + config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') + config.save() + if not config.config_ldap_provider_url \ - or not config.config_ldap_port \ - or not config.config_ldap_dn \ - or not config.config_ldap_user_object: + or not config.config_ldap_port \ + or not config.config_ldap_dn \ + or not config.config_ldap_user_object: return reboot_required, _configuration_result(_('Please Enter a LDAP Provider, ' 'Port, DN and User Object Identifier'), gdrive_error) + if config.config_ldap_authentication > constants.LDAP_AUTH_ANONYMOUS: if config.config_ldap_authentication > constants.LDAP_AUTH_UNAUTHENTICATE: if not config.config_ldap_serv_username or not bool(config.config_ldap_serv_password): @@ -746,14 +944,6 @@ def _configuration_ldap_check(reboot_required, to_save, gdrive_error): if not config.config_ldap_serv_username: return reboot_required, _configuration_result('Please Enter a LDAP Service Account', gdrive_error) - if config.config_ldap_group_object_filter: - if config.config_ldap_group_object_filter.count("%s") != 1: - return reboot_required, \ - _configuration_result(_('LDAP Group Object Filter Needs to Have One "%s" Format Identifier'), - gdrive_error) - if config.config_ldap_group_object_filter.count("(") != config.config_ldap_group_object_filter.count(")"): - return reboot_required, _configuration_result(_('LDAP Group Object Filter Has Unmatched Parenthesis'), - gdrive_error) if config.config_ldap_group_object_filter: if config.config_ldap_group_object_filter.count("%s") != 1: return reboot_required, \ @@ -771,7 +961,7 @@ def _configuration_ldap_check(reboot_required, to_save, gdrive_error): return reboot_required, _configuration_result(_('LDAP User Object Filter Has Unmatched Parenthesis'), gdrive_error) - if "ldap_import_user_filter" in to_save and to_save["ldap_import_user_filter"] == '0': + if to_save.get("ldap_import_user_filter") == '0': config.config_ldap_member_user_object = "" else: if config.config_ldap_member_user_object.count("%s") != 1: @@ -793,31 +983,6 @@ def _configuration_ldap_check(reboot_required, to_save, gdrive_error): return reboot_required, None -def _configuration_ldap_helper(to_save, gdrive_error): - reboot_required = False - reboot_required |= _config_string(to_save, "config_ldap_provider_url") - reboot_required |= _config_int(to_save, "config_ldap_port") - reboot_required |= _config_int(to_save, "config_ldap_authentication") - reboot_required |= _config_string(to_save, "config_ldap_dn") - reboot_required |= _config_string(to_save, "config_ldap_serv_username") - reboot_required |= _config_string(to_save, "config_ldap_user_object") - reboot_required |= _config_string(to_save, "config_ldap_group_object_filter") - reboot_required |= _config_string(to_save, "config_ldap_group_members_field") - reboot_required |= _config_string(to_save, "config_ldap_member_user_object") - reboot_required |= _config_checkbox(to_save, "config_ldap_openldap") - reboot_required |= _config_int(to_save, "config_ldap_encryption") - reboot_required |= _config_string(to_save, "config_ldap_cacert_path") - reboot_required |= _config_string(to_save, "config_ldap_cert_path") - reboot_required |= _config_string(to_save, "config_ldap_key_path") - _config_string(to_save, "config_ldap_group_name") - if "config_ldap_serv_password" in to_save and to_save["config_ldap_serv_password"] != "": - reboot_required |= 1 - config.set_from_dictionary(to_save, "config_ldap_serv_password", base64.b64encode, encode='UTF-8') - config.save() - - return _configuration_ldap_check(reboot_required, to_save, gdrive_error) - - def _configuration_update_helper(configured): reboot_required = False db_change = False @@ -921,8 +1086,8 @@ def _configuration_update_helper(configured): if config.config_use_google_drive and is_gdrive_ready() and not os.path.exists(metadata_db): gdriveutils.downloadFile(None, "metadata.db", metadata_db) db_change = True - except Exception as e: - return _configuration_result('%s' % e, gdrive_error, configured) + except Exception as ex: + return _configuration_result('%s' % ex, gdrive_error, configured) if db_change: if not calibre_db.setup_db(config, ub.app_DB_path): @@ -974,7 +1139,6 @@ def _configuration_result(error_flash=None, gdrive_error=None, configured=True): def _handle_new_user(to_save, content, languages, translations, kobo_support): content.default_language = to_save["default_language"] - # content.mature_content = "Show_mature_content" in to_save content.locale = to_save.get("locale", content.locale) content.sidebar_view = sum(int(key[5:]) for key in to_save if key.startswith('show_')) @@ -982,28 +1146,21 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support): content.sidebar_view |= constants.DETAIL_RANDOM content.role = constants.selected_roles(to_save) - - if not to_save["nickname"] or not to_save["email"] or not to_save["password"]: - flash(_(u"Please fill out all fields!"), category="error") - return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - registered_oauth=oauth_check, kobo_support=kobo_support, - title=_(u"Add new user")) content.password = generate_password_hash(to_save["password"]) - existing_user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == to_save["nickname"].lower()) \ - .first() - existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ - .first() - if not existing_user and not existing_email: - content.nickname = to_save["nickname"] - if config.config_public_reg and not check_valid_domain(to_save["email"]): - flash(_(u"E-mail is not from valid domain"), category="error") - return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, - registered_oauth=oauth_check, kobo_support=kobo_support, - title=_(u"Add new user")) - else: - content.email = to_save["email"] - else: - flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") + try: + if not to_save["name"] or not to_save["email"] or not to_save["password"]: + log.info("Missing entries on new user") + raise Exception(_(u"Please fill out all fields!")) + content.email = check_email(to_save["email"]) + # Query User name, if not existing, change + content.name = check_username(to_save["name"]) + if to_save.get("kindle_mail"): + content.kindle_mail = valid_email(to_save["kindle_mail"]) + if config.config_public_reg and not check_valid_domain(content.email): + log.info("E-mail: {} for new user is not from valid domain".format(content.email)) + raise Exception(_(u"E-mail is not from valid domain")) + except Exception as ex: + flash(str(ex), category="error") return render_title_template("user_edit.html", new_user=1, content=content, translations=translations, languages=languages, title=_(u"Add new user"), page="newuser", kobo_support=kobo_support, registered_oauth=oauth_check) @@ -1014,49 +1171,33 @@ def _handle_new_user(to_save, content, languages, translations, kobo_support): content.denied_column_value = config.config_denied_column_value ub.session.add(content) ub.session.commit() - flash(_(u"User '%(user)s' created", user=content.nickname), category="success") + flash(_(u"User '%(user)s' created", user=content.name), category="success") return redirect(url_for('admin.admin')) except IntegrityError: ub.session.rollback() - flash(_(u"Found an existing account for this e-mail address or nickname."), category="error") - except OperationalError: - ub.session.rollback() - flash(_(u"Settings DB is not Writeable"), category="error") - -def delete_user(content): - if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, - ub.User.id != content.id).count(): - ub.session.query(ub.User).filter(ub.User.id == content.id).delete() - ub.session_commit() - flash(_(u"User '%(nick)s' deleted", nick=content.nickname), category="success") - return redirect(url_for('admin.admin')) - else: - flash(_(u"No admin user remaining, can't delete user", nick=content.nickname), category="error") - return redirect(url_for('admin.admin')) - - -def save_edited_user(content): - try: - ub.session_commit() - flash(_(u"User '%(nick)s' updated", nick=content.nickname), category="success") - except IntegrityError: - ub.session.rollback() - flash(_(u"An unknown error occured."), category="error") + flash(_(u"Found an existing account for this e-mail address or name."), category="error") except OperationalError: ub.session.rollback() flash(_(u"Settings DB is not Writeable"), category="error") def _handle_edit_user(to_save, content, languages, translations, kobo_support): - if "delete" in to_save: - return delete_user(content) + if to_save.get("delete"): + if ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, + ub.User.id != content.id).count(): + ub.session.query(ub.User).filter(ub.User.id == content.id).delete() + ub.session_commit() + flash(_(u"User '%(nick)s' deleted", nick=content.name), category="success") + return redirect(url_for('admin.admin')) + else: + flash(_(u"No admin user remaining, can't delete user", nick=content.name), category="error") + return redirect(url_for('admin.admin')) else: if not ub.session.query(ub.User).filter(ub.User.role.op('&')(constants.ROLE_ADMIN) == constants.ROLE_ADMIN, ub.User.id != content.id).count() and 'admin_role' not in to_save: - flash(_(u"No admin user remaining, can't remove admin role", nick=content.nickname), category="error") + flash(_(u"No admin user remaining, can't remove admin role", nick=content.name), category="error") return redirect(url_for('admin.admin')) - - if "password" in to_save and to_save["password"]: + if to_save.get("password"): content.password = generate_password_hash(to_save["password"]) anonymous = content.is_anonymous content.role = constants.selected_roles(to_save) @@ -1074,50 +1215,46 @@ def _handle_edit_user(to_save, content, languages, translations, kobo_support): elif value not in val and content.check_visibility(value): content.sidebar_view &= ~value - if "Show_detail_random" in to_save: + if to_save.get("Show_detail_random"): content.sidebar_view |= constants.DETAIL_RANDOM else: content.sidebar_view &= ~constants.DETAIL_RANDOM - if "default_language" in to_save: + if to_save.get("default_language"): content.default_language = to_save["default_language"] - if "locale" in to_save and to_save["locale"]: + if to_save.get("locale"): content.locale = to_save["locale"] - if to_save["email"] and to_save["email"] != content.email: - existing_email = ub.session.query(ub.User).filter(ub.User.email == to_save["email"].lower()) \ - .first() - if not existing_email: - content.email = to_save["email"] - else: - flash(_(u"Found an existing account for this e-mail address."), category="error") - return render_title_template("user_edit.html", - translations=translations, - languages=languages, - mail_configured=config.get_mail_server_configured(), - kobo_support=kobo_support, - new_user=0, - content=content, - registered_oauth=oauth_check, - title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") - if "nickname" in to_save and to_save["nickname"] != content.nickname: - # Query User nickname, if not existing, change - if not ub.session.query(ub.User).filter(ub.User.nickname == to_save["nickname"]).scalar(): - content.nickname = to_save["nickname"] - else: - flash(_(u"This username is already taken"), category="error") - return render_title_template("user_edit.html", - translations=translations, - languages=languages, - mail_configured=config.get_mail_server_configured(), - new_user=0, content=content, - registered_oauth=oauth_check, - kobo_support=kobo_support, - title=_(u"Edit User %(nick)s", nick=content.nickname), - page="edituser") - - if "kindle_mail" in to_save and to_save["kindle_mail"] != content.kindle_mail: - content.kindle_mail = to_save["kindle_mail"] - return save_edited_user(content) + try: + if to_save.get("email", content.email) != content.email: + content.email = check_email(to_save["email"]) + # Query User name, if not existing, change + if to_save.get("name", content.name) != content.name: + if to_save.get("name") == "Guest": + raise Exception(_("Guest Name can't be changed")) + content.name = check_username(to_save["name"]) + if to_save.get("kindle_mail") != content.kindle_mail: + content.kindle_mail = valid_email(to_save["kindle_mail"]) if to_save["kindle_mail"] else "" + except Exception as ex: + flash(str(ex), category="error") + return render_title_template("user_edit.html", + translations=translations, + languages=languages, + mail_configured=config.get_mail_server_configured(), + kobo_support=kobo_support, + new_user=0, + content=content, + registered_oauth=oauth_check, + title=_(u"Edit User %(nick)s", nick=content.name), + page="edituser") + try: + ub.session_commit() + flash(_(u"User '%(nick)s' updated", nick=content.name), category="success") + except IntegrityError: + ub.session.rollback() + flash(_(u"An unknown error occured."), category="error") + except OperationalError: + ub.session.rollback() + flash(_(u"Settings DB is not Writeable"), category="error") @admi.route("/admin/user/new", methods=["GET", "POST"]) @@ -1145,7 +1282,7 @@ def new_user(): def edit_mailsettings(): content = config.get_mail_settings() return render_title_template("email_edit.html", content=content, title=_(u"Edit E-mail Server Settings"), - page="mailset") + page="mailset", feature_support=feature_support) @admi.route("/admin/mailsettings", methods=["POST"]) @@ -1153,14 +1290,30 @@ def edit_mailsettings(): @admin_required def update_mailsettings(): to_save = request.form.to_dict() + _config_int(to_save, "mail_server_type") + if to_save.get("invalidate"): + config.mail_gmail_token = {} + try: + flag_modified(config, "mail_gmail_token") + except AttributeError: + pass + elif to_save.get("gmail"): + try: + config.mail_gmail_token = services.gmail.setup_gmail(config.mail_gmail_token) + flash(_(u"G-Mail Account Verification Successful"), category="success") + except Exception as ex: + flash(str(ex), category="error") + log.error(ex) + return edit_mailsettings() - _config_string(to_save, "mail_server") - _config_int(to_save, "mail_port") - _config_int(to_save, "mail_use_ssl") - _config_string(to_save, "mail_login") - _config_string(to_save, "mail_password") - _config_string(to_save, "mail_from") - _config_int(to_save, "mail_size", lambda y: int(y)*1024*1024) + else: + _config_string(to_save, "mail_server") + _config_int(to_save, "mail_port") + _config_int(to_save, "mail_use_ssl") + _config_string(to_save, "mail_login") + _config_string(to_save, "mail_password") + _config_string(to_save, "mail_from") + _config_int(to_save, "mail_size", lambda y: int(y)*1024*1024) try: config.save() except (OperationalError, InvalidRequestError): @@ -1170,10 +1323,10 @@ def update_mailsettings(): if to_save.get("test"): if current_user.email: - result = send_test_mail(current_user.email, current_user.nickname) + result = send_test_mail(current_user.email, current_user.name) if result is None: - flash(_(u"Test e-mail queued for sending to %(email)s, please check Tasks for result", email=current_user.email), - category="info") + flash(_(u"Test e-mail queued for sending to %(email)s, please check Tasks for result", + email=current_user.email), category="info") else: flash(_(u"There was an error sending the Test e-mail: %(res)s", res=result), category="error") else: @@ -1189,7 +1342,7 @@ def update_mailsettings(): @admin_required def edit_user(user_id): content = ub.session.query(ub.User).filter(ub.User.id == int(user_id)).first() # type: ub.User - if not content or (not config.config_anonbrowse and content.nickname == "Guest"): + if not content or (not config.config_anonbrowse and content.name == "Guest"): flash(_(u"User not found"), category="error") return redirect(url_for('admin.admin')) languages = calibre_db.speaking_language() @@ -1206,7 +1359,8 @@ def edit_user(user_id): registered_oauth=oauth_check, mail_configured=config.get_mail_server_configured(), kobo_support=kobo_support, - title=_(u"Edit User %(nick)s", nick=content.nickname), page="edituser") + title=_(u"Edit User %(nick)s", nick=content.name), + page="edituser") @admi.route("/admin/resetpassword/") @@ -1328,18 +1482,15 @@ def get_updater_status(): return '' -def create_ldap_user(user, user_data, config): - imported = 0 - showtext = None - +def ldap_import_create_user(user, user_data): user_login_field = extract_dynamic_field_from_filter(user, config.config_ldap_user_object) username = user_data[user_login_field][0].decode('utf-8') # check for duplicate username - if ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == username.lower()).first(): - # if ub.session.query(ub.User).filter(ub.User.nickname == username).first(): + if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).first(): + # if ub.session.query(ub.User).filter(ub.User.name == username).first(): log.warning("LDAP User %s Already in Database", user_data) - return imported, showtext + return 0, None kindlemail = '' if 'mail' in user_data: @@ -1350,13 +1501,15 @@ def create_ldap_user(user, user_data, config): else: log.debug('No Mail Field Found in LDAP Response') useremail = username + '@email.com' - # check for duplicate email - if ub.session.query(ub.User).filter(func.lower(ub.User.email) == useremail.lower()).first(): - log.warning("LDAP Email %s Already in Database", user_data) - return imported, showtext + try: + # check for duplicate email + useremail = check_email(useremail) + except Exception as ex: + log.warning("LDAP Email Error: {}, {}".format(user_data, ex)) + return 0, None content = ub.User() - content.nickname = username + content.name = username content.password = '' # dummy password which will be replaced by ldap one content.email = useremail content.kindle_mail = kindlemail @@ -1369,12 +1522,12 @@ def create_ldap_user(user, user_data, config): ub.session.add(content) try: ub.session.commit() - imported = 1 - except Exception as e: - log.warning("Failed to create LDAP user: %s - %s", user, e) + return 1, None # increase no of users + except Exception as ex: + log.warning("Failed to create LDAP user: %s - %s", user, ex) ub.session.rollback() - showtext = _(u'Failed to Create at Least One LDAP User') - return imported, showtext + message = _(u'Failed to Create at Least One LDAP User') + return 0, message @admi.route('/import_ldap_users') @@ -1404,23 +1557,23 @@ def import_ldap_users(): query_filter = config.config_ldap_user_object try: user_identifier = extract_user_identifier(user, query_filter) - except Exception as e: - log.warning(e) + except Exception as ex: + log.warning(ex) continue else: user_identifier = user query_filter = None try: user_data = services.ldap.get_object_details(user=user_identifier, query_filter=query_filter) - except AttributeError as e: - log.debug_or_exception(e) + except AttributeError as ex: + log.debug_or_exception(ex) continue if user_data: - success, txt = create_ldap_user(user, user_data, config) - # In case of error store text for showing it - if txt: - showtext['text'] = txt - imported += success + user_count, message = ldap_import_create_user(user, user_data) + if message: + showtext['text'] = message + else: + imported += user_count else: log.warning("LDAP User: %s Not Found", user) showtext['text'] = _(u'At Least One LDAP User Not Found in Database') diff --git a/cps/comic.py b/cps/comic.py index c1f1fd63..462c11f0 100644 --- a/cps/comic.py +++ b/cps/comic.py @@ -105,8 +105,8 @@ def _extract_Cover_from_archive(original_file_extension, tmp_file_name, rarExecu if extension in COVER_EXTENSIONS: cover_data = cf.read(name) break - except Exception as e: - log.debug('Rarfile failed with error: %s', e) + except Exception as ex: + log.debug('Rarfile failed with error: %s', ex) return cover_data diff --git a/cps/config_sql.py b/cps/config_sql.py index b80a6866..2ab0e3d6 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -23,7 +23,11 @@ import sys from sqlalchemy import exc, Column, String, Integer, SmallInteger, Boolean, BLOB, JSON from sqlalchemy.exc import OperationalError -from sqlalchemy.ext.declarative import declarative_base +try: + # Compability with sqlalchemy 2.0 + from sqlalchemy.orm import declarative_base +except ImportError: + from sqlalchemy.ext.declarative import declarative_base from . import constants, cli, logger, ub @@ -35,7 +39,7 @@ class _Flask_Settings(_Base): __tablename__ = 'flask_settings' id = Column(Integer, primary_key=True) - flask_session_key = Column(BLOB, default="") + flask_session_key = Column(BLOB, default=b"") def __init__(self, key): self.flask_session_key = key @@ -54,6 +58,8 @@ class _Settings(_Base): mail_password = Column(String, default='mypassword') mail_from = Column(String, default='automailer ') mail_size = Column(Integer, default=25*1024*1024) + mail_server_type = Column(SmallInteger, default=0) + mail_gmail_token = Column(JSON, default={}) config_calibre_dir = Column(String) config_port = Column(Integer, default=constants.DEFAULT_PORT) @@ -242,15 +248,16 @@ class _ConfigSQL(object): return {k:v for k, v in self.__dict__.items() if k.startswith('mail_')} def get_mail_server_configured(self): - return not bool(self.mail_server == constants.DEFAULT_MAIL_SERVER) + return bool((self.mail_server != constants.DEFAULT_MAIL_SERVER and self.mail_server_type == 0) + or (self.mail_gmail_token != {} and self.mail_server_type == 1)) def set_from_dictionary(self, dictionary, field, convertor=None, default=None, encode=None): - '''Possibly updates a field of this object. + """Possibly updates a field of this object. The new value, if present, is grabbed from the given dictionary, and optionally passed through a convertor. :returns: `True` if the field has changed value - ''' + """ new_value = dictionary.get(field, default) if new_value is None: # log.debug("_ConfigSQL set_from_dictionary field '%s' not found", field) @@ -301,8 +308,11 @@ class _ConfigSQL(object): have_metadata_db = os.path.isfile(db_file) self.db_configured = have_metadata_db constants.EXTENSIONS_UPLOAD = [x.lstrip().rstrip().lower() for x in self.config_upload_formats.split(',')] - # pylint: disable=access-member-before-definition - logfile = logger.setup(self.config_logfile, self.config_log_level) + if os.environ.get('FLASK_DEBUG'): + logfile = logger.setup(logger.LOG_TO_STDOUT, logger.logging.DEBUG) + else: + # pylint: disable=access-member-before-definition + logfile = logger.setup(self.config_logfile, self.config_log_level) if logfile != self.config_logfile: log.warning("Log path %s not valid, falling back to default", self.config_logfile) self.config_logfile = logfile @@ -360,10 +370,14 @@ def _migrate_table(session, orm_class): if isinstance(column.default.arg, bool): column_default = ("DEFAULT %r" % int(column.default.arg)) else: - column_default = ("DEFAULT %r" % column.default.arg) + column_default = ("DEFAULT '%r'" % column.default.arg) + if isinstance(column.type, JSON): + column_type = "JSON" + else: + column_type = column.type alter_table = "ALTER TABLE %s ADD COLUMN `%s` %s %s" % (orm_class.__tablename__, column_name, - column.type, + column_type, column_default) log.debug(alter_table) session.execute(alter_table) @@ -426,12 +440,12 @@ def load_configuration(session): session.commit() conf = _ConfigSQL(session) # Migrate from global restrictions to user based restrictions - if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "": - conf.config_denied_tags = conf.config_mature_content_tags - conf.save() - session.query(ub.User).filter(ub.User.mature_content != True). \ - update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False) - session.commit() + #if bool(conf.config_default_show & constants.MATURE_CONTENT) and conf.config_denied_tags == "": + # conf.config_denied_tags = conf.config_mature_content_tags + # conf.save() + # session.query(ub.User).filter(ub.User.mature_content != True). \ + # update({"denied_tags": conf.config_mature_content_tags}, synchronize_session=False) + # session.commit() return conf def get_flask_session_key(session): diff --git a/cps/constants.py b/cps/constants.py index 2c00d434..e9c26cb1 100644 --- a/cps/constants.py +++ b/cps/constants.py @@ -21,8 +21,10 @@ import sys import os from collections import namedtuple -# if installed via pip this variable is set to true -HOME_CONFIG = False +# if installed via pip this variable is set to true (empty file with name .HOMEDIR present) +HOME_CONFIG = os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.HOMEDIR')) + +#In executables updater is not available, so variable is set to False there UPDATER_AVAILABLE = True # Base dir is parent of current file, necessary if called from different folder @@ -86,6 +88,26 @@ SIDEBAR_ARCHIVED = 1 << 15 SIDEBAR_DOWNLOAD = 1 << 16 SIDEBAR_LIST = 1 << 17 +sidebar_settings = { + "detail_random": DETAIL_RANDOM, + "sidebar_language": SIDEBAR_LANGUAGE, + "sidebar_series": SIDEBAR_SERIES, + "sidebar_category": SIDEBAR_CATEGORY, + "sidebar_random": SIDEBAR_RANDOM, + "sidebar_author": SIDEBAR_AUTHOR, + "sidebar_best_rated": SIDEBAR_BEST_RATED, + "sidebar_read_and_unread": SIDEBAR_READ_AND_UNREAD, + "sidebar_recent": SIDEBAR_RECENT, + "sidebar_sorted": SIDEBAR_SORTED, + "sidebar_publisher": SIDEBAR_PUBLISHER, + "sidebar_rating": SIDEBAR_RATING, + "sidebar_format": SIDEBAR_FORMAT, + "sidebar_archived": SIDEBAR_ARCHIVED, + "sidebar_download": SIDEBAR_DOWNLOAD, + "sidebar_list": SIDEBAR_LIST, + } + + ADMIN_USER_ROLES = sum(r for r in ALL_ROLES.values()) & ~ROLE_ANONYMOUS ADMIN_USER_SIDEBAR = (SIDEBAR_LIST << 1) - 1 diff --git a/cps/db.py b/cps/db.py index 3eec6454..5cb04ed3 100644 --- a/cps/db.py +++ b/cps/db.py @@ -30,7 +30,13 @@ from sqlalchemy import Table, Column, ForeignKey, CheckConstraint from sqlalchemy import String, Integer, Boolean, TIMESTAMP, Float from sqlalchemy.orm import relationship, sessionmaker, scoped_session from sqlalchemy.orm.collections import InstrumentedList -from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.exc import OperationalError +try: + # Compability with sqlalchemy 2.0 + from sqlalchemy.orm import declarative_base +except ImportError: + from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.pool import StaticPool from sqlalchemy.sql.expression import and_, true, false, text, func, or_ from sqlalchemy.ext.associationproxy import association_proxy @@ -326,7 +332,6 @@ class Books(Base): has_cover = Column(Integer, default=0) uuid = Column(String) isbn = Column(String(collation='NOCASE'), default="") - # Iccn = Column(String(collation='NOCASE'), default="") flags = Column(Integer, nullable=False, default=1) authors = relationship('Authors', secondary=books_authors_link, backref='books') @@ -437,12 +442,80 @@ class CalibreDB(): self.instances.add(self) - def initSession(self, expire_on_commit=True): self.session = self.session_factory() self.session.expire_on_commit = expire_on_commit self.update_title_sort(self.config) + @classmethod + def setup_db_cc_classes(self, cc): + cc_ids = [] + books_custom_column_links = {} + for row in cc: + if row.datatype not in cc_exceptions: + if row.datatype == 'series': + dicttable = {'__tablename__': 'books_custom_column_' + str(row.id) + '_link', + 'id': Column(Integer, primary_key=True), + 'book': Column(Integer, ForeignKey('books.id'), + primary_key=True), + 'map_value': Column('value', Integer, + ForeignKey('custom_column_' + + str(row.id) + '.id'), + primary_key=True), + 'extra': Column(Float), + 'asoc': relationship('custom_column_' + str(row.id), uselist=False), + 'value': association_proxy('asoc', 'value') + } + books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'), + (Base,), dicttable) + else: + books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', + Base.metadata, + Column('book', Integer, ForeignKey('books.id'), + primary_key=True), + Column('value', Integer, + ForeignKey('custom_column_' + + str(row.id) + '.id'), + primary_key=True) + ) + cc_ids.append([row.id, row.datatype]) + + ccdict = {'__tablename__': 'custom_column_' + str(row.id), + 'id': Column(Integer, primary_key=True)} + if row.datatype == 'float': + ccdict['value'] = Column(Float) + elif row.datatype == 'int': + ccdict['value'] = Column(Integer) + elif row.datatype == 'bool': + ccdict['value'] = Column(Boolean) + else: + ccdict['value'] = Column(String) + if row.datatype in ['float', 'int', 'bool']: + ccdict['book'] = Column(Integer, ForeignKey('books.id')) + cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict) + + for cc_id in cc_ids: + if (cc_id[1] == 'bool') or (cc_id[1] == 'int') or (cc_id[1] == 'float'): + setattr(Books, + 'custom_column_' + str(cc_id[0]), + relationship(cc_classes[cc_id[0]], + primaryjoin=( + Books.id == cc_classes[cc_id[0]].book), + backref='books')) + elif (cc_id[1] == 'series'): + setattr(Books, + 'custom_column_' + str(cc_id[0]), + relationship(books_custom_column_links[cc_id[0]], + backref='books')) + else: + setattr(Books, + 'custom_column_' + str(cc_id[0]), + relationship(cc_classes[cc_id[0]], + secondary=books_custom_column_links[cc_id[0]], + backref='books')) + + return cc_classes + @classmethod def setup_db(cls, config, app_db_path): cls.config = config @@ -465,84 +538,24 @@ class CalibreDB(): isolation_level="SERIALIZABLE", connect_args={'check_same_thread': False}, poolclass=StaticPool) - cls.engine.execute("attach database '{}' as calibre;".format(dbpath)) - cls.engine.execute("attach database '{}' as app_settings;".format(app_db_path)) + with cls.engine.begin() as connection: + connection.execute(text("attach database '{}' as calibre;".format(dbpath))) + connection.execute(text("attach database '{}' as app_settings;".format(app_db_path))) conn = cls.engine.connect() # conn.text_factory = lambda b: b.decode(errors = 'ignore') possible fix for #1302 - except Exception as e: - config.invalidate(e) + except Exception as ex: + config.invalidate(ex) return False config.db_configured = True if not cc_classes: - cc = conn.execute("SELECT id, datatype FROM custom_columns") - - cc_ids = [] - books_custom_column_links = {} - for row in cc: - if row.datatype not in cc_exceptions: - if row.datatype == 'series': - dicttable = {'__tablename__': 'books_custom_column_' + str(row.id) + '_link', - 'id': Column(Integer, primary_key=True), - 'book': Column(Integer, ForeignKey('books.id'), - primary_key=True), - 'map_value': Column('value', Integer, - ForeignKey('custom_column_' + - str(row.id) + '.id'), - primary_key=True), - 'extra': Column(Float), - 'asoc': relationship('custom_column_' + str(row.id), uselist=False), - 'value': association_proxy('asoc', 'value') - } - books_custom_column_links[row.id] = type(str('books_custom_column_' + str(row.id) + '_link'), - (Base,), dicttable) - else: - books_custom_column_links[row.id] = Table('books_custom_column_' + str(row.id) + '_link', - Base.metadata, - Column('book', Integer, ForeignKey('books.id'), - primary_key=True), - Column('value', Integer, - ForeignKey('custom_column_' + - str(row.id) + '.id'), - primary_key=True) - ) - cc_ids.append([row.id, row.datatype]) - - ccdict = {'__tablename__': 'custom_column_' + str(row.id), - 'id': Column(Integer, primary_key=True)} - if row.datatype == 'float': - ccdict['value'] = Column(Float) - elif row.datatype == 'int': - ccdict['value'] = Column(Integer) - elif row.datatype == 'bool': - ccdict['value'] = Column(Boolean) - else: - ccdict['value'] = Column(String) - if row.datatype in ['float', 'int', 'bool']: - ccdict['book'] = Column(Integer, ForeignKey('books.id')) - cc_classes[row.id] = type(str('custom_column_' + str(row.id)), (Base,), ccdict) - - for cc_id in cc_ids: - if (cc_id[1] == 'bool') or (cc_id[1] == 'int') or (cc_id[1] == 'float'): - setattr(Books, - 'custom_column_' + str(cc_id[0]), - relationship(cc_classes[cc_id[0]], - primaryjoin=( - Books.id == cc_classes[cc_id[0]].book), - backref='books')) - elif (cc_id[1] == 'series'): - setattr(Books, - 'custom_column_' + str(cc_id[0]), - relationship(books_custom_column_links[cc_id[0]], - backref='books')) - else: - setattr(Books, - 'custom_column_' + str(cc_id[0]), - relationship(cc_classes[cc_id[0]], - secondary=books_custom_column_links[cc_id[0]], - backref='books')) + try: + cc = conn.execute("SELECT id, datatype FROM custom_columns") + cls.setup_db_cc_classes(cc) + except OperationalError as e: + log.debug_or_exception(e) cls.session_factory = scoped_session(sessionmaker(autocommit=False, autoflush=True, @@ -614,13 +627,18 @@ class CalibreDB(): randm = self.session.query(Books) \ .filter(self.common_filters(allow_show_archived)) \ .order_by(func.random()) \ - .limit(self.config.config_random_books) + .limit(self.config.config_random_books).all() else: randm = false() off = int(int(pagesize) * (page - 1)) - query = self.session.query(database) \ - .join(*join, isouter=True) \ - .filter(db_filter) \ + query = self.session.query(database) + if len(join) == 3: + query = query.outerjoin(join[0], join[1]).outerjoin(join[2]) + elif len(join) == 2: + query = query.outerjoin(join[0], join[1]) + elif len(join) == 1: + query = query.outerjoin(join[0]) + query = query.filter(db_filter)\ .filter(self.common_filters(allow_show_archived)) entries = list() pagination = list() @@ -628,8 +646,8 @@ class CalibreDB(): pagination = Pagination(page, pagesize, len(query.all())) entries = query.order_by(*order).offset(off).limit(pagesize).all() - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) #for book in entries: # book = self.order_authors(book) return entries, randm, pagination @@ -773,7 +791,7 @@ class CalibreDB(): def lcase(s): try: return unidecode.unidecode(s.lower()) - except Exception as e: + except Exception as ex: log = logger.create() - log.debug_or_exception(e) + log.debug_or_exception(ex) return s.lower() diff --git a/cps/editbooks.py b/cps/editbooks.py index d9fd756f..8cc960cc 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -36,6 +36,8 @@ except ImportError: pass +from babel import Locale as LC +from babel.core import UnknownLocaleError from flask import Blueprint, request, flash, redirect, url_for, abort, Markup, Response from flask_babel import gettext as _ from flask_login import current_user, login_required @@ -77,17 +79,7 @@ def edit_required(f): return inner - -# Modifies different Database objects, first check if elements have to be added to database, than check -# if elements have to be deleted, because they are no longer used -def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type): - # passing input_elements not as a list may lead to undesired results - if not isinstance(input_elements, list): - raise TypeError(str(input_elements) + " should be passed as a list") - changed = False - input_elements = [x for x in input_elements if x != ''] - # we have all input element (authors, series, tags) names now - # 1. search for elements to remove +def search_objects_remove(db_book_object, db_type, input_elements): del_elements = [] for c_elements in db_book_object: found = False @@ -105,7 +97,10 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session # if the element was not found in the new list, add it to remove list if not found: del_elements.append(c_elements) - # 2. search for elements that need to be added + return del_elements + + +def search_objects_add(db_book_object, db_type, input_elements): add_elements = [] for inp_element in input_elements: found = False @@ -121,64 +116,96 @@ def modify_database_object(input_elements, db_book_object, db_object, db_session break if not found: add_elements.append(inp_element) - # if there are elements to remove, we remove them now + return add_elements + + +def remove_objects(db_book_object, db_session, del_elements): + changed = False if len(del_elements) > 0: for del_element in del_elements: db_book_object.remove(del_element) changed = True if len(del_element.books) == 0: db_session.delete(del_element) + return changed + +def add_objects(db_book_object, db_object, db_session, db_type, add_elements): + changed = False + if db_type == 'languages': + db_filter = db_object.lang_code + elif db_type == 'custom': + db_filter = db_object.value + else: + db_filter = db_object.name + for add_element in add_elements: + # check if a element with that name exists + db_element = db_session.query(db_object).filter(db_filter == add_element).first() + # if no element is found add it + # if new_element is None: + if db_type == 'author': + new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "") + elif db_type == 'series': + new_element = db_object(add_element, add_element) + elif db_type == 'custom': + new_element = db_object(value=add_element) + elif db_type == 'publisher': + new_element = db_object(add_element, None) + else: # db_type should be tag or language + new_element = db_object(add_element) + if db_element is None: + changed = True + db_session.add(new_element) + db_book_object.append(new_element) + else: + db_element = create_objects_for_addition(db_element, add_element, db_type) + changed = True + # add element to book + changed = True + db_book_object.append(db_element) + return changed + + +def create_objects_for_addition(db_element, add_element, db_type): + if db_type == 'custom': + if db_element.value != add_element: + db_element.value = add_element # ToDo: Before new_element, but this is not plausible + elif db_type == 'languages': + if db_element.lang_code != add_element: + db_element.lang_code = add_element + elif db_type == 'series': + if db_element.name != add_element: + db_element.name = add_element + db_element.sort = add_element + elif db_type == 'author': + if db_element.name != add_element: + db_element.name = add_element + db_element.sort = add_element.replace('|', ',') + elif db_type == 'publisher': + if db_element.name != add_element: + db_element.name = add_element + db_element.sort = None + elif db_element.name != add_element: + db_element.name = add_element + return db_element + + +# Modifies different Database objects, first check if elements if elements have to be deleted, +# because they are no longer used, than check if elements have to be added to database +def modify_database_object(input_elements, db_book_object, db_object, db_session, db_type): + # passing input_elements not as a list may lead to undesired results + if not isinstance(input_elements, list): + raise TypeError(str(input_elements) + " should be passed as a list") + input_elements = [x for x in input_elements if x != ''] + # we have all input element (authors, series, tags) names now + # 1. search for elements to remove + del_elements = search_objects_remove(db_book_object, db_type, input_elements) + # 2. search for elements that need to be added + add_elements = search_objects_add(db_book_object, db_type, input_elements) + # if there are elements to remove, we remove them now + changed = remove_objects(db_book_object, db_session, del_elements) # if there are elements to add, we add them now! if len(add_elements) > 0: - if db_type == 'languages': - db_filter = db_object.lang_code - elif db_type == 'custom': - db_filter = db_object.value - else: - db_filter = db_object.name - for add_element in add_elements: - # check if a element with that name exists - db_element = db_session.query(db_object).filter(db_filter == add_element).first() - # if no element is found add it - # if new_element is None: - if db_type == 'author': - new_element = db_object(add_element, helper.get_sorted_author(add_element.replace('|', ',')), "") - elif db_type == 'series': - new_element = db_object(add_element, add_element) - elif db_type == 'custom': - new_element = db_object(value=add_element) - elif db_type == 'publisher': - new_element = db_object(add_element, None) - else: # db_type should be tag or language - new_element = db_object(add_element) - if db_element is None: - changed = True - db_session.add(new_element) - db_book_object.append(new_element) - else: - if db_type == 'custom': - if db_element.value != add_element: - new_element.value = add_element - elif db_type == 'languages': - if db_element.lang_code != add_element: - db_element.lang_code = add_element - elif db_type == 'series': - if db_element.name != add_element: - db_element.name = add_element - db_element.sort = add_element - elif db_type == 'author': - if db_element.name != add_element: - db_element.name = add_element - db_element.sort = add_element.replace('|', ',') - elif db_type == 'publisher': - if db_element.name != add_element: - db_element.name = add_element - db_element.sort = None - elif db_element.name != add_element: - db_element.name = add_element - # add element to book - changed = True - db_book_object.append(db_element) + changed |= add_objects(db_book_object, db_object, db_session, db_type, add_elements) return changed @@ -318,8 +345,8 @@ def delete_book(book_id, book_format, jsonResponse): calibre_db.session.query(db.Data).filter(db.Data.book == book.id).\ filter(db.Data.format == book_format).delete() calibre_db.session.commit() - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) calibre_db.session.rollback() else: # book not found @@ -431,7 +458,7 @@ def edit_book_comments(comments, book): return modif_date -def edit_book_languages(languages, book, upload=False): +def edit_book_languages(languages, book, upload=False, invalid=None): input_languages = languages.split(',') unknown_languages = [] if not upload: @@ -440,7 +467,10 @@ def edit_book_languages(languages, book, upload=False): input_l = isoLanguages.get_valid_language_codes(get_locale(), input_languages, unknown_languages) for l in unknown_languages: log.error('%s is not a valid language', l) - flash(_(u"%(langname)s is not a valid language", langname=l), category="warning") + if isinstance(invalid, list): + invalid.append(l) + else: + flash(_(u"%(langname)s is not a valid language", langname=l), category="warning") # ToDo: Not working correct if upload and len(input_l) == 1: # If the language of the file is excluded from the users view, it's not imported, to allow the user to view @@ -606,7 +636,7 @@ def upload_single_file(request, book, book_id): # Queue uploader info uploadText=_(u"File format %(ext)s added to %(book)s", ext=file_ext.upper(), book=book.title) - WorkerThread.add(current_user.nickname, TaskUpload( + WorkerThread.add(current_user.name, TaskUpload( "" + uploadText + "")) return uploader.process( @@ -630,6 +660,46 @@ def upload_cover(request, book): return None +def handle_title_on_edit(book, book_title): + # handle book title + book_title = book_title.rstrip().strip() + if book.title != book_title: + if book_title == '': + book_title = _(u'Unknown') + book.title = book_title + return True + return False + + +def handle_author_on_edit(book, author_name, update_stored=True): + # handle author(s) + input_authors = author_name.split('&') + input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors)) + # Remove duplicates in authors list + input_authors = helper.uniq(input_authors) + # we have all author names now + if input_authors == ['']: + input_authors = [_(u'Unknown')] # prevent empty Author + + change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author') + + # Search for each author if author is in database, if not, author name and sorted author name is generated new + # everything then is assembled for sorted author field in database + sort_authors_list = list() + for inp in input_authors: + stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first() + if not stored_author: + stored_author = helper.get_sorted_author(inp) + else: + stored_author = stored_author.sort + sort_authors_list.append(helper.get_sorted_author(stored_author)) + sort_authors = ' & '.join(sort_authors_list) + if book.author_sort != sort_authors and update_stored: + book.author_sort = sort_authors + change = True + return input_authors, change + + @editbook.route("/admin/book/", methods=['GET', 'POST']) @login_required_if_no_ano @edit_required @@ -647,7 +717,6 @@ def edit_book(book_id): if request.method != 'POST': return render_edit_book(book_id) - book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) # Book not found @@ -665,41 +734,14 @@ def edit_book(book_id): # Update book edited_books_id = None - #handle book title - if book.title != to_save["book_title"].rstrip().strip(): - if to_save["book_title"] == '': - to_save["book_title"] = _(u'Unknown') - book.title = to_save["book_title"].rstrip().strip() + # handle book title + title_change = handle_title_on_edit(book, to_save["book_title"]) + + input_authors, authorchange = handle_author_on_edit(book, to_save["author_name"]) + if authorchange or title_change: edited_books_id = book.id modif_date = True - # handle author(s) - input_authors = to_save["author_name"].split('&') - input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors)) - # Remove duplicates in authors list - input_authors = helper.uniq(input_authors) - # we have all author names now - if input_authors == ['']: - input_authors = [_(u'Unknown')] # prevent empty Author - - modif_date |= modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author') - - # Search for each author if author is in database, if not, authorname and sorted authorname is generated new - # everything then is assembled for sorted author field in database - sort_authors_list = list() - for inp in input_authors: - stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first() - if not stored_author: - stored_author = helper.get_sorted_author(inp) - else: - stored_author = stored_author.sort - sort_authors_list.append(helper.get_sorted_author(stored_author)) - sort_authors = ' & '.join(sort_authors_list) - if book.author_sort != sort_authors: - edited_books_id = book.id - book.author_sort = sort_authors - modif_date = True - if config.config_use_google_drive: gdriveutils.updateGdriveCalibreFromLocal() @@ -724,10 +766,8 @@ def edit_book(book_id): # Add default series_index to book modif_date |= edit_book_series_index(to_save["series_index"], book) - # Handle book comments/description modif_date |= edit_book_comments(to_save["description"], book) - # Handle identifiers input_identifiers = identifier_list(to_save, book) modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session) @@ -736,9 +776,16 @@ def edit_book(book_id): modif_date |= modification # Handle book tags modif_date |= edit_book_tags(to_save['tags'], book) - # Handle book series modif_date |= edit_book_series(to_save["series"], book) + # handle book publisher + modif_date |= edit_book_publisher(to_save['publisher'], book) + # handle book languages + modif_date |= edit_book_languages(to_save['languages'], book) + # handle book ratings + modif_date |= edit_book_ratings(to_save, book) + # handle cc data + modif_date |= edit_cc_data(book_id, book, to_save) if to_save["pubdate"]: try: @@ -748,18 +795,6 @@ def edit_book(book_id): else: book.pubdate = db.Books.DEFAULT_PUBDATE - # handle book publisher - modif_date |= edit_book_publisher(to_save['publisher'], book) - - # handle book languages - modif_date |= edit_book_languages(to_save['languages'], book) - - # handle book ratings - modif_date |= edit_book_ratings(to_save, book) - - # handle cc data - modif_date |= edit_cc_data(book_id, book, to_save) - if modif_date: book.last_modified = datetime.utcnow() calibre_db.session.merge(book) @@ -775,8 +810,8 @@ def edit_book(book_id): calibre_db.session.rollback() flash(error, category="error") return render_edit_book(book_id) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) calibre_db.session.rollback() flash(_("Error editing book, please check logfile for details"), category="error") return redirect(url_for('web.show_book', book_id=book.id)) @@ -892,6 +927,48 @@ def create_book_on_upload(modif_date, meta): calibre_db.session.flush() return db_book, input_authors, title_dir +def file_handling_on_upload(requested_file): + # check if file extension is correct + if '.' in requested_file.filename: + file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() + if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD: + flash( + _("File extension '%(ext)s' is not allowed to be uploaded to this server", + ext=file_ext), category="error") + return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') + else: + flash(_('File to be uploaded must have an extension'), category="error") + return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') + + # extract metadata from file + try: + meta = uploader.upload(requested_file, config.config_rarfile_location) + except (IOError, OSError): + log.error("File %s could not saved to temp dir", requested_file.filename) + flash(_(u"File %(filename)s could not saved to temp dir", + filename=requested_file.filename), category="error") + return None, Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') + return meta, None + + +def move_coverfile(meta, db_book): + # move cover to final directory, including book id + if meta.cover: + coverfile = meta.cover + else: + coverfile = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg') + new_coverpath = os.path.join(config.config_calibre_dir, db_book.path, "cover.jpg") + try: + copyfile(coverfile, new_coverpath) + if meta.cover: + os.unlink(meta.cover) + except OSError as e: + log.error("Failed to move cover file %s: %s", new_coverpath, e) + flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=new_coverpath, + error=e), + category="error") + + @editbook.route("/upload", methods=["GET", "POST"]) @login_required_if_no_ano @upload_required @@ -906,30 +983,13 @@ def upload(): calibre_db.update_title_sort(config) calibre_db.session.connection().connection.connection.create_function('uuid4', 0, lambda: str(uuid4())) - # check if file extension is correct - if '.' in requested_file.filename: - file_ext = requested_file.filename.rsplit('.', 1)[-1].lower() - if file_ext not in constants.EXTENSIONS_UPLOAD and '' not in constants.EXTENSIONS_UPLOAD: - flash( - _("File extension '%(ext)s' is not allowed to be uploaded to this server", - ext=file_ext), category="error") - return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') - else: - flash(_('File to be uploaded must have an extension'), category="error") - return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') - - # extract metadata from file - try: - meta = uploader.upload(requested_file, config.config_rarfile_location) - except (IOError, OSError): - log.error("File %s could not saved to temp dir", requested_file.filename) - flash(_(u"File %(filename)s could not saved to temp dir", - filename= requested_file.filename), category="error") - return Response(json.dumps({"location": url_for("web.index")}), mimetype='application/json') + meta, error = file_handling_on_upload(requested_file) + if error: + return error db_book, input_authors, title_dir = create_book_on_upload(modif_date, meta) - # Comments needs book id therfore only possible after flush + # Comments needs book id therefore only possible after flush modif_date |= edit_book_comments(Markup(meta.description).unescape(), db_book) book_id = db_book.id @@ -941,21 +1001,7 @@ def upload(): meta.file_path, title_dir + meta.extension) - # move cover to final directory, including book id - if meta.cover: - coverfile = meta.cover - else: - coverfile = os.path.join(constants.STATIC_DIR, 'generic_cover.jpg') - new_coverpath = os.path.join(config.config_calibre_dir, db_book.path, "cover.jpg") - try: - copyfile(coverfile, new_coverpath) - if meta.cover: - os.unlink(meta.cover) - except OSError as e: - log.error("Failed to move cover file %s: %s", new_coverpath, e) - flash(_(u"Failed to Move Cover File %(file)s: %(error)s", file=new_coverpath, - error=e), - category="error") + move_coverfile(meta, db_book) # save data to database, reread data calibre_db.session.commit() @@ -965,7 +1011,7 @@ def upload(): if error: flash(error, category="error") uploadText=_(u"File %(file)s uploaded", file=title) - WorkerThread.add(current_user.nickname, TaskUpload( + WorkerThread.add(current_user.name, TaskUpload( "" + uploadText + "")) if len(request.files.getlist("btn-upload")) < 2: @@ -995,7 +1041,7 @@ def convert_bookformat(book_id): log.info('converting: book id: %s from: %s to: %s', book_id, book_format_from, book_format_to) rtn = helper.convert_book_format(book_id, config.config_calibre_dir, book_format_from.upper(), - book_format_to.upper(), current_user.nickname) + book_format_to.upper(), current_user.name) if rtn is None: flash(_(u"Book successfully queued for converting to %(book_format)s", @@ -1023,63 +1069,88 @@ def scholar_search(query): else: return [] - - @editbook.route("/ajax/editbooks/", methods=['POST']) @login_required_if_no_ano @edit_required def edit_list_book(param): vals = request.form.to_dict() book = calibre_db.get_book(vals['pk']) + ret = "" if param =='series_index': edit_book_series_index(vals['value'], book) + ret = Response(json.dumps({'success': True, 'newValue': book.series_index}), mimetype='application/json') elif param =='tags': edit_book_tags(vals['value'], book) + ret = Response(json.dumps({'success': True, 'newValue': ', '.join([tag.name for tag in book.tags])}), + mimetype='application/json') elif param =='series': edit_book_series(vals['value'], book) + ret = Response(json.dumps({'success': True, 'newValue': ', '.join([serie.name for serie in book.series])}), + mimetype='application/json') elif param =='publishers': - vals['publisher'] = vals['value'] - edit_book_publisher(vals, book) + edit_book_publisher(vals['value'], book) + ret = Response(json.dumps({'success': True, + 'newValue': ', '.join([publisher.name for publisher in book.publishers])}), + mimetype='application/json') elif param =='languages': - edit_book_languages(vals['value'], book) + invalid = list() + edit_book_languages(vals['value'], book, invalid=invalid) + if invalid: + ret = Response(json.dumps({'success': False, + 'msg': 'Invalid languages in request: {}'.format(','.join(invalid))}), + mimetype='application/json') + else: + lang_names = list() + for lang in book.languages: + try: + lang_names.append(LC.parse(lang.lang_code).get_language_name(get_locale())) + except UnknownLocaleError: + lang_names.append(_(isoLanguages.get(part3=lang.lang_code).name)) + ret = Response(json.dumps({'success': True, 'newValue': ', '.join(lang_names)}), + mimetype='application/json') elif param =='author_sort': book.author_sort = vals['value'] - elif param =='title': - book.title = vals['value'] + ret = Response(json.dumps({'success': True, 'newValue': book.author_sort}), + mimetype='application/json') + elif param == 'title': + sort = book.sort + handle_title_on_edit(book, vals.get('value', "")) helper.update_dir_stucture(book.id, config.config_calibre_dir) + ret = Response(json.dumps({'success': True, 'newValue': book.title}), + mimetype='application/json') elif param =='sort': book.sort = vals['value'] - # ToDo: edit books + ret = Response(json.dumps({'success': True, 'newValue': book.sort}), + mimetype='application/json') elif param =='authors': - input_authors = vals['value'].split('&') - input_authors = list(map(lambda it: it.strip().replace(',', '|'), input_authors)) - modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author') - sort_authors_list = list() - for inp in input_authors: - stored_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == inp).first() - if not stored_author: - stored_author = helper.get_sorted_author(inp) - else: - stored_author = stored_author.sort - sort_authors_list.append(helper.get_sorted_author(stored_author)) - sort_authors = ' & '.join(sort_authors_list) - if book.author_sort != sort_authors: - book.author_sort = sort_authors + input_authors, __ = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true") helper.update_dir_stucture(book.id, config.config_calibre_dir, input_authors[0]) + ret = Response(json.dumps({'success': True, + 'newValue': ' & '.join([author.replace('|',',') for author in input_authors])}), + mimetype='application/json') book.last_modified = datetime.utcnow() calibre_db.session.commit() - return "" + # revert change for sort if automatic fields link is deactivated + if param == 'title' and vals.get('checkT') == "false": + book.sort = sort + calibre_db.session.commit() + return ret + @editbook.route("/ajax/sort_value//") @login_required def get_sorted_entry(field, bookid): - if field == 'title' or field == 'authors': + if field in ['title', 'authors', 'sort', 'author_sort']: book = calibre_db.get_filtered_book(bookid) if book: if field == 'title': return json.dumps({'sort': book.sort}) elif field == 'authors': return json.dumps({'author_sort': book.author_sort}) + if field == 'sort': + return json.dumps({'sort': book.title}) + if field == 'author_sort': + return json.dumps({'author_sort': book.author}) return "" diff --git a/cps/epub.py b/cps/epub.py index 5833c2aa..998dbfa6 100644 --- a/cps/epub.py +++ b/cps/epub.py @@ -87,18 +87,29 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension): lang = epub_metadata['language'].split('-', 1)[0].lower() epub_metadata['language'] = isoLanguages.get_lang3(lang) - series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns) - if len(series) > 0: - epub_metadata['series'] = series[0] - else: - epub_metadata['series'] = '' + epub_metadata = parse_epbub_series(ns, tree, epub_metadata) - series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns) - if len(series_id) > 0: - epub_metadata['series_id'] = series_id[0] - else: - epub_metadata['series_id'] = '1' + coverfile = parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path) + if not epub_metadata['title']: + title = original_file_name + else: + title = epub_metadata['title'] + + return BookMeta( + file_path=tmp_file_path, + extension=original_file_extension, + title=title.encode('utf-8').decode('utf-8'), + author=epub_metadata['creator'].encode('utf-8').decode('utf-8'), + cover=coverfile, + description=epub_metadata['description'], + tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'), + series=epub_metadata['series'].encode('utf-8').decode('utf-8'), + series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'), + languages=epub_metadata['language'], + publisher="") + +def parse_ebpub_cover(ns, tree, epubZip, coverpath, tmp_file_path): coversection = tree.xpath("/pkg:package/pkg:manifest/pkg:item[@id='cover-image']/@href", namespaces=ns) coverfile = None if len(coversection) > 0: @@ -126,21 +137,18 @@ def get_epub_info(tmp_file_path, original_file_name, original_file_extension): coverfile = extractCover(epubZip, filename, "", tmp_file_path) else: coverfile = extractCover(epubZip, coversection[0], coverpath, tmp_file_path) + return coverfile - if not epub_metadata['title']: - title = original_file_name +def parse_epbub_series(ns, tree, epub_metadata): + series = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series']/@content", namespaces=ns) + if len(series) > 0: + epub_metadata['series'] = series[0] else: - title = epub_metadata['title'] + epub_metadata['series'] = '' - return BookMeta( - file_path=tmp_file_path, - extension=original_file_extension, - title=title.encode('utf-8').decode('utf-8'), - author=epub_metadata['creator'].encode('utf-8').decode('utf-8'), - cover=coverfile, - description=epub_metadata['description'], - tags=epub_metadata['subject'].encode('utf-8').decode('utf-8'), - series=epub_metadata['series'].encode('utf-8').decode('utf-8'), - series_id=epub_metadata['series_id'].encode('utf-8').decode('utf-8'), - languages=epub_metadata['language'], - publisher="") + series_id = tree.xpath("/pkg:package/pkg:metadata/pkg:meta[@name='calibre:series_index']/@content", namespaces=ns) + if len(series_id) > 0: + epub_metadata['series_id'] = series_id[0] + else: + epub_metadata['series_id'] = '1' + return epub_metadata diff --git a/cps/gdrive.py b/cps/gdrive.py index 158a5a4f..6d6dfd07 100644 --- a/cps/gdrive.py +++ b/cps/gdrive.py @@ -155,6 +155,6 @@ def on_received_watch_confirmation(): # prevent error on windows, as os.rename does on existing files, also allow cross hdd move move(os.path.join(tmp_dir, "tmp_metadata.db"), dbpath) calibre_db.reconnect_db(config, ub.app_DB_path) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) return '' diff --git a/cps/gdriveutils.py b/cps/gdriveutils.py index 79587b79..a98d0b66 100644 --- a/cps/gdriveutils.py +++ b/cps/gdriveutils.py @@ -28,7 +28,11 @@ from sqlalchemy import create_engine from sqlalchemy import Column, UniqueConstraint from sqlalchemy import String, Integer from sqlalchemy.orm import sessionmaker, scoped_session -from sqlalchemy.ext.declarative import declarative_base +try: + # Compability with sqlalchemy 2.0 + from sqlalchemy.orm import declarative_base +except ImportError: + from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.exc import OperationalError, InvalidRequestError try: @@ -198,8 +202,8 @@ def getDrive(drive=None, gauth=None): gauth.Refresh() except RefreshError as e: log.error("Google Drive error: %s", e) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) else: # Initialize the saved creds gauth.Authorize() @@ -493,8 +497,8 @@ def getChangeById (drive, change_id): except (errors.HttpError) as error: log.error(error) return None - except Exception as e: - log.error(e) + except Exception as ex: + log.error(ex) return None diff --git a/cps/helper.py b/cps/helper.py index e18ae33b..f1c32ea0 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -35,7 +35,7 @@ from babel.units import format_unit from flask import send_from_directory, make_response, redirect, abort, url_for from flask_babel import gettext as _ from flask_login import current_user -from sqlalchemy.sql.expression import true, false, and_, text +from sqlalchemy.sql.expression import true, false, and_, text, func from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash @@ -480,8 +480,8 @@ def reset_password(user_id): password = generate_random_password() existing_user.password = generate_password_hash(password) ub.session.commit() - send_registration_mail(existing_user.email, existing_user.nickname, password, True) - return 1, existing_user.nickname + send_registration_mail(existing_user.email, existing_user.name, password, True) + return 1, existing_user.name except Exception: ub.session.rollback() return 0, None @@ -498,11 +498,37 @@ def generate_random_password(): def uniq(inpt): output = [] + inpt = [ " ".join(inp.split()) for inp in inpt] for x in inpt: if x not in output: output.append(x) return output +def check_email(email): + email = valid_email(email) + if ub.session.query(ub.User).filter(func.lower(ub.User.email) == email.lower()).first(): + log.error(u"Found an existing account for this e-mail address") + raise Exception(_(u"Found an existing account for this e-mail address")) + return email + + +def check_username(username): + username = username.strip() + if ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.lower()).scalar(): + log.error(u"This username is already taken") + raise Exception (_(u"This username is already taken")) + return username + + +def valid_email(email): + email = email.strip() + # Regex according to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email#validation + if not re.search(r"^[\w.!#$%&'*+\\/=?^_`{|}~-]+@[\w](?:[\w-]{0,61}[\w])?(?:\.[\w](?:[\w-]{0,61}[\w])?)*$", + email): + log.error(u"Invalid e-mail address format") + raise Exception(_(u"Invalid e-mail address format")) + return email + # ################################# External interface ################################# @@ -550,8 +576,8 @@ def get_book_cover_internal(book, use_generic_cover_on_failure): else: log.error('%s/cover.jpg not found on Google Drive', book.path) return get_cover_on_failure(use_generic_cover_on_failure) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) return get_cover_on_failure(use_generic_cover_on_failure) else: cover_file_path = os.path.join(config.config_calibre_dir, book.path) @@ -731,7 +757,7 @@ def format_runtime(runtime): def render_task_status(tasklist): renderedtasklist = list() for __, user, __, task in tasklist: - if user == current_user.nickname or current_user.role_admin(): + if user == current_user.name or current_user.role_admin(): ret = {} if task.start_time: ret['starttime'] = format_datetime(task.start_time, format='short', locale=get_locale()) diff --git a/cps/isoLanguages.py b/cps/isoLanguages.py index 08bdf956..35d9f0a7 100644 --- a/cps/isoLanguages.py +++ b/cps/isoLanguages.py @@ -63,11 +63,12 @@ def get_language_codes(locale, language_names, remainder=None): if v in language_names: lang.append(k) language_names.remove(v) - if remainder is not None: + if remainder is not None and language_names: remainder.extend(language_names) return lang + def get_valid_language_codes(locale, language_names, remainder=None): lang = list() if "" in language_names: diff --git a/cps/jinjia.py b/cps/jinjia.py index 688d1fba..f37dfb49 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -82,7 +82,7 @@ def formatdate_filter(val): except AttributeError as e: log.error('Babel error: %s, Current user locale: %s, Current User: %s', e, current_user.locale, - current_user.nickname + current_user.name ) return val diff --git a/cps/kobo.py b/cps/kobo.py index 2b956bfc..8988ef3f 100644 --- a/cps/kobo.py +++ b/cps/kobo.py @@ -177,7 +177,7 @@ def HandleSyncRequest(): for book in changed_entries: formats = [data.format for data in book.Books.data] if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats: - helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.nickname) + helper.convert_book_format(book.Books.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name) kobo_reading_state = get_or_create_reading_state(book.Books.id) entitlement = { @@ -262,8 +262,8 @@ def generate_sync_response(sync_token, sync_results, set_cont=False): extra_headers["x-kobo-sync-mode"] = store_response.headers.get("x-kobo-sync-mode") extra_headers["x-kobo-recent-reads"] = store_response.headers.get("x-kobo-recent-reads") - except Exception as e: - log.error("Failed to receive or parse response from Kobo's sync endpoint: " + str(e)) + except Exception as ex: + log.error("Failed to receive or parse response from Kobo's sync endpoint: {}".format(ex)) if set_cont: extra_headers["x-kobo-sync"] = "continue" sync_token.to_headers(extra_headers) diff --git a/cps/kobo_auth.py b/cps/kobo_auth.py index 23f60fe2..a51095c8 100644 --- a/cps/kobo_auth.py +++ b/cps/kobo_auth.py @@ -155,7 +155,7 @@ def generate_auth_token(user_id): for book in books: formats = [data.format for data in book.data] if not 'KEPUB' in formats and config.config_kepubifypath and 'EPUB' in formats: - helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.nickname) + helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name) return render_title_template( "generate_kobo_auth_url.html", diff --git a/cps/oauth_bb.py b/cps/oauth_bb.py index 1fd7c9b1..5d909d91 100644 --- a/cps/oauth_bb.py +++ b/cps/oauth_bb.py @@ -42,6 +42,7 @@ except NameError: oauth_check = {} +oauthblueprints = [] oauth = Blueprint('oauth', __name__) log = logger.create() @@ -87,7 +88,7 @@ def register_user_with_oauth(user=None): except NoResultFound: # no found, return error return - ub.session_commit("User {} with OAuth for provider {} registered".format(user.nickname, oauth_key)) + ub.session_commit("User {} with OAuth for provider {} registered".format(user.name, oauth_key)) def logout_oauth_user(): @@ -133,8 +134,8 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider # already bind with user, just login if oauth_entry.user: login_user(oauth_entry.user) - log.debug(u"You are now logged in as: '%s'", oauth_entry.user.nickname) - flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.nickname), + log.debug(u"You are now logged in as: '%s'", oauth_entry.user.name) + flash(_(u"you are now logged in as: '%(nickname)s'", nickname= oauth_entry.user.name), category="success") return redirect(url_for('web.index')) else: @@ -146,8 +147,8 @@ def bind_oauth_or_register(provider_id, provider_user_id, redirect_url, provider ub.session.commit() flash(_(u"Link to %(oauth)s Succeeded", oauth=provider_name), category="success") return redirect(url_for('web.profile')) - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) ub.session.rollback() else: flash(_(u"Login failed, No User Linked With OAuth Account"), category="error") @@ -193,8 +194,8 @@ def unlink_oauth(provider): ub.session.commit() logout_oauth_user() flash(_(u"Unlink to %(oauth)s Succeeded", oauth=oauth_check[provider]), category="success") - except Exception as e: - log.debug_or_exception(e) + except Exception as ex: + log.debug_or_exception(ex) ub.session.rollback() flash(_(u"Unlink to %(oauth)s Failed", oauth=oauth_check[provider]), category="error") except NoResultFound: @@ -203,7 +204,6 @@ def unlink_oauth(provider): return redirect(url_for('web.profile')) def generate_oauth_blueprints(): - oauthblueprints = [] if not ub.session.query(ub.OAuthProvider).count(): for provider in ("github", "google"): oauthProvider = ub.OAuthProvider() @@ -299,39 +299,6 @@ if ub.oauth_support: ) # ToDo: Translate flash(msg, category="error") - - @oauth.route('/link/github') - @oauth_required - def github_login(): - if not github.authorized: - return redirect(url_for('github.login')) - account_info = github.get('/user') - if account_info.ok: - account_info_json = account_info.json() - return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github') - flash(_(u"GitHub Oauth error, please retry later."), category="error") - return redirect(url_for('web.login')) - - - @oauth.route('/unlink/github', methods=["GET"]) - @login_required - def github_login_unlink(): - return unlink_oauth(oauthblueprints[0]['id']) - - - @oauth.route('/link/google') - @oauth_required - def google_login(): - if not google.authorized: - return redirect(url_for("google.login")) - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - account_info_json = resp.json() - return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google') - flash(_(u"Google Oauth error, please retry later."), category="error") - return redirect(url_for('web.login')) - - @oauth_error.connect_via(oauthblueprints[1]['blueprint']) def google_error(blueprint, error, error_description=None, error_uri=None): msg = ( @@ -346,7 +313,39 @@ if ub.oauth_support: flash(msg, category="error") - @oauth.route('/unlink/google', methods=["GET"]) - @login_required - def google_login_unlink(): - return unlink_oauth(oauthblueprints[1]['id']) +@oauth.route('/link/github') +@oauth_required +def github_login(): + if not github.authorized: + return redirect(url_for('github.login')) + account_info = github.get('/user') + if account_info.ok: + account_info_json = account_info.json() + return bind_oauth_or_register(oauthblueprints[0]['id'], account_info_json['id'], 'github.login', 'github') + flash(_(u"GitHub Oauth error, please retry later."), category="error") + return redirect(url_for('web.login')) + + +@oauth.route('/unlink/github', methods=["GET"]) +@login_required +def github_login_unlink(): + return unlink_oauth(oauthblueprints[0]['id']) + + +@oauth.route('/link/google') +@oauth_required +def google_login(): + if not google.authorized: + return redirect(url_for("google.login")) + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + account_info_json = resp.json() + return bind_oauth_or_register(oauthblueprints[1]['id'], account_info_json['id'], 'google.login', 'google') + flash(_(u"Google Oauth error, please retry later."), category="error") + return redirect(url_for('web.login')) + + +@oauth.route('/unlink/google', methods=["GET"]) +@login_required +def google_login_unlink(): + return unlink_oauth(oauthblueprints[1]['id']) diff --git a/cps/opds.py b/cps/opds.py index e8d3fad9..85a978a7 100644 --- a/cps/opds.py +++ b/cps/opds.py @@ -27,7 +27,7 @@ from functools import wraps from flask import Blueprint, request, render_template, Response, g, make_response, abort from flask_login import current_user -from sqlalchemy.sql.expression import func, text, or_, and_ +from sqlalchemy.sql.expression import func, text, or_, and_, true from werkzeug.security import check_password_hash from . import constants, logger, config, db, calibre_db, ub, services, get_locale, isoLanguages @@ -97,6 +97,44 @@ def feed_normal_search(): return feed_search(request.args.get("query", "").strip()) +@opds.route("/opds/books") +@requires_basic_auth_if_no_ano +def feed_booksindex(): + shift = 0 + off = int(request.args.get("offset") or 0) + entries = calibre_db.session.query(func.upper(func.substr(db.Books.sort, 1, 1)).label('id'))\ + .filter(calibre_db.common_filters()).group_by(func.upper(func.substr(db.Books.sort, 1, 1))).all() + + elements = [] + if off == 0: + elements.append({'id': "00", 'name':_("All")}) + shift = 1 + for entry in entries[ + off + shift - 1: + int(off + int(config.config_books_per_page) - shift)]: + elements.append({'id': entry.id, 'name': entry.id}) + + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + len(entries) + 1) + return render_xml_template('feed.xml', + letterelements=elements, + folder='opds.feed_letter_books', + pagination=pagination) + + +@opds.route("/opds/books/letter/") +@requires_basic_auth_if_no_ano +def feed_letter_books(book_id): + off = request.args.get("offset") or 0 + letter = true() if book_id == "00" else func.upper(db.Books.sort).startswith(book_id) + entries, __, pagination = calibre_db.fill_indexpage((int(off) / (int(config.config_books_per_page)) + 1), 0, + db.Books, + letter, + [db.Books.sort]) + + return render_xml_template('feed.xml', entries=entries, pagination=pagination) + + @opds.route("/opds/new") @requires_basic_auth_if_no_ano def feed_new(): @@ -150,14 +188,41 @@ def feed_hot(): @opds.route("/opds/author") @requires_basic_auth_if_no_ano def feed_authorindex(): - off = request.args.get("offset") or 0 - entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\ - .filter(calibre_db.common_filters())\ - .group_by(text('books_authors_link.author'))\ - .order_by(db.Authors.sort).limit(config.config_books_per_page)\ - .offset(off) + shift = 0 + off = int(request.args.get("offset") or 0) + entries = calibre_db.session.query(func.upper(func.substr(db.Authors.sort, 1, 1)).label('id'))\ + .join(db.books_authors_link).join(db.Books).filter(calibre_db.common_filters())\ + .group_by(func.upper(func.substr(db.Authors.sort, 1, 1))).all() + + elements = [] + if off == 0: + elements.append({'id': "00", 'name':_("All")}) + shift = 1 + for entry in entries[ + off + shift - 1: + int(off + int(config.config_books_per_page) - shift)]: + elements.append({'id': entry.id, 'name': entry.id}) + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(calibre_db.session.query(db.Authors).all())) + len(entries) + 1) + return render_xml_template('feed.xml', + letterelements=elements, + folder='opds.feed_letter_author', + pagination=pagination) + + +@opds.route("/opds/author/letter/") +@requires_basic_auth_if_no_ano +def feed_letter_author(book_id): + off = request.args.get("offset") or 0 + letter = true() if book_id == "00" else func.upper(db.Authors.sort).startswith(book_id) + entries = calibre_db.session.query(db.Authors).join(db.books_authors_link).join(db.Books)\ + .filter(calibre_db.common_filters()).filter(letter)\ + .group_by(text('books_authors_link.author'))\ + .order_by(db.Authors.sort) + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + entries.count()) + entries = entries.limit(config.config_books_per_page).offset(off).all() return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_author', pagination=pagination) @@ -201,17 +266,41 @@ def feed_publisher(book_id): @opds.route("/opds/category") @requires_basic_auth_if_no_ano def feed_categoryindex(): + shift = 0 + off = int(request.args.get("offset") or 0) + entries = calibre_db.session.query(func.upper(func.substr(db.Tags.name, 1, 1)).label('id'))\ + .join(db.books_tags_link).join(db.Books).filter(calibre_db.common_filters())\ + .group_by(func.upper(func.substr(db.Tags.name, 1, 1))).all() + elements = [] + if off == 0: + elements.append({'id': "00", 'name':_("All")}) + shift = 1 + for entry in entries[ + off + shift - 1: + int(off + int(config.config_books_per_page) - shift)]: + elements.append({'id': entry.id, 'name': entry.id}) + + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + len(entries) + 1) + return render_xml_template('feed.xml', + letterelements=elements, + folder='opds.feed_letter_category', + pagination=pagination) + +@opds.route("/opds/category/letter/") +@requires_basic_auth_if_no_ano +def feed_letter_category(book_id): off = request.args.get("offset") or 0 + letter = true() if book_id == "00" else func.upper(db.Tags.name).startswith(book_id) entries = calibre_db.session.query(db.Tags)\ .join(db.books_tags_link)\ .join(db.Books)\ - .filter(calibre_db.common_filters())\ + .filter(calibre_db.common_filters()).filter(letter)\ .group_by(text('books_tags_link.tag'))\ - .order_by(db.Tags.name)\ - .offset(off)\ - .limit(config.config_books_per_page) + .order_by(db.Tags.name) pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(calibre_db.session.query(db.Tags).all())) + entries.count()) + entries = entries.offset(off).limit(config.config_books_per_page).all() return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_category', pagination=pagination) @@ -229,16 +318,40 @@ def feed_category(book_id): @opds.route("/opds/series") @requires_basic_auth_if_no_ano def feed_seriesindex(): + shift = 0 + off = int(request.args.get("offset") or 0) + entries = calibre_db.session.query(func.upper(func.substr(db.Series.sort, 1, 1)).label('id'))\ + .join(db.books_series_link).join(db.Books).filter(calibre_db.common_filters())\ + .group_by(func.upper(func.substr(db.Series.sort, 1, 1))).all() + elements = [] + if off == 0: + elements.append({'id': "00", 'name':_("All")}) + shift = 1 + for entry in entries[ + off + shift - 1: + int(off + int(config.config_books_per_page) - shift)]: + elements.append({'id': entry.id, 'name': entry.id}) + pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, + len(entries) + 1) + return render_xml_template('feed.xml', + letterelements=elements, + folder='opds.feed_letter_series', + pagination=pagination) + +@opds.route("/opds/series/letter/") +@requires_basic_auth_if_no_ano +def feed_letter_series(book_id): off = request.args.get("offset") or 0 + letter = true() if book_id == "00" else func.upper(db.Series.sort).startswith(book_id) entries = calibre_db.session.query(db.Series)\ .join(db.books_series_link)\ .join(db.Books)\ - .filter(calibre_db.common_filters())\ + .filter(calibre_db.common_filters()).filter(letter)\ .group_by(text('books_series_link.series'))\ - .order_by(db.Series.sort)\ - .offset(off).all() + .order_by(db.Series.sort) pagination = Pagination((int(off) / (int(config.config_books_per_page)) + 1), config.config_books_per_page, - len(calibre_db.session.query(db.Series).all())) + entries.count()) + entries = entries.offset(off).limit(config.config_books_per_page).all() return render_xml_template('feed.xml', listelements=entries, folder='opds.feed_series', pagination=pagination) @@ -269,7 +382,7 @@ def feed_ratingindex(): len(entries)) element = list() for entry in entries: - element.append(FeedObject(entry[0].id, "{} Stars".format(entry.name))) + element.append(FeedObject(entry[0].id, _("{} Stars").format(entry.name))) return render_xml_template('feed.xml', listelements=element, folder='opds.feed_ratings', pagination=pagination) @@ -428,7 +541,7 @@ def check_auth(username, password): username = username.encode('windows-1252') except UnicodeEncodeError: username = username.encode('utf-8') - user = ub.session.query(ub.User).filter(func.lower(ub.User.nickname) == + user = ub.session.query(ub.User).filter(func.lower(ub.User.name) == username.decode('utf-8').lower()).first() if bool(user and check_password_hash(str(user.password), password)): return True diff --git a/cps/remotelogin.py b/cps/remotelogin.py index d9e7388f..47d10c20 100644 --- a/cps/remotelogin.py +++ b/cps/remotelogin.py @@ -126,11 +126,11 @@ def token_verified(): login_user(user) ub.session.delete(auth_token) - ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.nickname)) + ub.session_commit("User {} logged in via remotelogin, token deleted".format(user.name)) data['status'] = 'success' log.debug(u"Remote Login for userid %s succeded", user.id) - flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.nickname), category="success") + flash(_(u"you are now logged in as: '%(nickname)s'", nickname=user.name), category="success") response = make_response(json.dumps(data, ensure_ascii=False)) response.headers["Content-Type"] = "application/json; charset=utf-8" diff --git a/cps/render_template.py b/cps/render_template.py index fdd8abb7..51e4db95 100644 --- a/cps/render_template.py +++ b/cps/render_template.py @@ -42,10 +42,16 @@ def get_sidebar_config(kwargs=None): sidebar.append({"glyph": "glyphicon-fire", "text": _('Hot Books'), "link": 'web.books_list', "id": "hot", "visibility": constants.SIDEBAR_HOT, 'public': True, "page": "hot", "show_text": _('Show Hot Books'), "config_show": True}) - sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', - "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), - "page": "download", "show_text": _('Show Downloaded Books'), - "config_show": content}) + if current_user.role_admin(): + sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.download_list', + "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), + "page": "download", "show_text": _('Show Downloaded Books'), + "config_show": content}) + else: + sidebar.append({"glyph": "glyphicon-download", "text": _('Downloaded Books'), "link": 'web.books_list', + "id": "download", "visibility": constants.SIDEBAR_DOWNLOAD, 'public': (not g.user.is_anonymous), + "page": "download", "show_text": _('Show Downloaded Books'), + "config_show": content}) sidebar.append( {"glyph": "glyphicon-star", "text": _('Top Rated Books'), "link": 'web.books_list', "id": "rated", "visibility": constants.SIDEBAR_BEST_RATED, 'public': True, "page": "rated", diff --git a/cps/services/__init__.py b/cps/services/__init__.py index 17f1f529..efd55621 100644 --- a/cps/services/__init__.py +++ b/cps/services/__init__.py @@ -45,3 +45,9 @@ except ImportError as err: log.debug("Cannot import SyncToken, syncing books with Kobo Devices will not work: %s", err) kobo = None SyncToken = None + +try: + from . import gmail +except ImportError as err: + log.debug("Cannot import Gmail, sending books via G-Mail Accounts will not work: %s", err) + gmail = None diff --git a/cps/services/gmail.py b/cps/services/gmail.py new file mode 100644 index 00000000..9524dd75 --- /dev/null +++ b/cps/services/gmail.py @@ -0,0 +1,80 @@ +from __future__ import print_function +import os.path +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from googleapiclient.discovery import build +from google.oauth2.credentials import Credentials + +from datetime import datetime +import base64 +from flask_babel import gettext as _ +from ..constants import BASE_DIR +from .. import logger + + +log = logger.create() + +SCOPES = ['openid', 'https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/userinfo.email'] + +def setup_gmail(token): + # If there are no (valid) credentials available, let the user log in. + creds = None + if "token" in token: + creds = Credentials( + token=token['token'], + refresh_token=token['refresh_token'], + token_uri=token['token_uri'], + client_id=token['client_id'], + client_secret=token['client_secret'], + scopes=token['scopes'], + ) + creds.expiry = datetime.fromisoformat(token['expiry']) + + if not creds or not creds.valid: + # don't forget to dump one more time after the refresh + # also, some file-locking routines wouldn't be needless + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + cred_file = os.path.join(BASE_DIR, 'gmail.json') + if not os.path.exists(cred_file): + raise Exception(_("Found no valid gmail.json file with OAuth information")) + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(BASE_DIR, 'gmail.json'), SCOPES) + creds = flow.run_local_server(port=0) + user_info = get_user_info(creds) + return { + 'token': creds.token, + 'refresh_token': creds.refresh_token, + 'token_uri': creds.token_uri, + 'client_id': creds.client_id, + 'client_secret': creds.client_secret, + 'scopes': creds.scopes, + 'expiry': creds.expiry.isoformat(), + 'email': user_info + } + +def get_user_info(credentials): + user_info_service = build(serviceName='oauth2', version='v2',credentials=credentials) + user_info = user_info_service.userinfo().get().execute() + return user_info.get('email', "") + +def send_messsage(token, msg): + creds = Credentials( + token=token['token'], + refresh_token=token['refresh_token'], + token_uri=token['token_uri'], + client_id=token['client_id'], + client_secret=token['client_secret'], + scopes=token['scopes'], + ) + creds.expiry = datetime.fromisoformat(token['expiry']) + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + service = build('gmail', 'v1', credentials=creds) + message_as_bytes = msg.as_bytes() # the message should converted from string to bytes. + message_as_base64 = base64.urlsafe_b64encode(message_as_bytes) # encode in base64 (printable letters coding) + raw = message_as_base64.decode() # convert to something JSON serializable + body = {'raw': raw} + + (service.users().messages().send(userId='me', body=body).execute()) diff --git a/cps/services/worker.py b/cps/services/worker.py index 072674a0..8433e408 100644 --- a/cps/services/worker.py +++ b/cps/services/worker.py @@ -159,9 +159,9 @@ class CalibreTask: # catch any unhandled exceptions in a task and automatically fail it try: self.run(*args) - except Exception as e: - self._handleError(str(e)) - log.debug_or_exception(e) + except Exception as ex: + self._handleError(str(ex)) + log.debug_or_exception(ex) self.end_time = datetime.now() diff --git a/cps/shelf.py b/cps/shelf.py index 5c6037ac..68e8331b 100644 --- a/cps/shelf.py +++ b/cps/shelf.py @@ -255,13 +255,13 @@ def create_edit_shelf(shelf, title, page, shelf_id=False): log.info(u"Shelf {} {}".format(to_save["title"], shelf_action)) flash(flash_text, category="success") return redirect(url_for('shelf.show_shelf', shelf_id=shelf.id)) - except (OperationalError, InvalidRequestError) as e: + except (OperationalError, InvalidRequestError) as ex: ub.session.rollback() - log.debug_or_exception(e) + log.debug_or_exception(ex) flash(_(u"Settings DB is not Writeable"), category="error") - except Exception as e: + except Exception as ex: ub.session.rollback() - log.debug_or_exception(e) + log.debug_or_exception(ex) flash(_(u"There was an error"), category="error") return render_title_template('shelf_edit.html', shelf=shelf, title=title, page=page) diff --git a/cps/static/css/img/clear.png b/cps/static/css/img/clear.png new file mode 100644 index 00000000..580b52a5 Binary files /dev/null and b/cps/static/css/img/clear.png differ diff --git a/cps/static/css/img/loading.gif b/cps/static/css/img/loading.gif new file mode 100644 index 00000000..5b33f7e5 Binary files /dev/null and b/cps/static/css/img/loading.gif differ diff --git a/cps/static/css/libs/bootstrap-table.min.css b/cps/static/css/libs/bootstrap-table.min.css index 72a8f74f..0fa2968e 100644 --- a/cps/static/css/libs/bootstrap-table.min.css +++ b/cps/static/css/libs/bootstrap-table.min.css @@ -1,10 +1,10 @@ /** * bootstrap-table - An extended table to integration with some of the most widely used CSS frameworks. (Supports Bootstrap, Semantic UI, Bulma, Material Design, Foundation) * - * @version v1.16.0 + * @version v1.18.3 * @homepage https://bootstrap-table.com * @author wenzhixin (http://wenzhixin.net.cn/) * @license MIT */ -.bootstrap-table .fixed-table-toolbar::after{content:"";display:block;clear:both}.bootstrap-table .fixed-table-toolbar .bs-bars,.bootstrap-table .fixed-table-toolbar .columns,.bootstrap-table .fixed-table-toolbar .search{position:relative;margin-top:10px;margin-bottom:10px}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group{display:inline-block;margin-left:-1px!important}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group>.btn{border-radius:0}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group:first-child>.btn{border-top-left-radius:4px;border-bottom-left-radius:4px}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group:last-child>.btn{border-top-right-radius:4px;border-bottom-right-radius:4px}.bootstrap-table .fixed-table-toolbar .columns .dropdown-menu{text-align:left;max-height:300px;overflow:auto;-ms-overflow-style:scrollbar;z-index:1001}.bootstrap-table .fixed-table-toolbar .columns label{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.428571429}.bootstrap-table .fixed-table-toolbar .columns-left{margin-right:5px}.bootstrap-table .fixed-table-toolbar .columns-right{margin-left:5px}.bootstrap-table .fixed-table-toolbar .pull-right .dropdown-menu{right:0;left:auto}.bootstrap-table .fixed-table-container{position:relative;clear:both}.bootstrap-table .fixed-table-container .table{width:100%;margin-bottom:0!important}.bootstrap-table .fixed-table-container .table td,.bootstrap-table .fixed-table-container .table th{vertical-align:middle;box-sizing:border-box}.bootstrap-table .fixed-table-container .table thead th{vertical-align:bottom;padding:0;margin:0}.bootstrap-table .fixed-table-container .table thead th:focus{outline:0 solid transparent}.bootstrap-table .fixed-table-container .table thead th.detail{width:30px}.bootstrap-table .fixed-table-container .table thead th .th-inner{padding:.75rem;vertical-align:bottom;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bootstrap-table .fixed-table-container .table thead th .sortable{cursor:pointer;background-position:right;background-repeat:no-repeat;padding-right:30px!important}.bootstrap-table .fixed-table-container .table thead th .both{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAkElEQVQoz7X QMQ5AQBCF4dWQSJxC5wwax1Cq1e7BAdxD5SL+Tq/QCM1oNiJidwox0355mXnG/DrEtIQ6azioNZQxI0ykPhTQIwhCR+BmBYtlK7kLJYwWCcJA9M4qdrZrd8pPjZWPtOqdRQy320YSV17OatFC4euts6z39GYMKRPCTKY9UnPQ6P+GtMRfGtPnBCiqhAeJPmkqAAAAAElFTkSuQmCC")}.bootstrap-table .fixed-table-container .table thead th .asc{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZ0lEQVQ4y2NgGLKgquEuFxBPAGI2ahhWCsS/gDibUoO0gPgxEP8H4ttArEyuQYxAPBdqEAxPBImTY5gjEL9DM+wTENuQahAvEO9DMwiGdwAxOymGJQLxTyD+jgWDxCMZRsEoGAVoAADeemwtPcZI2wAAAABJRU5ErkJggg==)}.bootstrap-table .fixed-table-container .table thead th .desc{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZUlEQVQ4y2NgGAWjYBSggaqGu5FA/BOIv2PBIPFEUgxjB+IdQPwfC94HxLykus4GiD+hGfQOiB3J8SojEE9EM2wuSJzcsFMG4ttQgx4DsRalkZENxL+AuJQaMcsGxBOAmGvopk8AVz1sLZgg0bsAAAAASUVORK5CYII=)}.bootstrap-table .fixed-table-container .table tbody tr.selected td{background-color:rgba(0,0,0,.075)}.bootstrap-table .fixed-table-container .table tbody tr.no-records-found td{text-align:center}.bootstrap-table .fixed-table-container .table tbody tr .card-view{display:flex}.bootstrap-table .fixed-table-container .table tbody tr .card-view .card-view-title{font-weight:700;display:inline-block;min-width:30%;text-align:left!important}.bootstrap-table .fixed-table-container .table .bs-checkbox{text-align:center}.bootstrap-table .fixed-table-container .table .bs-checkbox label{margin-bottom:0}.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type=checkbox],.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type=radio]{margin:0 auto!important}.bootstrap-table .fixed-table-container .table.table-sm .th-inner{padding:.3rem}.bootstrap-table .fixed-table-container.fixed-height:not(.has-footer){border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height.has-card-view{border-top:1px solid #dee2e6;border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .fixed-table-border{border-left:1px solid #dee2e6;border-right:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .table thead th{border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .table-dark thead th{border-bottom:1px solid #32383e}.bootstrap-table .fixed-table-container .fixed-table-header{overflow:hidden}.bootstrap-table .fixed-table-container .fixed-table-body{overflow-x:auto;overflow-y:auto;height:100%}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading{align-items:center;background:#fff;display:none;justify-content:center;position:absolute;bottom:0;width:100%;z-index:1000}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap{align-items:baseline;display:flex;justify-content:center}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .loading-text{font-size:2rem;margin-right:6px}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap{align-items:center;display:flex;justify-content:center}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::before{content:"";animation-duration:1.5s;animation-iteration-count:infinite;animation-name:LOADING;background:#212529;border-radius:50%;display:block;height:5px;margin:0 4px;opacity:0;width:5px}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot{animation-delay:.3s}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after{animation-delay:.6s}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark{background:#212529}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-dot,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::after,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::before{background:#fff}.bootstrap-table .fixed-table-container .fixed-table-footer{overflow:hidden}.bootstrap-table .fixed-table-pagination::after{content:"";display:block;clear:both}.bootstrap-table .fixed-table-pagination>.pagination,.bootstrap-table .fixed-table-pagination>.pagination-detail{margin-top:10px;margin-bottom:10px}.bootstrap-table .fixed-table-pagination>.pagination-detail .pagination-info{line-height:34px;margin-right:5px}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list{display:inline-block}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list .btn-group{position:relative;display:inline-block;vertical-align:middle}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list .btn-group .dropdown-menu{margin-bottom:0}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination{margin:0}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination a{padding:6px 12px;line-height:1.428571429}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a{color:#c8c8c8}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a::before{content:'\2B05'}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a::after{content:'\27A1'}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.disabled a{pointer-events:none;cursor:default}.bootstrap-table.fullscreen{position:fixed;top:0;left:0;z-index:1050;width:100%!important;background:#fff;height:calc(100vh);overflow-y:scroll}div.fixed-table-scroll-inner{width:100%;height:200px}div.fixed-table-scroll-outer{top:0;left:0;visibility:hidden;width:200px;height:150px;overflow:hidden}@keyframes LOADING{0%{opacity:0}50%{opacity:1}to{opacity:0}} \ No newline at end of file +.bootstrap-table .fixed-table-toolbar::after{content:"";display:block;clear:both}.bootstrap-table .fixed-table-toolbar .bs-bars,.bootstrap-table .fixed-table-toolbar .columns,.bootstrap-table .fixed-table-toolbar .search{position:relative;margin-top:10px;margin-bottom:10px}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group{display:inline-block;margin-left:-1px!important}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group>.btn{border-radius:0}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group:first-child>.btn{border-top-left-radius:4px;border-bottom-left-radius:4px}.bootstrap-table .fixed-table-toolbar .columns .btn-group>.btn-group:last-child>.btn{border-top-right-radius:4px;border-bottom-right-radius:4px}.bootstrap-table .fixed-table-toolbar .columns .dropdown-menu{text-align:left;max-height:300px;overflow:auto;-ms-overflow-style:scrollbar;z-index:1001}.bootstrap-table .fixed-table-toolbar .columns label{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.428571429}.bootstrap-table .fixed-table-toolbar .columns-left{margin-right:5px}.bootstrap-table .fixed-table-toolbar .columns-right{margin-left:5px}.bootstrap-table .fixed-table-toolbar .pull-right .dropdown-menu{right:0;left:auto}.bootstrap-table .fixed-table-container{position:relative;clear:both}.bootstrap-table .fixed-table-container .table{width:100%;margin-bottom:0!important}.bootstrap-table .fixed-table-container .table td,.bootstrap-table .fixed-table-container .table th{vertical-align:middle;box-sizing:border-box}.bootstrap-table .fixed-table-container .table thead th{vertical-align:bottom;padding:0;margin:0}.bootstrap-table .fixed-table-container .table thead th:focus{outline:0 solid transparent}.bootstrap-table .fixed-table-container .table thead th.detail{width:30px}.bootstrap-table .fixed-table-container .table thead th .th-inner{padding:.75rem;vertical-align:bottom;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bootstrap-table .fixed-table-container .table thead th .sortable{cursor:pointer;background-position:right;background-repeat:no-repeat;padding-right:30px!important}.bootstrap-table .fixed-table-container .table thead th .both{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAkElEQVQoz7X QMQ5AQBCF4dWQSJxC5wwax1Cq1e7BAdxD5SL+Tq/QCM1oNiJidwox0355mXnG/DrEtIQ6azioNZQxI0ykPhTQIwhCR+BmBYtlK7kLJYwWCcJA9M4qdrZrd8pPjZWPtOqdRQy320YSV17OatFC4euts6z39GYMKRPCTKY9UnPQ6P+GtMRfGtPnBCiqhAeJPmkqAAAAAElFTkSuQmCC")}.bootstrap-table .fixed-table-container .table thead th .asc{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZ0lEQVQ4y2NgGLKgquEuFxBPAGI2ahhWCsS/gDibUoO0gPgxEP8H4ttArEyuQYxAPBdqEAxPBImTY5gjEL9DM+wTENuQahAvEO9DMwiGdwAxOymGJQLxTyD+jgWDxCMZRsEoGAVoAADeemwtPcZI2wAAAABJRU5ErkJggg==")}.bootstrap-table .fixed-table-container .table thead th .desc{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZUlEQVQ4y2NgGAWjYBSggaqGu5FA/BOIv2PBIPFEUgxjB+IdQPwfC94HxLykus4GiD+hGfQOiB3J8SojEE9EM2wuSJzcsFMG4ttQgx4DsRalkZENxL+AuJQaMcsGxBOAmGvopk8AVz1sLZgg0bsAAAAASUVORK5CYII= ")}.bootstrap-table .fixed-table-container .table tbody tr.selected td{background-color:rgba(0,0,0,.075)}.bootstrap-table .fixed-table-container .table tbody tr.no-records-found td{text-align:center}.bootstrap-table .fixed-table-container .table tbody tr .card-view{display:flex}.bootstrap-table .fixed-table-container .table tbody tr .card-view .card-view-title{font-weight:700;display:inline-block;min-width:30%;width:auto!important;text-align:left!important}.bootstrap-table .fixed-table-container .table tbody tr .card-view .card-view-value{width:100%!important}.bootstrap-table .fixed-table-container .table .bs-checkbox{text-align:center}.bootstrap-table .fixed-table-container .table .bs-checkbox label{margin-bottom:0}.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type=checkbox],.bootstrap-table .fixed-table-container .table .bs-checkbox label input[type=radio]{margin:0 auto!important}.bootstrap-table .fixed-table-container .table.table-sm .th-inner{padding:.3rem}.bootstrap-table .fixed-table-container.fixed-height:not(.has-footer){border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height.has-card-view{border-top:1px solid #dee2e6;border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .fixed-table-border{border-left:1px solid #dee2e6;border-right:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .table thead th{border-bottom:1px solid #dee2e6}.bootstrap-table .fixed-table-container.fixed-height .table-dark thead th{border-bottom:1px solid #32383e}.bootstrap-table .fixed-table-container .fixed-table-header{overflow:hidden}.bootstrap-table .fixed-table-container .fixed-table-body{overflow-x:auto;overflow-y:auto;height:100%}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading{align-items:center;background:#fff;display:flex;justify-content:center;position:absolute;bottom:0;width:100%;z-index:1000;transition:visibility 0s,opacity .15s ease-in-out;opacity:0;visibility:hidden}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.open{visibility:visible;opacity:1}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap{align-items:baseline;display:flex;justify-content:center}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .loading-text{margin-right:6px}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap{align-items:center;display:flex;justify-content:center}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::before{content:"";animation-duration:1.5s;animation-iteration-count:infinite;animation-name:LOADING;background:#212529;border-radius:50%;display:block;height:5px;margin:0 4px;opacity:0;width:5px}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-dot{animation-delay:.3s}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading .loading-wrap .animation-wrap::after{animation-delay:.6s}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark{background:#212529}.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-dot,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::after,.bootstrap-table .fixed-table-container .fixed-table-body .fixed-table-loading.table-dark .animation-wrap::before{background:#fff}.bootstrap-table .fixed-table-container .fixed-table-footer{overflow:hidden}.bootstrap-table .fixed-table-pagination::after{content:"";display:block;clear:both}.bootstrap-table .fixed-table-pagination>.pagination,.bootstrap-table .fixed-table-pagination>.pagination-detail{margin-top:10px;margin-bottom:10px}.bootstrap-table .fixed-table-pagination>.pagination-detail .pagination-info{line-height:34px;margin-right:5px}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list{display:inline-block}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list .btn-group{position:relative;display:inline-block;vertical-align:middle}.bootstrap-table .fixed-table-pagination>.pagination-detail .page-list .btn-group .dropdown-menu{margin-bottom:0}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination{margin:0}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a{color:#c8c8c8}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a::before{content:'\2B05'}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.page-intermediate a::after{content:'\27A1'}.bootstrap-table .fixed-table-pagination>.pagination ul.pagination li.disabled a{pointer-events:none;cursor:default}.bootstrap-table.fullscreen{position:fixed;top:0;left:0;z-index:1050;width:100%!important;background:#fff;height:calc(100vh);overflow-y:scroll}.bootstrap-table.bootstrap4 .pagination-lg .page-link,.bootstrap-table.bootstrap5 .pagination-lg .page-link{padding:.5rem 1rem}.bootstrap-table.bootstrap5 .float-left{float:left}.bootstrap-table.bootstrap5 .float-right{float:right}div.fixed-table-scroll-inner{width:100%;height:200px}div.fixed-table-scroll-outer{top:0;left:0;visibility:hidden;width:200px;height:150px;overflow:hidden}@keyframes LOADING{0%{opacity:0}50%{opacity:1}to{opacity:0}} \ No newline at end of file diff --git a/cps/static/css/style.css b/cps/static/css/style.css index 02658771..f1d38901 100644 --- a/cps/static/css/style.css +++ b/cps/static/css/style.css @@ -56,6 +56,7 @@ a, .book-remove, .editable-empty, .editable-empty:hover { color: #45b29d; } +.book-remove:hover { color: #23527c; } .user-remove:hover { color: #23527c; } .btn-default a { color: #444; } .panel-title > a { text-decoration: none; } diff --git a/cps/static/js/filter_grid.js b/cps/static/js/filter_grid.js index d57d155f..623ffdc1 100644 --- a/cps/static/js/filter_grid.js +++ b/cps/static/js/filter_grid.js @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +var direction = $("#asc").data('order'); // 0=Descending order; 1= ascending order + var $list = $("#list").isotope({ itemSelector: ".book", layoutMode: "fitRows", @@ -24,6 +26,9 @@ var $list = $("#list").isotope({ }); $("#desc").click(function() { + if (direction === 0) { + return; + } var page = $(this).data("id"); $.ajax({ method:"post", @@ -39,6 +44,9 @@ $("#desc").click(function() { }); $("#asc").click(function() { + if (direction === 1) { + return; + } var page = $(this).data("id"); $.ajax({ method:"post", diff --git a/cps/static/js/filter_list.js b/cps/static/js/filter_list.js index ef7639fa..b8f79f4e 100644 --- a/cps/static/js/filter_list.js +++ b/cps/static/js/filter_list.js @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -var direction = 0; // Descending order +var direction = $("#asc").data('order'); // 0=Descending order; 1= ascending order var sort = 0; // Show sorted entries $("#sort_name").click(function() { diff --git a/cps/static/js/libs/bootstrap-table/bootstrap-table-editable.min.js b/cps/static/js/libs/bootstrap-table/bootstrap-table-editable.min.js index cd411037..27f5fdb8 100644 --- a/cps/static/js/libs/bootstrap-table/bootstrap-table-editable.min.js +++ b/cps/static/js/libs/bootstrap-table/bootstrap-table-editable.min.js @@ -1,10 +1,10 @@ /** * bootstrap-table - An extended table to integration with some of the most widely used CSS frameworks. (Supports Bootstrap, Semantic UI, Bulma, Material Design, Foundation) * - * @version v1.16.0 + * @version v1.18.3 * @homepage https://bootstrap-table.com * @author wenzhixin (http://wenzhixin.net.cn/) * @license MIT */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(require("jquery")):"function"==typeof define&&define.amd?define(["jquery"],e):e((t=t||self).jQuery)}(this,(function(t){"use strict";t=t&&t.hasOwnProperty("default")?t.default:t;var e="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};function n(t,e){return t(e={exports:{}},e.exports),e.exports}var r=function(t){return t&&t.Math==Math&&t},o=r("object"==typeof globalThis&&globalThis)||r("object"==typeof window&&window)||r("object"==typeof self&&self)||r("object"==typeof e&&e)||Function("return this")(),i=function(t){try{return!!t()}catch(t){return!0}},a=!i((function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})),c={}.propertyIsEnumerable,u=Object.getOwnPropertyDescriptor,f={f:u&&!c.call({1:2},1)?function(t){var e=u(this,t);return!!e&&e.enumerable}:c},l=function(t,e){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:e}},s={}.toString,d=function(t){return s.call(t).slice(8,-1)},p="".split,h=i((function(){return!Object("z").propertyIsEnumerable(0)}))?function(t){return"String"==d(t)?p.call(t,""):Object(t)}:Object,v=function(t){if(null==t)throw TypeError("Can't call method on "+t);return t},y=function(t){return h(v(t))},g=function(t){return"object"==typeof t?null!==t:"function"==typeof t},b=function(t,e){if(!g(t))return t;var n,r;if(e&&"function"==typeof(n=t.toString)&&!g(r=n.call(t)))return r;if("function"==typeof(n=t.valueOf)&&!g(r=n.call(t)))return r;if(!e&&"function"==typeof(n=t.toString)&&!g(r=n.call(t)))return r;throw TypeError("Can't convert object to primitive value")},m={}.hasOwnProperty,x=function(t,e){return m.call(t,e)},O=o.document,w=g(O)&&g(O.createElement),E=function(t){return w?O.createElement(t):{}},j=!a&&!i((function(){return 7!=Object.defineProperty(E("div"),"a",{get:function(){return 7}}).a})),S=Object.getOwnPropertyDescriptor,T={f:a?S:function(t,e){if(t=y(t),e=b(e,!0),j)try{return S(t,e)}catch(t){}if(x(t,e))return l(!f.f.call(t,e),t[e])}},P=function(t){if(!g(t))throw TypeError(String(t)+" is not an object");return t},A=Object.defineProperty,_={f:a?A:function(t,e,n){if(P(t),e=b(e,!0),P(n),j)try{return A(t,e,n)}catch(t){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(t[e]=n.value),t}},I=a?function(t,e,n){return _.f(t,e,l(1,n))}:function(t,e,n){return t[e]=n,t},R=function(t,e){try{I(o,t,e)}catch(n){o[t]=e}return e},C=o["__core-js_shared__"]||R("__core-js_shared__",{}),k=Function.toString;"function"!=typeof C.inspectSource&&(C.inspectSource=function(t){return k.call(t)});var M,$,F,D=C.inspectSource,N=o.WeakMap,q="function"==typeof N&&/native code/.test(D(N)),B=n((function(t){(t.exports=function(t,e){return C[t]||(C[t]=void 0!==e?e:{})})("versions",[]).push({version:"3.6.0",mode:"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})})),L=0,K=Math.random(),V=function(t){return"Symbol("+String(void 0===t?"":t)+")_"+(++L+K).toString(36)},U=B("keys"),W=function(t){return U[t]||(U[t]=V(t))},z={},Y=o.WeakMap;if(q){var G=new Y,H=G.get,Q=G.has,X=G.set;M=function(t,e){return X.call(G,t,e),e},$=function(t){return H.call(G,t)||{}},F=function(t){return Q.call(G,t)}}else{var Z=W("state");z[Z]=!0,M=function(t,e){return I(t,Z,e),e},$=function(t){return x(t,Z)?t[Z]:{}},F=function(t){return x(t,Z)}}var J,tt,et={set:M,get:$,has:F,enforce:function(t){return F(t)?$(t):M(t,{})},getterFor:function(t){return function(e){var n;if(!g(e)||(n=$(e)).type!==t)throw TypeError("Incompatible receiver, "+t+" required");return n}}},nt=n((function(t){var e=et.get,n=et.enforce,r=String(String).split("String");(t.exports=function(t,e,i,a){var c=!!a&&!!a.unsafe,u=!!a&&!!a.enumerable,f=!!a&&!!a.noTargetGet;"function"==typeof i&&("string"!=typeof e||x(i,"name")||I(i,"name",e),n(i).source=r.join("string"==typeof e?e:"")),t!==o?(c?!f&&t[e]&&(u=!0):delete t[e],u?t[e]=i:I(t,e,i)):u?t[e]=i:R(e,i)})(Function.prototype,"toString",(function(){return"function"==typeof this&&e(this).source||D(this)}))})),rt=o,ot=function(t){return"function"==typeof t?t:void 0},it=function(t,e){return arguments.length<2?ot(rt[t])||ot(o[t]):rt[t]&&rt[t][e]||o[t]&&o[t][e]},at=Math.ceil,ct=Math.floor,ut=function(t){return isNaN(t=+t)?0:(t>0?ct:at)(t)},ft=Math.min,lt=function(t){return t>0?ft(ut(t),9007199254740991):0},st=Math.max,dt=Math.min,pt=function(t){return function(e,n,r){var o,i=y(e),a=lt(i.length),c=function(t,e){var n=ut(t);return n<0?st(n+e,0):dt(n,e)}(r,a);if(t&&n!=n){for(;a>c;)if((o=i[c++])!=o)return!0}else for(;a>c;c++)if((t||c in i)&&i[c]===n)return t||c||0;return!t&&-1}},ht={includes:pt(!0),indexOf:pt(!1)},vt=ht.indexOf,yt=function(t,e){var n,r=y(t),o=0,i=[];for(n in r)!x(z,n)&&x(r,n)&&i.push(n);for(;e.length>o;)x(r,n=e[o++])&&(~vt(i,n)||i.push(n));return i},gt=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],bt=gt.concat("length","prototype"),mt={f:Object.getOwnPropertyNames||function(t){return yt(t,bt)}},xt={f:Object.getOwnPropertySymbols},Ot=it("Reflect","ownKeys")||function(t){var e=mt.f(P(t)),n=xt.f;return n?e.concat(n(t)):e},wt=function(t,e){for(var n=Ot(e),r=_.f,o=T.f,i=0;i=74)&&(J=Vt.match(/Chrome\/(\d+)/))&&(tt=J[1]);var Yt,Gt=tt&&+tt,Ht=Bt("species"),Qt=Bt("isConcatSpreadable"),Xt=Gt>=51||!i((function(){var t=[];return t[Qt]=!1,t.concat()[0]!==t})),Zt=(Yt="concat",Gt>=51||!i((function(){var t=[];return(t.constructor={})[Ht]=function(){return{foo:1}},1!==t[Yt](Boolean).foo}))),Jt=function(t){if(!g(t))return!1;var e=t[Qt];return void 0!==e?!!e:Ct(t)};Rt({target:"Array",proto:!0,forced:!Xt||!Zt},{concat:function(t){var e,n,r,o,i,a=kt(this),c=Kt(a,0),u=0;for(e=-1,r=arguments.length;e9007199254740991)throw TypeError("Maximum allowed index exceeded");for(n=0;n=9007199254740991)throw TypeError("Maximum allowed index exceeded");Mt(c,u++,i)}return c.length=u,c}});var te,ee=function(t,e,n){if(function(t){if("function"!=typeof t)throw TypeError(String(t)+" is not a function")}(t),void 0===e)return t;switch(n){case 0:return function(){return t.call(e)};case 1:return function(n){return t.call(e,n)};case 2:return function(n,r){return t.call(e,n,r)};case 3:return function(n,r,o){return t.call(e,n,r,o)}}return function(){return t.apply(e,arguments)}},ne=[].push,re=function(t){var e=1==t,n=2==t,r=3==t,o=4==t,i=6==t,a=5==t||i;return function(c,u,f,l){for(var s,d,p=kt(c),v=h(p),y=ee(u,f,3),g=lt(v.length),b=0,m=l||Kt,x=e?m(c,g):n?m(c,0):void 0;g>b;b++)if((a||b in v)&&(d=y(s=v[b],b,p),t))if(e)x[b]=d;else if(d)switch(t){case 3:return!0;case 5:return s;case 6:return b;case 2:ne.call(x,s)}else if(o)return!1;return i?-1:r||o?o:x}},oe={forEach:re(0),map:re(1),filter:re(2),some:re(3),every:re(4),find:re(5),findIndex:re(6)},ie=Object.keys||function(t){return yt(t,gt)},ae=a?Object.defineProperties:function(t,e){P(t);for(var n,r=ie(e),o=r.length,i=0;o>i;)_.f(t,n=r[i++],e[n]);return t},ce=it("document","documentElement"),ue=W("IE_PROTO"),fe=function(){},le=function(t){return" - {% endblock %} diff --git a/cps/templates/config_view_edit.html b/cps/templates/config_view_edit.html index d87e9f81..e0a8094c 100644 --- a/cps/templates/config_view_edit.html +++ b/cps/templates/config_view_edit.html @@ -141,8 +141,8 @@ - {{_('Add Allowed/Denied Tags')}} - {{_('Add Allowed/Denied custom column values')}} + {{_('Add Allowed/Denied Tags')}} + {{_('Add Allowed/Denied custom column values')}} diff --git a/cps/templates/email_edit.html b/cps/templates/email_edit.html index 0dd9ef17..8b92a248 100644 --- a/cps/templates/email_edit.html +++ b/cps/templates/email_edit.html @@ -7,44 +7,66 @@

{{title}}

+ {% if feature_support['gmail'] %}
- - + +
-
- - +
+
+ {% if content.mail_gmail_token == {} %} + + {% else %} + + {% endif %} +
-
- - +
+ {% endif %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + +
+ + + {% if feature_support['gmail'] %}
-
- - -
-
- - -
-
- - -
- -
- - - - -
- - - {{_('Cancel')}} + {% endif %} + {{_('Back')}} {% if g.allow_registration %}
diff --git a/cps/templates/feed.xml b/cps/templates/feed.xml index 4ad1db8c..9073142e 100644 --- a/cps/templates/feed.xml +++ b/cps/templates/feed.xml @@ -84,4 +84,11 @@ {% endfor %} + {% for entry in letterelements %} + + {{entry['name']}} + {{ url_for(folder, book_id=entry['id']) }} + + + {% endfor %} diff --git a/cps/templates/grid.html b/cps/templates/grid.html index 9724e31d..6b03d89c 100644 --- a/cps/templates/grid.html +++ b/cps/templates/grid.html @@ -8,8 +8,8 @@ {% endif %} {% endif %} - - + + {% if charlist|length %} {% endif %} diff --git a/cps/templates/index.xml b/cps/templates/index.xml index c6a6e8f0..4ffd4290 100644 --- a/cps/templates/index.xml +++ b/cps/templates/index.xml @@ -14,6 +14,13 @@ {{instance}} https://github.com/janeczku/calibre-web + + {{_('Alphabetical Books')}} + + {{url_for('opds.feed_booksindex')}} + {{ current_time }} + {{_('Books sorted alphabetically')}} + {{_('Hot Books')}} diff --git a/cps/templates/layout.html b/cps/templates/layout.html index 643459cd..d4e0800e 100644 --- a/cps/templates/layout.html +++ b/cps/templates/layout.html @@ -1,4 +1,4 @@ -{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal %} +{% from 'modal_dialogs.html' import restrict_modal, delete_book, filechooser_modal, delete_confirm_modal, change_confirm_modal %} @@ -76,7 +76,7 @@ {% if g.user.role_admin() %}
  • {% endif %} -
  • +
  • {% if not g.user.is_anonymous %}
  • {% endif %} diff --git a/cps/templates/list.html b/cps/templates/list.html index 53a712d1..1e171ca5 100644 --- a/cps/templates/list.html +++ b/cps/templates/list.html @@ -8,8 +8,8 @@ {% endif %} {% endif %} - - + + {% if charlist|length %} {% endif %} diff --git a/cps/templates/modal_dialogs.html b/cps/templates/modal_dialogs.html index da00834c..e134236e 100644 --- a/cps/templates/modal_dialogs.html +++ b/cps/templates/modal_dialogs.html @@ -22,7 +22,7 @@
    - +
    @@ -108,13 +108,31 @@ +
    +{% endmacro %} + +{% macro change_confirm_modal() %} +