Coverage for app / routes.py: 80%

265 statements  

« 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 

9 

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 

13 

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) 

24 

25 

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. 

33 

34 :raises TemplateNotFound: If the specified template does not exist or cannot 

35 be loaded. 

36 

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) 

43 

44 

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. 

51 

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. 

55 

56 :parameters: None 

57 

58 :raises: None 

59 

60 :returns: Rendered HTML template of the 'About' page including the application 

61 information. 

62 """ 

63 about_info = build_about_info() 

64 

65 return render_template('about.html', about_info=about_info) 

66 

67 

68@app.route('/search', methods=['GET']) 

69def search(): 

70 """ 

71 Search for books. Author, title, or cat must be specified. 

72 

73 :return: 

74 """ 

75 bks = _perform_search_base_on_args(request) 

76 

77 if bks is None: 

78 # author, title, or cat must be specified 

79 return jsonify({"error": "bad search input"}), 400 

80 

81 return render_template('results.html', books=bks, placeholder=PLACEHOLDER) 

82 

83 

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 

95 

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) 

99 

100 

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. 

107 

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 

111 

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') 

118 

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 

122 

123 return jsonify(build_library_search_urls(author, title)) 

124 

125 

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. 

132 

133 :raises HTTPException: Responds with status code 400 if input arguments are 

134 missing or result in no search results. 

135 

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) 

142 

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 

147 

148 response = _make_csv_response(bks) 

149 

150 return response 

151 

152 

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. 

162 

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. 

165 

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. 

169 

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)) 

177 

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) 

181 

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) 

190 

191 

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. 

199 

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. 

210 

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)) 

220 

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) 

224 

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 

227 

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")) 

247 

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) 

252 

253 

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. 

264 

265 If the 'asin' parameter is missing or cannot be found, appropriate error responses 

266 are returned to the client. 

267 

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 

273 

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 

284 

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) 

289 

290 

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. 

299 

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. 

303 

304 :raises Exception: On encountering any unhandled issue during book deletion 

305 

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') 

311 

312 if not book_id or not book_id.isdigit(): 

313 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400 

314 

315 try: 

316 del_book(book_id) 

317 

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 

323 

324 

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. 

335 

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. 

339 

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') 

346 

347 if not book_id or not book_id.isdigit(): 

348 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400 

349 

350 if not status: 

351 return jsonify({"error": "Missing 'status' parameter"}), 400 

352 

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 

359 

360 user_id = current_user.id 

361 set_book_status(book_id, status, user_id) 

362 return jsonify({'new_status': status}), 200 

363 

364 

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. 

374 

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') 

382 

383 if not book_id or not book_id.isdigit(): 

384 return jsonify({"error": "Invalid or missing 'book_id' parameter"}), 400 

385 

386 if not fb: 

387 return jsonify({"error": "Missing 'feedback' parameter"}), 400 

388 

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 

395 

396 user_id = current_user.id 

397 set_book_feedback(book_id, fb, user_id) 

398 return jsonify({'new_feedback': fb}), 200 

399 

400 

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) 

414 

415 

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 

432 

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 

437 

438 

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. 

444 

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 

452 

453 return jsonify({'success': True, 

454 'tags': get_tags_and_colors(book_id=book.id, user_id=current_user.id)}) 

455 

456 

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)}) 

465 

466 

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. 

473 

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. 

481 

482 If a validation or existence check fails, an appropriate error response is 

483 returned. 

484 

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 

493 

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}) 

504 

505 

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. 

511 

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. 

517 

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 

525 

526 

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') 

530 

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) 

543 

544 return tag, book, None, 200 

545 

546 

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. 

552 

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 

565 

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 

571 

572 return None, 200, book 

573 

574 

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. 

580 

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). 

593 

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') 

602 

603 status_filter = req.args.get('status', None) 

604 feedback_filter = req.args.get('feedback', None) 

605 

606 tag_filter = req.args.getlist('tag') 

607 # make sure all lower case 

608 tag_filter = [tag.lower() for tag in tag_filter] 

609 

610 sort_column = req.args.get('sortColumn', None) 

611 sort_order = req.args.get('sortOrder', 'asc') 

612 

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 

624 

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) 

635 

636 return bks 

637 

638 

639def _make_csv_response(bks): 

640 """ 

641 Generates a CSV response from a list of books. 

642 

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'. 

647 

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 

711 

712 

713def _safe_string(in_str: str) -> str: 

714 """ 

715 Replace all 0xA0 (non-breaking space) characters in the input string with 0x20 (regular space). 

716 

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', ' ')