Coverage for app / routes.py: 80%
265 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-06 04:49 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-06 04:49 +0000
1"""
2This module defines the primary routes for the Flask application, providing endpoints
3for rendering templates, searching, managing books, downloading data, and offering
4application metadata. The routes include role-based access control, validation of
5user inputs, and interaction with both the database and external services as required.
6"""
7import csv
8import io
10from flask import (current_app as app, render_template, request, jsonify,
11 flash, redirect, url_for, make_response, Request, Response)
12from flask_security import roles_required, current_user, auth_required
14from app.forms import BookForm
15from app.helpers import PLACEHOLDER, build_library_search_urls, compute_next_url
16from app.limiter import limiter
17from app.models import Tag, Book
18from app.services import (fetch_product_details, build_about_info, search_by_categories, get_book_by_id,
19 search_by_author, get_tags_for_user, get_or_create_tag,
20 tag_book, find_tag_for_user, get_tags_and_colors, remove_tag_from_book,
21 get_tags_for_user_with_colors, search_by_title, add_new_book,
22 book_to_dict_with_status_and_feedback, set_book_status, set_book_feedback,
23 update_book, del_book, get_category_bs_tree, id_to_fullpath)
26@app.route('/')
27def index():
28 """
29 Handles the root endpoint and renders the main page of the application. This
30 function establishes the setup required for the app's main page by retrieving
31 the binary search tree structure for the application's categories and
32 passing it to the HTML template for rendering.
34 :raises TemplateNotFound: If the specified template does not exist or cannot
35 be loaded.
37 :return: Response object containing the rendered HTML content for the main
38 page, including the category binary search tree data.
39 :rtype: werkzeug.wrappers.Response
40 """
41 category_bs_tree = get_category_bs_tree()
42 return render_template('index.html', category_bs_tree=category_bs_tree)
45@app.route('/about')
46@roles_required('admin')
47@limiter.limit("1 per second")
48def about():
49 """
50 Handles the rendering of the 'About' page for administrators.
52 This function is accessed via the '/about' route and is restricted to users
53 with the 'admin' role. It gathers the necessary information for the 'About'
54 page and passes it to the template for rendering.
56 :parameters: None
58 :raises: None
60 :returns: Rendered HTML template of the 'About' page including the application
61 information.
62 """
63 about_info = build_about_info()
65 return render_template('about.html', about_info=about_info)
68@app.route('/search', methods=['GET'])
69def search():
70 """
71 Search for books. Author, title, or cat must be specified.
73 :return:
74 """
75 bks = _perform_search_base_on_args(request)
77 if bks is None:
78 # author, title, or cat must be specified
79 return jsonify({"error": "bad search input"}), 400
81 return render_template('results.html', books=bks, placeholder=PLACEHOLDER)
84@app.route('/details', methods=['GET'])
85def details():
86 """
87 The route '/details' allows fetching book
88 information in JSON format by providing the book ID via the query
89 parameter `id`. For logged on users, the feedback and reading status
90 of the book are also returned.
91 """
92 error, status, book = _check_for_required_book(request)
93 if error:
94 return error, status
96 user_id = current_user.id if current_user.is_authenticated else None
97 book_dict = book_to_dict_with_status_and_feedback(book, user_id)
98 return jsonify(book_dict)
101@app.route("/library_searches", methods=['GET'])
102def library_searches():
103 """
104 Handles library search requests by constructing and returning URLs for
105 searching library databases. The endpoint requires both 'author' and
106 'title' query parameters to be provided in the request.
108 :return: A JSON response with constructed library search URLs or an error
109 message if required query parameters are missing.
110 :rtype: flask.Response
112 :raises ValueError: Raised with a 400 HTTP status if either 'author' or 'title' query
113 parameters are missing from the request args.
114 """
115 # Extract 'author' and 'title' query parameters
116 author = request.args.get('author')
117 title = request.args.get('title')
119 # Validate that both parameters are provided
120 if not author or not title:
121 return jsonify({"error": "Both 'author' and 'title' query parameters are required."}), 400
123 return jsonify(build_library_search_urls(author, title))
126@app.route('/download')
127def download():
128 """
129 Handles the /download endpoint to perform a search operation and return results
130 in CSV format based on provided query parameters. If no valid query parameters
131 are provided or if the search yields no results, then responds with an error message.
133 :raises HTTPException: Responds with status code 400 if input arguments are
134 missing or result in no search results.
136 :return: A Flask `Response` object containing the data in CSV format if the
137 query parameters are valid and yield results, otherwise, a JSON error
138 message with the appropriate error status code.
139 """
140 # Perform search based on input args
141 bks = _perform_search_base_on_args(request)
143 # input arguments did not result in search
144 if bks is None:
145 # author, title, or cat must be specified
146 return jsonify({"error": "Bad search input parameter"}), 400
148 response = _make_csv_response(bks)
150 return response
153@app.route('/add_book', methods=["GET", "POST"])
154@roles_required('editor')
155@limiter.limit("1 per second")
156def add_book():
157 """
158 Handles the addition of a new book to the database. This function provides an interface
159 for editors to add a book using a web form, processes the form, validates the submission,
160 and performs the required operations to register the new book. Feedback is returned to the
161 user depending on whether the operation succeeds or fails.
163 The function operates in both GET and POST modes, rendering the submission form in GET
164 operations and processing form data during POST operations.
166 If the next field of the form is not set, the next URL is determined using the request's
167 referrer. In case of a valid submission, it adds a new book to the database and redirects
168 the user. If it fails, an appropriate error message is flashed.
170 :raises Exception:
171 If an unanticipated error occurs during the addition of the new book or database operation.
172 :return:
173 A rendered template containing the add-book form upon GET operation or the corresponding
174 redirect statement upon successful form submission in POST operations.
175 """
176 form = BookForm(data=(request.form if request.method == "POST" else request.args))
178 # If next not set, use the referrer if we have one, for where to go when done
179 if not form.next.data:
180 form.next.data = compute_next_url(request)
182 if form.validate_on_submit(): # Checks if the form is submitted and valid
183 try:
184 book = add_new_book(form) # Attempt to add the book
185 flash(f"Book id:{book.id} title:'{book.title}' added successfully!", "success")
186 return redirect(form.next.data if form.next.data else url_for("index"))
187 except Exception as e: # pylint: disable=broad-except
188 flash(f"An error occurred while adding the book: {e}", "danger")
189 return render_template("add_book.html", book_form=form)
192@app.route('/edit_book', methods=["GET", "POST"])
193@roles_required('editor')
194@limiter.limit("1 per second")
195def edit_book():
196 """
197 Handles the functionality for editing a book. This function is accessible only
198 to users with the 'editor' role and supports both GET and POST HTTP methods.
200 The function performs several crucial operations:
201 1. Fetches and prepares the book editing form depending on the request method.
202 2. Validates the 'id' form data to ensure it is provided and is in the correct
203 format.
204 3. If invoked via a POST request, it validates the form data, attempts to update
205 the book, and handles potential exceptions.
206 4. If invoked via a GET request, it retrieves the book from the database and
207 populates the form using the book data.
208 5. Displays appropriate messages or redirects users based on the success or
209 failure of the operation.
211 :param: None
212 :raises: Ensures any system exceptions are caught and appropriately managed
213 (e.g., database-related issues during book retrieval or update). The
214 function does not re-raise exceptions but instead uses user-visible
215 messages.
216 :return: A rendered HTML page for editing a book in case of GET requests, or a
217 redirection upon successful or failed form submission in POST requests.
218 """
219 form = BookForm(data=(request.form if request.method == "POST" else request.args))
221 # If next not set, use the referrer if we have one, for where to go when done
222 if not form.next.data:
223 form.next.data = compute_next_url(request)
225 if not form.id.data or not str(form.id.data).isdigit(): # Validate 'book_id'
226 return jsonify({"error": "Invalid or missing 'id' parameter"}), 400
228 if request.method == "POST": # Check if the request method is POST
229 if form.validate_on_submit(): # Checks if the form is submitted and valid 229 ↛ 251line 229 didn't jump to line 251 because the condition on line 229 was always true
230 try:
231 book = update_book(form) # Attempt to update the book
232 flash(f"Book id:{book.id} title:'{book.title}' updated successfully!", "success")
233 # on a successful update, go to next
234 return redirect(form.next.data if form.next.data else url_for("index"))
235 except Exception as e: # pylint: disable=broad-except
236 flash(f"An error occurred while updating the book: {e}", "danger")
237 else:
238 # fill in the book from the database
239 try:
240 book = get_book_by_id(form.id.data)
241 if not book:
242 flash(f"Book with ID {form.id.data} not found.", "warning")
243 return redirect(form.next.data if form.next.data else url_for("index"))
244 except Exception as e: # pylint: disable=broad-except
245 flash(f"Failed to get the book with ID {form.id.data}: {e}", "danger")
246 return redirect(form.next.data if form.next.data else url_for("index"))
248 if book: 248 ↛ 251line 248 didn't jump to line 251 because the condition on line 248 was always true
249 form.fill_from_book(book)
250 # return to or show the initial edit form
251 return render_template("edit_book.html", book_form=form)
254@app.route('/fill_by_asin', methods=["GET"])
255@roles_required('editor')
256@limiter.limit("1 per second")
257def fill_by_asin():
258 """
259 Handles a GET request to fetch and return product details by ASIN (Amazon Standard
260 Identification Number). This function ensures that the authenticated user has the
261 required 'editor' role to access this endpoint. It retrieves the 'asin' parameter
262 from the request query string, validates its presence, and fetches the corresponding
263 product details using the `fetch_product_details` function.
265 If the 'asin' parameter is missing or cannot be found, appropriate error responses
266 are returned to the client.
268 :return: A JSON response containing either the product details fetched using the
269 ASIN or an error message. If successful, the response contains the product
270 details; otherwise, an error message indicating the issue (e.g., missing ASIN
271 or not found) is returned.
272 :rtype: flask.Response
274 :raises flask.exceptions.BadRequest: If the 'asin' parameter is missing in the request.
275 :raises flask.exceptions.NotFound: If the provided ASIN is not found in the database
276 or external service.
277 """
278 asin = request.args.get('asin')
279 if not asin:
280 return jsonify({"error": "Missing asin parameter"}), 400, None
281 # Validate ASIN format: must be exactly 10 alphanumeric characters
282 if len(asin) != 10 or not asin.isalnum():
283 return jsonify({"error": "Invalid asin parameter; must be 10 letters or digits"}), 400, None
285 book_data = fetch_product_details(asin)
286 if not book_data: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 return jsonify({"error": f"ASIN {asin} not found"}), 404, None
288 return jsonify(book_data)
291@app.route('/delete_book', methods=["POST"])
292@roles_required('admin')
293@limiter.limit("1 per second")
294def delete_book():
295 """
296 Deletes a book with a given identifier. This function ensures that necessary
297 validation checks are performed on the provided book identifier and calls the
298 appropriate deletion routine.
300 The endpoint is protected and accessible only to users with the 'admin' role.
301 It handles both success scenarios where the deletion is performed successfully
302 and failure scenarios involving missing parameters or unexpected issues.
304 :raises Exception: On encountering any unhandled issue during book deletion
306 :return: A JSON response indicating the success of the operation, or an error
307 message and HTTP status code in the event of a failure.
308 """
309 # Validate required parameters
310 book_id = request.form.get('book_id')
312 if not book_id or not book_id.isdigit():
313 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400
315 try:
316 del_book(book_id)
318 flash(f"Book id:{book_id} deleted successfully!", "success")
319 return jsonify({"message": f"Book id:{book_id} deleted successfully!"}), 200
320 except Exception as e: # pylint: disable=broad-except
321 flash(f"Unhandled exception: {str(e)}")
322 return jsonify({"error": "An unexpected error occurred"}), 500
325@app.route('/change_status', methods=["POST"])
326@auth_required()
327@limiter.limit("1 per second")
328def change_status():
329 """
330 Handles updating the status of a book for the authenticated user. This endpoint
331 validates the input parameters for correctness, ensures that the status is among
332 the allowed values, and updates the status of the specified book in the user's
333 profile. It then returns the new status as a response. Only authenticated
334 users are allowed to access this route.
336 :raises ValueError: If the `book_id` parameter is missing or not a valid digit.
337 :raises ValueError: If the `status` parameter is missing.
338 :raises ValueError: If the `status` value is not within the allowed statuses.
340 :return: A JSON object containing the updated book status.
341 :rtype: Response
342 """
343 # Validate required parameters
344 book_id = request.form.get('book_id')
345 status = request.form.get('status')
347 if not book_id or not book_id.isdigit():
348 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400
350 if not status:
351 return jsonify({"error": "Missing 'status' parameter"}), 400
353 # Validate if status is supported
354 allowed_statuses = ['read', 'up_next', 'none']
355 if status not in allowed_statuses:
356 allowed = ', '.join(allowed_statuses)
357 return jsonify(
358 {"error": f"Invalid 'status' value. Allowed values: {allowed}"}), 400
360 user_id = current_user.id
361 set_book_status(book_id, status, user_id)
362 return jsonify({'new_status': status}), 200
365@app.route('/change_feedback', methods=["POST"])
366@auth_required()
367@limiter.limit("1 per second")
368def change_feedback():
369 """
370 Handles the change of feedback for a specific book. This function validates the
371 input parameters, including `book_id` and `feedback`, ensures that the feedback
372 value is within the allowed list of feedback states, and then updates the feedback
373 for the given user and book in the system.
375 :return: JSON object containing the updated feedback, or an error message in case
376 of invalid input and the corresponding HTTP status code.
377 :rtype: flask.Response
378 """
379 # Validate required parameters
380 book_id = request.form.get('book_id')
381 fb = request.form.get('feedback')
383 if not book_id or not book_id.isdigit():
384 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400
386 if not fb:
387 return jsonify({"error": "Missing 'feedback' parameter"}), 400
389 # Validate if feedback is supported
390 allowed_feedback = ['like', 'dislike', 'none']
391 if fb not in allowed_feedback:
392 return jsonify({
393 "error":
394 f"Invalid 'feedback' value. Allowed values: {', '.join(allowed_feedback)}"}), 400
396 user_id = current_user.id
397 set_book_feedback(book_id, fb, user_id)
398 return jsonify({'new_feedback': fb}), 200
401@app.route('/autocomplete_tags')
402@auth_required()
403@limiter.exempt
404def autocomplete_tags():
405 """
406 Provides an endpoint to fetch tag suggestions based on a query for the
407 authenticated user. The response contains matching tags and their associated
408 colors.
409 """
410 q = request.args.get('q', '').lower()
411 suggestions = get_tags_for_user(current_user.id, q)
412 tags_and_colors = [{'name': s.name, 'color': s.color} for s in suggestions]
413 return jsonify(tags_and_colors)
416@app.route('/add_tag', methods=['POST'])
417@auth_required()
418@limiter.limit("1 per second")
419def add_tag():
420 """
421 Handles the addition of a tag to a book for the current user. The function expects
422 a JSON payload containing the tag name and the corresponding book ID. The user must
423 be authenticated to access this endpoint. If the specified tag or book ID is invalid
424 or missing, the function returns an error response. Otherwise, it ensures that the tag
425 exists for the user, associates it with the given book, and returns the updated list
426 of tags associated with the book.
427 """
428 try:
429 tag, book, error, status = _check_for_required_tag_and_book(request, tag_create=True)
430 if error:
431 return error, status
433 new_set_of_tags = tag_book(book_id=book.id, tag_id=tag.id, user_id=current_user.id)
434 return jsonify({'success': True, 'tags': new_set_of_tags})
435 except ValueError as e:
436 return jsonify({"error": str(e)}), 400
439@app.route('/get_tags', methods=['GET'])
440@auth_required()
441def get_tags():
442 """
443 Handles the GET request to retrieve tags and their associated colors for a specific book.
445 This function ensures the necessary book data is present and authenticated
446 before returning the requested tags and colors. If no valid book is found,
447 an error response is returned.
448 """
449 error, status, book = _check_for_required_book(request)
450 if error:
451 return error, status
453 return jsonify({'success': True,
454 'tags': get_tags_and_colors(book_id=book.id, user_id=current_user.id)})
457@app.route('/get_user_tags', methods=['GET'])
458@auth_required()
459def get_user_tags():
460 """
461 Handles the GET request to retrieve tags and their associated colors for a user.
462 """
463 return jsonify({'success': True,
464 'tags': get_tags_for_user_with_colors(user_id=current_user.id)})
467@app.route('/remove_tag', methods=['POST'])
468@auth_required()
469@limiter.limit("1 per second")
470def remove_tag():
471 """
472 Handles the removal of a tag associated with a book for an authenticated user.
474 This function receives a JSON payload from an HTTP POST request, extracts
475 the tag name and book ID, and performs the following operations:
476 - Validates the presence and correctness of the input parameters.
477 - Checks the existence of the book with the given ID in the system.
478 - Verifies that the specified tag is associated with the authenticated user.
479 - If the tag exists and is associated with the user, removes the tag from
480 the book and returns the updated set of tags for the book.
482 If a validation or existence check fails, an appropriate error response is
483 returned.
485 :return: A JSON response indicating the success or failure of the operation.
486 :rtype: flask.Response
487 :raises ValueError: If the `book_id` is not a valid integer.
488 :raises KeyError: If `tag` or `book_id` is missing in the request payload.
489 """
490 tag, book, error, status = _check_for_required_tag_and_book(request)
491 if error:
492 return error, status
494 user_id = current_user.id
495 if not tag:
496 # if the tag doesn't exist, then it can't be assigned to a book
497 # just get the current set of tags and return them
498 return (
499 jsonify({'success': True,
500 'tags': get_tags_and_colors(book_id=book.id, user_id=user_id)}))
501 # perform the removal and return the new set of tags
502 new_set_of_tags = remove_tag_from_book(tag_id=tag.id, book_id=book.id, user_id=user_id)
503 return jsonify({'success': True, 'tags': new_set_of_tags})
506@app.route('/csp-report', methods=['POST'])
507@limiter.limit("10 per second")
508def csp_report():
509 """
510 Handles Content Security Policy (CSP) violation reports sent via POST requests.
512 This function acts as the endpoint for handling and logging CSP violation
513 reports. When a violation is detected by the browser, it sends a report to
514 this endpoint. The function retrieves the report in JSON format from the
515 request body and logs it using the application's logging framework. It
516 returns an HTTP 204 response indicating successful handling of the report.
518 :returns: An empty response body with HTTP status code 204 to indicate no
519 content is returned.
520 """
521 report = request.get_json(force=True)
522 # Log the report to your preferred logging system
523 app.logger.warning(f"CSP Violation: {report}")
524 return '', 204 # No content response
527def _check_for_required_tag_and_book(req, tag_create=False) -> (Tag, Book, Response, int):
528 tag_name = req.json.get('tag')
529 book_id = req.json.get('book_id')
531 if not tag_name or not book_id:
532 return None, None, jsonify({"error": "Missing or invalid parameters"}), 400
533 # check that book exists
534 book = get_book_by_id(book_id)
535 if not book:
536 return None, None, jsonify({"error": f"Book {book_id} not found"}), 400
537 # check if tag exists for user
538 tag_name = tag_name.lower()
539 if tag_create:
540 tag = get_or_create_tag(user_id=current_user.id, tag_name=tag_name)
541 else:
542 tag = find_tag_for_user(user_id=current_user.id, tag_name=tag_name)
544 return tag, book, None, 200
547def _check_for_required_book(req):
548 """
549 Validates the presence and format of the 'id' parameter in the request and retrieves the
550 associated book object. If the 'id' is missing or invalid, or if the book does not exist,
551 an appropriate error response is returned.
553 :param req: The Flask request object containing query parameters.
554 Expects the 'id' parameter in the request arguments.
555 :type req: flask.Request
556 :return: A tuple containing:
557 - An error response (if applicable) or None,
558 - The corresponding HTTP status code,
559 - The retrieved book object or None.
560 :rtype: tuple
561 """
562 book_id = req.args.get('id')
563 if not book_id or not book_id.isdigit():
564 return jsonify({"error": "Invalid or missing 'id' parameter"}), 400, None
566 book = get_book_by_id(book_id,
567 current_user.id if current_user.is_authenticated else None,
568 load_status=True, load_feedback=True)
569 if not book:
570 return jsonify({"error": f"Book {book_id} not found"}), 404, None
572 return None, 200, book
575def _perform_search_base_on_args(req: Request):
576 """
577 Performs a search for books based on arguments provided in the request object. The search
578 can be filtered by author, title, or categories, with additional options such as status and
579 feedback filters. The result set can also be sorted based on the provided sort criteria.
581 :param req: A Request object containing search arguments and options. The following arguments
582 are handled:
583 - author (str): Author's name to filter books by.
584 - title (str): Book title to filter books by.
585 - cat (list[str]): List of category IDs to filter books by (decoded into full path
586 strings).
587 - status (Optional[str]): Status filter for books (e.g., available, checked-out).
588 - feedback (Optional[str]): Feedback filter for books (e.g., positive, negative).
589 - sortColumn (Optional[str]): Column to sort books by; valid values are 'title',
590 'author', or 'rating'.
591 - sortOrder (Optional[str]): Order to sort books; valid values are 'asc' (ascending) or
592 'desc' (descending).
594 :return:
595 - A sorted list of books that match the search criteria if valid sorting and filtering
596 options are provided.
597 - None if no search criteria are provided or if invalid filter/sort options are supplied.
598 """
599 author = req.args.get('author')
600 title = req.args.get('title')
601 categories = req.args.getlist('cat')
603 status_filter = req.args.get('status', None)
604 feedback_filter = req.args.get('feedback', None)
606 tag_filter = req.args.getlist('tag')
607 # make sure all lower case
608 tag_filter = [tag.lower() for tag in tag_filter]
610 sort_column = req.args.get('sortColumn', None)
611 sort_order = req.args.get('sortOrder', 'asc')
613 if author:
614 bks = search_by_author(author, status_filter, feedback_filter, tag_filter)
615 elif title:
616 bks = search_by_title(title, status_filter, feedback_filter, tag_filter)
617 elif categories:
618 # Decode encoded categories to full path strings before searching
619 bks = search_by_categories(
620 [id_to_fullpath(category) for category in categories], status_filter,
621 feedback_filter, tag_filter)
622 else:
623 return None
625 # if sort criteria were supplied, then apply them
626 if sort_column:
627 # Verify sort criteria. Returning None to our caller will return 400 to the browser
628 if sort_column not in ['title', 'author', 'rating']:
629 return None # invalid input
630 if sort_order not in ['asc', 'desc']:
631 return None # invalid input
632 # sort the books
633 reverse_order = sort_order == 'desc'
634 bks = sorted(bks, key=lambda bk: getattr(bk, sort_column, ''), reverse=reverse_order)
636 return bks
639def _make_csv_response(bks):
640 """
641 Generates a CSV response from a list of books.
643 This function takes a list of book objects and creates a CSV file in-memory.
644 The CSV file includes predefined columns for book attributes. It then creates
645 an HTTP response containing the CSV content, suitable for file download. Missing
646 data fields are replaced with default values such as 'none'.
648 :param bks: A list of book objects to be written into the CSV.
649 :type bks: list
650 :return: Flask response containing the generated CSV data.
651 :rtype: flask.Response
652 """
653 # Create an in-memory buffer
654 output = io.StringIO()
655 # Use csv.writer to write CSV data into the buffer
656 writer = csv.writer(output)
657 # Write header row
658 writer.writerow([
659 "Id",
660 "Title",
661 "Author",
662 "Rating",
663 "Description",
664 "Feedback",
665 "Pages",
666 "Categories",
667 "Booksellers_Rank",
668 "ASIN",
669 "ISBN-10",
670 "ISBN-13",
671 "Amazon_Link",
672 "Cover_Image",
673 "Status",
674 "Tags",
675 "Specifications"
676 ])
677 for bk in bks:
678 tags = bk.tags
679 if tags: 679 ↛ 680line 679 didn't jump to line 680 because the condition on line 679 was never true
680 tag_str = ', '.join(map(lambda t: t.tag.name.lower().replace(' ', '-'), tags))
681 else:
682 tag_str = ''
683 writer.writerow([
684 bk.id,
685 _safe_string(bk.title),
686 _safe_string(bk.author),
687 bk.rating,
688 _safe_string(bk.book_description),
689 bk.feedbacks[0].feedback.value if bk.feedbacks else 'none',
690 bk.hardcover,
691 bk.categories_flat,
692 bk.bestsellers_rank_flat,
693 bk.asin,
694 bk.isbn_10,
695 bk.isbn_13,
696 bk.link,
697 bk.image,
698 bk.reading_statuses[0].status.value if bk.reading_statuses else 'none',
699 tag_str,
700 bk.specifications_flat
701 ])
702 # Get the CSV content from the buffer
703 csv_content = output.getvalue()
704 output.close()
705 # Create a response object
706 response = make_response(csv_content)
707 # Set headers for file download
708 response.headers["Content-Disposition"] = "attachment; filename=booklist.csv"
709 response.mimetype = "text/csv"
710 return response
713def _safe_string(in_str: str) -> str:
714 """
715 Replace all 0xA0 (non-breaking space) characters in the input string with 0x20 (regular space).
717 :param in_str: The input string to process.
718 :type in_str: str
719 :return: A new string with all non-breaking spaces replaced by regular spaces.
720 :rtype: str
721 """
722 if not in_str:
723 return ''
724 return in_str.replace('\xa0', ' ')