Catch additional error on not existing custom column linked to read column (#2341)

Prevent metadata changes are lost on edit books with errors (#2326)
Better log output
Renamed log message on database delete
This commit is contained in:
Ozzie Isaacs 2022-03-20 11:21:15 +01:00
parent 39459603d4
commit 8cb5989c97
10 changed files with 312 additions and 241 deletions

View File

@ -1237,7 +1237,7 @@ def _db_configuration_update_helper():
config.store_calibre_uuid(calibre_db, db.LibraryId)
# if db changed -> delete shelfs, delete download books, delete read books, kobo sync...
if db_change:
log.info("Calibre Database changed, delete all Calibre-Web info related to old Database")
log.info("Calibre Database changed, all Calibre-Web info related to old Database gets deleted")
ub.session.query(ub.Downloads).delete()
ub.session.query(ub.ArchivedBook).delete()
ub.session.query(ub.ReadBook).delete()

View File

@ -620,8 +620,8 @@ class CalibreDB:
bd = (self.session.query(Books, read_column.value, ub.ArchivedBook.is_archived).select_from(Books)
.join(read_column, read_column.book == book_id,
isouter=True))
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(read_column))
# Skip linking read column and return None instead of read status
bd = self.session.query(Books, None, ub.ArchivedBook.is_archived)
return (bd.filter(Books.id == book_id)
@ -665,11 +665,11 @@ class CalibreDB:
neg_content_cc_filter = false() if neg_cc_list == [''] else \
getattr(Books, 'custom_column_' + str(self.config.config_restricted_column)). \
any(cc_classes[self.config.config_restricted_column].value.in_(neg_cc_list))
except (KeyError, AttributeError):
except (KeyError, AttributeError, IndexError):
pos_content_cc_filter = false()
neg_content_cc_filter = true()
log.error(u"Custom Column No.%d is not existing in calibre database",
self.config.config_restricted_column)
log.error("Custom Column No.{} is not existing in calibre database".format(
self.config.config_restricted_column))
flash(_("Custom Column No.%(column)d is not existing in calibre database",
column=self.config.config_restricted_column),
category="error")
@ -727,8 +727,8 @@ class CalibreDB:
query = (self.session.query(database, read_column.value, ub.ArchivedBook.is_archived)
.select_from(Books)
.outerjoin(read_column, read_column.book == Books.id))
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(read_column))
# Skip linking read column and return None instead of read status
query = self.session.query(database, None, ub.ArchivedBook.is_archived)
query = query.outerjoin(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,
@ -839,8 +839,8 @@ class CalibreDB:
read_column = cc_classes[config_read_column]
query = (self.session.query(Books, ub.ArchivedBook.is_archived, read_column.value).select_from(Books)
.outerjoin(read_column, read_column.book == Books.id))
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", config_read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(config_read_column))
# Skip linking read column
query = self.session.query(Books, ub.ArchivedBook.is_archived, None)
query = query.outerjoin(ub.ArchivedBook, and_(Books.id == ub.ArchivedBook.book_id,

View File

@ -289,13 +289,13 @@ def delete_whole_book(book_id, book):
def render_delete_book_result(book_format, json_response, warning, book_id):
if book_format:
if json_response:
return json.dumps([warning, {"location": url_for("edit-book.edit_book", book_id=book_id),
return json.dumps([warning, {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "success",
"format": book_format,
"message": _('Book Format Successfully Deleted')}])
else:
flash(_('Book Format Successfully Deleted'), category="success")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else:
if json_response:
return json.dumps([warning, {"location": url_for('web.index'),
@ -316,16 +316,16 @@ def delete_book_from_table(book_id, book_format, json_response):
result, error = helper.delete_book(book, config.config_calibre_dir, book_format=book_format.upper())
if not result:
if json_response:
return json.dumps([{"location": url_for("edit-book.edit_book", book_id=book_id),
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": error}])
else:
flash(error, category="error")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
if error:
if json_response:
warning = {"location": url_for("edit-book.edit_book", book_id=book_id),
warning = {"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "warning",
"format": "",
"message": error}
@ -343,13 +343,13 @@ def delete_book_from_table(book_id, book_format, json_response):
log.error_or_exception(ex)
calibre_db.session.rollback()
if json_response:
return json.dumps([{"location": url_for("edit-book.edit_book", book_id=book_id),
return json.dumps([{"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": ex}])
else:
flash(str(ex), category="error")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
else:
# book not found
@ -357,13 +357,13 @@ def delete_book_from_table(book_id, book_format, json_response):
return render_delete_book_result(book_format, json_response, warning, book_id)
message = _("You are missing permissions to delete books")
if json_response:
return json.dumps({"location": url_for("edit-book.edit_book", book_id=book_id),
return json.dumps({"location": url_for("edit-book.show_edit_book", book_id=book_id),
"type": "danger",
"format": "",
"message": message})
else:
flash(message, category="error")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
def render_edit_book(book_id):
@ -413,18 +413,18 @@ def render_edit_book(book_id):
def edit_book_ratings(to_save, book):
changed = False
if to_save["rating"].strip():
if to_save.get("rating","").strip():
old_rating = False
if len(book.ratings) > 0:
old_rating = book.ratings[0].rating
ratingx2 = int(float(to_save["rating"]) * 2)
if ratingx2 != old_rating:
rating_x2 = int(float(to_save.get("rating","")) * 2)
if rating_x2 != old_rating:
changed = True
is_rating = calibre_db.session.query(db.Ratings).filter(db.Ratings.rating == ratingx2).first()
is_rating = calibre_db.session.query(db.Ratings).filter(db.Ratings.rating == rating_x2).first()
if is_rating:
book.ratings.append(is_rating)
else:
new_rating = db.Ratings(rating=ratingx2)
new_rating = db.Ratings(rating=rating_x2)
book.ratings.append(new_rating)
if old_rating:
book.ratings.remove(book.ratings[0])
@ -622,24 +622,26 @@ def edit_cc_data(book_id, book, to_save, cc):
'custom')
return changed
# returns None if no file is uploaded
# returns False if an error occours, in all other cases the ebook metadata is returned
def upload_single_file(file_request, book, book_id):
# Check and handle Uploaded file
if 'btn-upload-format' in file_request.files:
requested_file = file_request.files['btn-upload-format']
requested_file = file_request.files.get('btn-upload-format', None)
if requested_file:
# check for empty request
if requested_file.filename != '':
if not current_user.role_upload():
abort(403)
flash(_(u"User has no rights to upload additional file formats"), category="error")
return False
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 redirect(url_for('web.show_book', book_id=book.id))
return False
else:
flash(_('File to be uploaded must have an extension'), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
return False
file_name = book.path.rsplit('/', 1)[-1]
filepath = os.path.normpath(os.path.join(config.config_calibre_dir, book.path))
@ -651,12 +653,12 @@ def upload_single_file(file_request, book, book_id):
os.makedirs(filepath)
except OSError:
flash(_(u"Failed to create path %(path)s (Permission denied).", path=filepath), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
return False
try:
requested_file.save(saved_filename)
except OSError:
flash(_(u"Failed to store file %(file)s.", file=saved_filename), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
return False
file_size = os.path.getsize(saved_filename)
is_format = calibre_db.get_book_format(book_id, file_ext.upper())
@ -674,7 +676,7 @@ def upload_single_file(file_request, book, book_id):
calibre_db.session.rollback()
log.error_or_exception("Database error: {}".format(e))
flash(_(u"Database error: %(error)s.", error=e.orig), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
return False # return redirect(url_for('web.show_book', book_id=book.id))
# Queue uploader info
link = '<a href="{}">{}</a>'.format(url_for('web.show_book', book_id=book.id), escape(book.title))
@ -684,15 +686,16 @@ def upload_single_file(file_request, book, book_id):
return uploader.process(
saved_filename, *os.path.splitext(requested_file.filename),
rarExecutable=config.config_rarfile_location)
return None
def upload_cover(cover_request, book):
if 'btn-upload-cover' in cover_request.files:
requested_file = cover_request.files['btn-upload-cover']
requested_file = cover_request.files.get('btn-upload-cover', None)
if requested_file:
# check for empty request
if requested_file.filename != '':
if not current_user.role_upload():
abort(403)
flash(_(u"User has no rights to upload cover"), category="error")
return False
ret, message = helper.save_cover(requested_file, book.path)
if ret is True:
return True
@ -716,25 +719,6 @@ def handle_title_on_edit(book, book_title):
def handle_author_on_edit(book, author_name, update_stored=True):
# handle author(s)
input_authors, renamed = prepare_authors(author_name)
'''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
renamed = list()
for in_aut in input_authors:
renamed_author = calibre_db.session.query(db.Authors).filter(db.Authors.name == in_aut).first()
if renamed_author and in_aut != renamed_author.name:
renamed.append(renamed_author.name)
all_books = calibre_db.session.query(db.Books) \
.filter(db.Books.authors.any(db.Authors.name == renamed_author.name)).all()
sorted_renamed_author = helper.get_sorted_author(renamed_author.name)
sorted_old_author = helper.get_sorted_author(in_aut)
for one_book in all_books:
one_book.author_sort = one_book.author_sort.replace(sorted_renamed_author, sorted_old_author)'''
change = modify_database_object(input_authors, book.authors, db.Authors, calibre_db.session, 'author')
@ -754,12 +738,19 @@ def handle_author_on_edit(book, author_name, update_stored=True):
change = True
return input_authors, change, renamed
@EditBook.route("/admin/book/<int:book_id>", methods=['GET'])
@login_required_if_no_ano
@edit_required
def show_edit_book(book_id):
return render_edit_book(book_id)
@EditBook.route("/admin/book/<int:book_id>", methods=['GET', 'POST'])
@EditBook.route("/admin/book/<int:book_id>", methods=['POST'])
@login_required_if_no_ano
@edit_required
def edit_book(book_id):
modify_date = False
edit_error = False
# create the function for sorting...
try:
@ -768,109 +759,120 @@ def edit_book(book_id):
log.error_or_exception(e)
calibre_db.session.rollback()
# Show form
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
if not book:
flash(_(u"Oops! Selected book title is unavailable. File does not exist or is not accessible"),
category="error")
return redirect(url_for("web.index"))
meta = upload_single_file(request, book, book_id)
if upload_cover(request, book) is True:
book.has_cover = 1
modify_date = True
to_save = request.form.to_dict()
try:
to_save = request.form.to_dict()
merge_metadata(to_save, meta)
# Update book
# Update folder of book on local disk
edited_books_id = None
# handle book title
title_author_error = None
# handle book title change
title_change = handle_title_on_edit(book, to_save["book_title"])
input_authors, authorchange, renamed = handle_author_on_edit(book, to_save["author_name"])
if authorchange or title_change:
# handle book author change
input_authors, author_change, renamed = handle_author_on_edit(book, to_save["author_name"])
if author_change or title_change:
edited_books_id = book.id
modify_date = True
title_author_error = helper.update_dir_structure(edited_books_id,
config.config_calibre_dir,
input_authors[0],
renamed_author=renamed)
if title_author_error:
flash(title_author_error, category="error")
calibre_db.session.rollback()
book = calibre_db.get_filtered_book(book_id, allow_show_archived=True)
# handle upload other formats from local disk
meta = upload_single_file(request, book, book_id)
# only merge metadata if file was uploaded and no error occurred (meta equals not false or none)
if meta:
merge_metadata(to_save, meta)
# handle upload covers from local disk
cover_upload_success = upload_cover(request, book)
if cover_upload_success:
book.has_cover = 1
modify_date = True
# upload new covers or new file formats to google drive
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
error = ""
if edited_books_id:
error = helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0],
renamed_author=renamed)
if to_save.get("cover_url", None):
if not current_user.role_upload():
edit_error = True
flash(_(u"User has no rights to upload cover"), category="error")
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
book.has_cover = 0
else:
result, error = helper.save_cover_from_url(to_save["cover_url"], book.path)
if result is True:
book.has_cover = 1
modify_date = True
else:
flash(error, category="error")
if not error:
if "cover_url" in to_save:
if to_save["cover_url"]:
if not current_user.role_upload():
calibre_db.session.rollback()
return "", 403
if to_save["cover_url"].endswith('/static/generic_cover.jpg'):
book.has_cover = 0
else:
result, error = helper.save_cover_from_url(to_save["cover_url"], book.path)
if result is True:
book.has_cover = 1
modify_date = True
else:
flash(error, category="error")
# Add default series_index to book
modify_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save['tags'], book)
# Handle book series
modify_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modify_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
# Add default series_index to book
modify_date |= edit_book_series_index(to_save["series_index"], book)
# Handle book comments/description
modify_date |= edit_book_comments(Markup(to_save['description']).unescape(), book)
# Handle identifiers
input_identifiers = identifier_list(to_save, book)
modification, warning = modify_identifiers(input_identifiers, book.identifiers, calibre_db.session)
if warning:
flash(_("Identifiers are not Case Sensitive, Overwriting Old Identifier"), category="warning")
modify_date |= modification
# Handle book tags
modify_date |= edit_book_tags(to_save['tags'], book)
# Handle book series
modify_date |= edit_book_series(to_save["series"], book)
# handle book publisher
modify_date |= edit_book_publisher(to_save['publisher'], book)
# handle book languages
try:
modify_date |= edit_book_languages(to_save['languages'], book)
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
except ValueError as e:
flash(str(e), category="error")
edit_error = True
# handle book ratings
modify_date |= edit_book_ratings(to_save, book)
# handle cc data
modify_date |= edit_all_cc_data(book_id, book, to_save)
if to_save["pubdate"]:
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError:
book.pubdate = db.Books.DEFAULT_PUBDATE
else:
if to_save.get("pubdate", None):
try:
book.pubdate = datetime.strptime(to_save["pubdate"], "%Y-%m-%d")
except ValueError as e:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.utcnow()
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
flash(_("Metadata successfully updated"), category="success")
return render_edit_book(book_id)
flash(str(e), category="error")
edit_error = True
else:
book.pubdate = db.Books.DEFAULT_PUBDATE
if modify_date:
book.last_modified = datetime.utcnow()
kobo_sync_status.remove_synced_book(edited_books_id, all=True)
calibre_db.session.merge(book)
calibre_db.session.commit()
if config.config_use_google_drive:
gdriveutils.updateGdriveCalibreFromLocal()
if meta is not False \
and edit_error is not True \
and title_author_error is not True \
and cover_upload_success is not False:
flash(_("Metadata successfully updated"), category="success")
if "detail_view" in to_save:
return redirect(url_for('web.show_book', book_id=book.id))
else:
calibre_db.session.rollback()
flash(error, category="error")
return render_edit_book(book_id)
except ValueError as e:
log.error_or_exception("Error: {}".format(e))
calibre_db.session.rollback()
flash(str(e), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
@ -882,14 +884,14 @@ def edit_book(book_id):
except Exception as ex:
log.error_or_exception(ex)
calibre_db.session.rollback()
flash(_("Error editing book, please check logfile for details"), category="error")
flash(_("Error editing book: {}".format(ex)), category="error")
return redirect(url_for('web.show_book', book_id=book.id))
def merge_metadata(to_save, meta):
if to_save['author_name'] == _(u'Unknown'):
if to_save.get('author_name', "") == _(u'Unknown'):
to_save['author_name'] = ''
if to_save['book_title'] == _(u'Unknown'):
if to_save.get('book_title', "") == _(u'Unknown'):
to_save['book_title'] = ''
for s_field, m_field in [
('tags', 'tags'), ('author_name', 'author'), ('series', 'series'),
@ -1117,7 +1119,7 @@ def upload():
if len(request.files.getlist("btn-upload")) < 2:
if current_user.role_edit() or current_user.role_admin():
resp = {"location": url_for('edit-book.edit_book', book_id=book_id)}
resp = {"location": url_for('edit-book.show_edit_book', book_id=book_id)}
return Response(json.dumps(resp), mimetype='application/json')
else:
resp = {"location": url_for('web.show_book', book_id=book_id)}
@ -1139,7 +1141,7 @@ def convert_bookformat(book_id):
if (book_format_from is None) or (book_format_to is None):
flash(_(u"Source or destination format for conversion missing"), category="error")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=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(),
@ -1151,7 +1153,7 @@ def convert_bookformat(book_id):
category="success")
else:
flash(_(u"There was an error converting this book: %(res)s", res=rtn), category="error")
return redirect(url_for('edit-book.edit_book', book_id=book_id))
return redirect(url_for('edit-book.show_edit_book', book_id=book_id))
@EditBook.route("/ajax/getcustomenum/<int:c_id>")
@ -1211,10 +1213,15 @@ def edit_list_book(param):
mimetype='application/json')
elif param == 'title':
sort_param = book.sort
handle_title_on_edit(book, vals.get('value', ""))
helper.update_dir_structure(book.id, config.config_calibre_dir)
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
mimetype='application/json')
if handle_title_on_edit(book, vals.get('value', "")):
rename_error = helper.update_dir_structure(book.id, config.config_calibre_dir)
if not rename_error:
ret = Response(json.dumps({'success': True, 'newValue': book.title}),
mimetype='application/json')
else:
ret = Response(json.dumps({'success': False,
'msg': rename_error}),
mimetype='application/json')
elif param == 'sort':
book.sort = vals['value']
ret = Response(json.dumps({'success': True, 'newValue': book.sort}),
@ -1225,11 +1232,17 @@ def edit_list_book(param):
mimetype='application/json')
elif param == 'authors':
input_authors, __, renamed = handle_author_on_edit(book, vals['value'], vals.get('checkA', None) == "true")
helper.update_dir_structure(book.id, config.config_calibre_dir, input_authors[0], renamed_author=renamed)
ret = Response(json.dumps({
'success': True,
'newValue': ' & '.join([author.replace('|', ',') for author in input_authors])}),
mimetype='application/json')
rename_error = helper.update_dir_structure(book.id, config.config_calibre_dir, input_authors[0],
renamed_author=renamed)
if not rename_error:
ret = Response(json.dumps({
'success': True,
'newValue': ' & '.join([author.replace('|', ',') for author in input_authors])}),
mimetype='application/json')
else:
ret = Response(json.dumps({'success': False,
'msg': rename_error}),
mimetype='application/json')
elif param == 'is_archived':
is_archived = change_archived_books(book.id, vals['value'] == "True",
message="Book {} archive bit set to: {}".format(book.id, vals['value']))
@ -1356,8 +1369,8 @@ def table_xchange_author_title():
author_names.append(authr.name.replace('|', ','))
title_change = handle_title_on_edit(book, " ".join(author_names))
input_authors, authorchange, renamed = handle_author_on_edit(book, authors)
if authorchange or title_change:
input_authors, author_change, renamed = handle_author_on_edit(book, authors)
if author_change or title_change:
edited_books_id = book.id
modify_date = True
@ -1365,8 +1378,9 @@ def table_xchange_author_title():
gdriveutils.updateGdriveCalibreFromLocal()
if edited_books_id:
helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0],
renamed_author=renamed)
# toDo: Handle error
edit_error = helper.update_dir_structure(edited_books_id, config.config_calibre_dir, input_authors[0],
renamed_author=renamed)
if modify_date:
book.last_modified = datetime.utcnow()
try:

View File

@ -327,8 +327,9 @@ def edit_book_read_status(book_id, read_status=None):
new_cc = cc_class(value=read_status or 1, book=book_id)
calibre_db.session.add(new_cc)
calibre_db.session.commit()
except (KeyError, AttributeError):
log.error(u"Custom Column No.%d is not existing in calibre database", config.config_read_column)
except (KeyError, AttributeError, IndexError):
log.error(
"Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
return "Custom Column No.{} is not existing in calibre database".format(config.config_read_column)
except (OperationalError, InvalidRequestError) as ex:
calibre_db.session.rollback()
@ -435,7 +436,8 @@ def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=
new_author_path = os.path.join(calibre_path, new_author_rename_dir)
shutil.move(os.path.normcase(old_author_path), os.path.normcase(new_author_path))
except OSError as ex:
log.error_or_exception("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.error("Rename author from: %s to %s: %s", old_author_path, new_author_path, ex)
log.debug(ex, exc_info=True)
return _("Rename author from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=old_author_path, dest=new_author_path, error=str(ex))
else:
@ -446,31 +448,31 @@ def rename_all_authors(first_author, renamed_author, calibre_path="", localbook=
# Moves files in file storage during author/title rename, or from temp dir to file storage
def update_dir_structure_file(book_id, calibre_path, first_author, original_filepath, db_filename, renamed_author):
# get book database entry from id, if original path overwrite source with original_filepath
localbook = calibre_db.get_book(book_id)
local_book = calibre_db.get_book(book_id)
if original_filepath:
path = original_filepath
else:
path = os.path.join(calibre_path, localbook.path)
path = os.path.join(calibre_path, local_book.path)
# Create (current) authordir and titledir from database
authordir = localbook.path.split('/')[0]
titledir = localbook.path.split('/')[1]
# Create (current) author_dir and title_dir from database
author_dir = local_book.path.split('/')[0]
title_dir = local_book.path.split('/')[1]
# Create new_authordir from parameter or from database
# Create new titledir from database and add id
new_authordir = rename_all_authors(first_author, renamed_author, calibre_path, localbook)
# Create new_author_dir from parameter or from database
# Create new title_dir from database and add id
new_author_dir = rename_all_authors(first_author, renamed_author, calibre_path, local_book)
if first_author:
if first_author.lower() in [r.lower() for r in renamed_author]:
if os.path.isdir(os.path.join(calibre_path, new_authordir)):
path = os.path.join(calibre_path, new_authordir, titledir)
if os.path.isdir(os.path.join(calibre_path, new_author_dir)):
path = os.path.join(calibre_path, new_author_dir, title_dir)
new_titledir = get_valid_filename(localbook.title, chars=96) + " (" + str(book_id) + ")"
new_title_dir = get_valid_filename(local_book.title, chars=96) + " (" + str(book_id) + ")"
if titledir != new_titledir or authordir != new_authordir or original_filepath:
if title_dir != new_title_dir or author_dir != new_author_dir or original_filepath:
error = move_files_on_change(calibre_path,
new_authordir,
new_titledir,
localbook,
new_author_dir,
new_title_dir,
local_book,
db_filename,
original_filepath,
path)
@ -478,7 +480,7 @@ def update_dir_structure_file(book_id, calibre_path, first_author, original_file
return error
# Rename all files from old names to new names
return rename_files_on_change(first_author, renamed_author, localbook, original_filepath, path, calibre_path)
return rename_files_on_change(first_author, renamed_author, local_book, original_filepath, path, calibre_path)
def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_dir, original_filepath, filename_ext):
@ -490,7 +492,7 @@ def upload_new_file_gdrive(book_id, first_author, renamed_author, title, title_d
title_dir + " (" + str(book_id) + ")")
book.path = gdrive_path.replace("\\", "/")
gd.uploadFileToEbooksFolder(os.path.join(gdrive_path, file_name).replace("\\", "/"), original_filepath)
return rename_files_on_change(first_author, renamed_author, localbook=book, gdrive=True)
return rename_files_on_change(first_author, renamed_author, local_book=book, gdrive=True)
def update_dir_structure_gdrive(book_id, first_author, renamed_author):
@ -549,7 +551,7 @@ def move_files_on_change(calibre_path, new_authordir, new_titledir, localbook, d
# change location in database to new author/title path
localbook.path = os.path.join(new_authordir, new_titledir).replace('\\', '/')
except OSError as ex:
log.error_or_exception("Rename title from: %s to %s: %s", path, new_path, ex)
log.error_or_exception("Rename title from {} to {} failed with error: {}".format(path, new_path, ex))
return _("Rename title from: '%(src)s' to '%(dest)s' failed with error: %(error)s",
src=path, dest=new_path, error=str(ex))
return False
@ -557,8 +559,8 @@ def move_files_on_change(calibre_path, new_authordir, new_titledir, localbook, d
def rename_files_on_change(first_author,
renamed_author,
localbook,
orignal_filepath="",
local_book,
original_filepath="",
path="",
calibre_path="",
gdrive=False):
@ -566,12 +568,12 @@ def rename_files_on_change(first_author,
try:
clean_author_database(renamed_author, calibre_path, gdrive=gdrive)
if first_author and first_author not in renamed_author:
clean_author_database([first_author], calibre_path, localbook, gdrive)
if not gdrive and not renamed_author and not orignal_filepath and len(os.listdir(os.path.dirname(path))) == 0:
clean_author_database([first_author], calibre_path, local_book, gdrive)
if not gdrive and not renamed_author and not original_filepath and len(os.listdir(os.path.dirname(path))) == 0:
shutil.rmtree(os.path.dirname(path))
except (OSError, FileNotFoundError) as ex:
log.error_or_exception("Error in rename file in path %s", ex)
return _("Error in rename file in path: %(error)s", error=str(ex))
log.error_or_exception("Error in rename file in path {}".format(ex))
return _("Error in rename file in path: {}".format(str(ex)))
return False

View File

@ -110,8 +110,8 @@ def get_readbooks_ids():
readBooks = calibre_db.session.query(db.cc_classes[config.config_read_column])\
.filter(db.cc_classes[config.config_read_column].value == True).all()
return frozenset([x.book for x in readBooks])
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
return []
# Returns the template for rendering and includes the instance name

View File

@ -295,7 +295,7 @@
{% if g.user.role_edit() %}
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group" aria-label="Edit/Delete book">
<a href="{{ url_for('edit-book.edit_book', book_id=entry.id) }}" class="btn btn-sm btn-primary" id="edit_book" role="button"><span class="glyphicon glyphicon-edit"></span> {{_('Edit Metadata')}}</a>
<a href="{{ url_for('edit-book.show_edit_book', book_id=entry.id) }}" class="btn btn-sm btn-primary" id="edit_book" role="button"><span class="glyphicon glyphicon-edit"></span> {{_('Edit Metadata')}}</a>
</div>
</div>
{% endif %}

View File

@ -67,6 +67,7 @@ logged_in = dict()
def signal_store_user_session(object, user):
store_user_session()
def store_user_session():
if flask_session.get('user_id', ""):
flask_session['_user_id'] = flask_session.get('user_id', "")
@ -85,15 +86,16 @@ def store_user_session():
else:
log.error("No user id in session")
def delete_user_session(user_id, session_key):
try:
log.debug("Deleted session_key: " + session_key)
session.query(User_Sessions).filter(User_Sessions.user_id==user_id,
User_Sessions.session_key==session_key).delete()
session.query(User_Sessions).filter(User_Sessions.user_id == user_id,
User_Sessions.session_key == session_key).delete()
session.commit()
except (exc.OperationalError, exc.InvalidRequestError) as e:
except (exc.OperationalError, exc.InvalidRequestError) as ex:
session.rollback()
log.exception(e)
log.exception(ex)
def check_user_session(user_id, session_key):
@ -209,9 +211,9 @@ class UserBase:
pass
try:
session.commit()
except (exc.OperationalError, exc.InvalidRequestError):
except (exc.OperationalError, exc.InvalidRequestError) as e:
session.rollback()
# ToDo: Error message
log.error_or_exception(e)
def __repr__(self):
return '<User %r>' % self.name

View File

@ -87,7 +87,7 @@ def add_security_headers(resp):
csp += ''.join([' ' + host for host in config.config_trustedhosts.strip().split(',')])
csp += " 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:; img-src 'self' data:"
resp.headers['Content-Security-Policy'] = csp
if request.endpoint == "edit-book.edit_book" or config.config_use_google_drive:
if request.endpoint == "edit-book.show_edit_book" or config.config_use_google_drive:
resp.headers['Content-Security-Policy'] += " *"
elif request.endpoint == "web.read_book":
resp.headers['Content-Security-Policy'] += " blob:;style-src-elem 'self' blob: 'unsafe-inline';"
@ -646,8 +646,8 @@ def render_read_books(page, are_read, as_xml=False, order=None):
db.Books.id == db.books_series_link.c.book,
db.Series,
db.cc_classes[config.config_read_column])
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
if not as_xml:
flash(_("Custom Column No.%(column)d is not existing in calibre database",
column=config.config_read_column),
@ -826,8 +826,9 @@ def list_books():
books = (calibre_db.session.query(db.Books, read_column.value, ub.ArchivedBook.is_archived)
.select_from(db.Books)
.outerjoin(read_column, read_column.book == db.Books.id))
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", read_column)
except (KeyError, AttributeError, IndexError):
log.error(
"Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
# Skip linking read column and return None instead of read status
books = calibre_db.session.query(db.Books, None, ub.ArchivedBook.is_archived)
books = (books.outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
@ -1139,8 +1140,9 @@ def adv_search_read_status(q, read_status):
else:
q = q.join(db.cc_classes[config.config_read_column], isouter=True) \
.filter(coalesce(db.cc_classes[config.config_read_column].value, False) != True)
except (KeyError, AttributeError):
log.error(u"Custom Column No.%d is not existing in calibre database", config.config_read_column)
except (KeyError, AttributeError, IndexError):
log.error(
"Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
flash(_("Custom Column No.%(column)d is not existing in calibre database",
column=config.config_read_column),
category="error")
@ -1262,8 +1264,8 @@ def render_adv_search_results(term, offset=None, order=None, limit=None):
query = (calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, read_column.value)
.select_from(db.Books)
.outerjoin(read_column, read_column.book == db.Books.id))
except (KeyError, AttributeError):
log.error("Custom Column No.%d is not existing in calibre database", config.config_read_column)
except (KeyError, AttributeError, IndexError):
log.error("Custom Column No.{} is not existing in calibre database".format(config.config_read_column))
# Skip linking read column
query = calibre_db.session.query(db.Books, ub.ArchivedBook.is_archived, None)
query = query.outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,

View File

@ -1,5 +1,5 @@
# GDrive Integration
google-api-python-client>=1.7.11,<2.41.0
google-api-python-client>=1.7.11,<2.42.0
gevent>20.6.0,<22.0.0
greenlet>=0.4.17,<1.2.0
httplib2>=0.9.2,<0.21.0
@ -13,7 +13,7 @@ rsa>=3.4.2,<4.9.0
# Gmail
google-auth-oauthlib>=0.4.3,<0.6.0
google-api-python-client>=1.7.11,<2.41.0
google-api-python-client>=1.7.11,<2.42.0
# goodreads
goodreads>=0.3.2,<0.4.0

View File

@ -37,20 +37,20 @@
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3" style="margin-top:50px;">
<p class='text-justify attribute'><strong>Start Time: </strong>2022-03-14 21:13:33</p>
<p class='text-justify attribute'><strong>Start Time: </strong>2022-03-19 22:04:05</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Stop Time: </strong>2022-03-15 02:05:20</p>
<p class='text-justify attribute'><strong>Stop Time: </strong>2022-03-20 02:50:09</p>
</div>
</div>
<div class="row">
<div class="col-xs-6 col-md-6 col-sm-offset-3">
<p class='text-justify attribute'><strong>Duration: </strong>4h 3 min</p>
<p class='text-justify attribute'><strong>Duration: </strong>3h 58 min</p>
</div>
</div>
</div>
@ -1589,8 +1589,8 @@
<tr id="su" class="failClass">
<td>TestEditBooksOnGdrive</td>
<td class="text-center">20</td>
<td class="text-center">17</td>
<td class="text-center">3</td>
<td class="text-center">16</td>
<td class="text-center">4</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">
@ -1735,11 +1735,31 @@
<tr id='pt16.16' class='hiddenRow bg-success'>
<tr id="ft16.16" class="none bg-danger">
<td>
<div class='testcase'>TestEditBooksOnGdrive - test_edit_title</div>
</td>
<td colspan='6' align='center'>PASS</td>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_ft16.16')">FAIL</a>
</div>
<!--css div popup start-->
<div id="div_ft16.16" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_ft16.16').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 145, in test_edit_title
self.assertTrue(self.check_element_on_page((By.ID, &#39;flash_success&#39;)))
AssertionError: False is not true</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
</tr>
@ -1761,7 +1781,7 @@
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 853, in test_upload_book_epub
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 855, in test_upload_book_epub
self.assertEqual(&#39;8936&#39;, resp.headers[&#39;Content-Length&#39;])
AssertionError: &#39;8936&#39; != &#39;1103&#39;
- 8936
@ -1801,7 +1821,7 @@ AssertionError: &#39;8936&#39; != &#39;1103&#39;
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 766, in test_upload_cover_hdd
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 768, in test_upload_cover_hdd
self.assertGreater(diff(&#39;original.png&#39;, &#39;jpeg.png&#39;, delete_diff_file=True), 0.02)
AssertionError: 0.0 not greater than 0.02</pre>
</div>
@ -1830,9 +1850,9 @@ AssertionError: 0.0 not greater than 0.02</pre>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 935, in test_watch_metadata
File &#34;/home/ozzie/Development/calibre-web-test/test/test_edit_ebooks_gdrive.py&#34;, line 937, in test_watch_metadata
self.assertNotIn(&#39;series&#39;, book)
AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;reader&#39;: [], &#39;title&#39;: &#39;testbook&#39;, &#39;author&#39;: [&#39;John Döe&#39;], &#39;rating&#39;: 0, &#39;languages&#39;: [&#39;English&#39;], &#39;identifier&#39;: [], &#39;cover&#39;: &#39;/cover/5?edit=2e081d1c-86d2-461f-a309-e51e1e378161&#39;, &#39;tag&#39;: [], &#39;publisher&#39;: [&#39;Randomhäus&#39;], &#39;pubdate&#39;: &#39;Jan 19, 2017&#39;, &#39;comment&#39;: &#39;Lorem ipsum dolor sit amet, consectetuer adipiscing elit.Aenean commodo ligula eget dolor.Aenean massa.Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem.Nulla consequat massa quis enim.Donec pede justo, fringilla vel, aliquet nec, vulputate&#39;, &#39;add_shelf&#39;: [], &#39;del_shelf&#39;: [], &#39;edit_enable&#39;: True, &#39;kindle&#39;: None, &#39;kindlebtn&#39;: None, &#39;download&#39;: [&#39;EPUB (6.7 kB)&#39;], &#39;read&#39;: False, &#39;archived&#39;: False, &#39;series_all&#39;: &#39;Book 1 of test&#39;, &#39;series_index&#39;: &#39;1&#39;, &#39;series&#39;: &#39;test&#39;, &#39;cust_columns&#39;: []}</pre>
AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;reader&#39;: [], &#39;title&#39;: &#39;testbook&#39;, &#39;author&#39;: [&#39;John Döe&#39;], &#39;rating&#39;: 0, &#39;languages&#39;: [&#39;English&#39;], &#39;identifier&#39;: [], &#39;cover&#39;: &#39;/cover/5?edit=34e51cc2-2413-4a23-8324-26d568a421ba&#39;, &#39;tag&#39;: [], &#39;publisher&#39;: [&#39;Randomhäus&#39;], &#39;pubdate&#39;: &#39;Jan 19, 2017&#39;, &#39;comment&#39;: &#39;Lorem ipsum dolor sit amet, consectetuer adipiscing elit.Aenean commodo ligula eget dolor.Aenean massa.Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem.Nulla consequat massa quis enim.Donec pede justo, fringilla vel, aliquet nec, vulputate&#39;, &#39;add_shelf&#39;: [], &#39;del_shelf&#39;: [], &#39;edit_enable&#39;: True, &#39;kindle&#39;: None, &#39;kindlebtn&#39;: None, &#39;download&#39;: [&#39;EPUB (6.7 kB)&#39;], &#39;read&#39;: False, &#39;archived&#39;: False, &#39;series_all&#39;: &#39;Book 1 of test&#39;, &#39;series_index&#39;: &#39;1&#39;, &#39;series&#39;: &#39;test&#39;, &#39;cust_columns&#39;: []}</pre>
</div>
<div class="clearfix"></div>
</div>
@ -2825,34 +2845,65 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr id="su" class="passClass">
<td>TestMergeBooksList</td>
<td class="text-center">2</td>
<td class="text-center">2</td>
<tr id="su" class="errorClass">
<td>_ErrorHolder</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">0</td>
<td class="text-center">1</td>
<td class="text-center">0</td>
<td class="text-center">
<a onclick="showClassDetail('c31', 2)">Detail</a>
<a onclick="showClassDetail('c31', 1)">Detail</a>
</td>
</tr>
<tr id='pt31.1' class='hiddenRow bg-success'>
<tr id="et31.1" class="none bg-info">
<td>
<div class='testcase'>TestMergeBooksList - test_book_merge</div>
<div class='testcase'>setUpClass (test_merge_books_list)</div>
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
<tr id='pt31.2' class='hiddenRow bg-success'>
<td>
<div class='testcase'>TestMergeBooksList - test_delete_book</div>
<td colspan='6'>
<div class="text-center">
<a class="popup_link text-center" onfocus='blur()' onclick="showTestDetail('div_et31.1')">ERROR</a>
</div>
<!--css div popup start-->
<div id="div_et31.1" class="popup_window test_output" style="display:block;">
<div class='close_button pull-right'>
<button type="button" class="close" aria-label="Close" onfocus="this.blur();"
onclick="document.getElementById('div_et31.1').style.display='none'"><span
aria-hidden="true">&times;</span></button>
</div>
<div class="text-left pull-left">
<pre class="text-left">Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_merge_books_list.py&#34;, line 24, in setUpClass
startup(cls, cls.py_version, {&#39;config_calibre_dir&#39;: TEST_DB})
File &#34;/home/ozzie/Development/calibre-web-test/test/helper_func.py&#34;, line 175, in startup
inst.driver = webdriver.Firefox()
File &#34;/home/ozzie/Development/calibre-web-test/venv/lib/python3.8/site-packages/selenium/webdriver/firefox/webdriver.py&#34;, line 178, in __init__
RemoteWebDriver.__init__(
File &#34;/home/ozzie/Development/calibre-web-test/venv/lib/python3.8/site-packages/selenium/webdriver/remote/webdriver.py&#34;, line 269, in __init__
self.start_session(capabilities, browser_profile)
File &#34;/home/ozzie/Development/calibre-web-test/venv/lib/python3.8/site-packages/selenium/webdriver/remote/webdriver.py&#34;, line 360, in start_session
response = self.execute(Command.NEW_SESSION, parameters)
File &#34;/home/ozzie/Development/calibre-web-test/venv/lib/python3.8/site-packages/selenium/webdriver/remote/webdriver.py&#34;, line 425, in execute
self.error_handler.check_response(response)
File &#34;/home/ozzie/Development/calibre-web-test/venv/lib/python3.8/site-packages/selenium/webdriver/remote/errorhandler.py&#34;, line 247, in check_response
raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.WebDriverException: Message: Process unexpectedly closed with status 0
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File &#34;/home/ozzie/Development/calibre-web-test/test/test_merge_books_list.py&#34;, line 28, in setUpClass
cls.driver.quit()
AttributeError: &#39;NoneType&#39; object has no attribute &#39;quit&#39;</pre>
</div>
<div class="clearfix"></div>
</div>
<!--css div popup end-->
</td>
<td colspan='6' align='center'>PASS</td>
</tr>
@ -4600,10 +4651,10 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr id='total_row' class="text-center bg-grey">
<td>Total</td>
<td>405</td>
<td>396</td>
<td>3</td>
<td>0</td>
<td>404</td>
<td>393</td>
<td>4</td>
<td>1</td>
<td>6</td>
<td>&nbsp;</td>
</tr>
@ -4776,7 +4827,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestCliGdrivedb</td>
</tr>
@ -4806,7 +4857,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestEbookConvertCalibreGDrive</td>
</tr>
@ -4836,7 +4887,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestEbookConvertGDriveKepubify</td>
</tr>
@ -4878,7 +4929,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestEditAuthorsGdrive</td>
</tr>
@ -4914,7 +4965,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestEditBooksOnGdrive</td>
</tr>
@ -4956,7 +5007,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
<tr>
<th>google-api-python-client</th>
<td>2.40.0</td>
<td>2.41.0</td>
<td>TestSetupGdrive</td>
</tr>
@ -5046,7 +5097,7 @@ AssertionError: &#39;series&#39; unexpectedly found in {&#39;id&#39;: 5, &#39;re
</div>
<script>
drawCircle(396, 3, 0, 6);
drawCircle(393, 4, 1, 6);
showCase(5);
</script>