├── data └── library.sqlite ├── static ├── background.jpeg ├── script.js └── styles.css ├── requirements.txt ├── templates ├── add_author.html ├── add_book.html ├── book_detail.html └── home.html ├── data_models.py ├── README.md └── app.py /data/library.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ell-716/Book-Alchemy/HEAD/data/library.sqlite -------------------------------------------------------------------------------- /static/background.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ell-716/Book-Alchemy/HEAD/static/background.jpeg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==3.0.3 2 | Flask-SQLAlchemy==3.1.1 3 | Jinja2==3.1.4 4 | requests==2.32.3 5 | SQLAlchemy==2.0.36 6 | -------------------------------------------------------------------------------- /templates/add_author.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Add Author 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |

My Book Library 📚

16 |
17 |
18 | Add a book 19 | Add an author 20 |
21 |
22 |
23 | 24 | 25 | {% if success_message %} 26 |

{{ success_message }}

27 | {% endif %} 28 | 29 | 30 | {% if warning_message %} 31 |

{{ warning_message }}

32 | {% endif %} 33 | 34 | 35 | 36 |
37 | 38 |

39 | 40 | 41 |

42 | 43 | 44 |

45 | 46 | 47 |
48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /templates/add_book.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Add Book 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |

My Book Library 📚

16 |
17 |
18 | Add a book 19 | Add an author 20 |
21 |
22 |
23 | 24 | 25 | {% if success_message %} 26 |

{{ success_message }}

27 | {% endif %} 28 | 29 | 30 | {% if warning_message %} 31 |

{{ warning_message }}

32 | {% endif %} 33 | 34 | 35 |
36 | 37 |

38 | 39 | 40 |

41 | 42 | 43 |

44 | 45 | 46 | 47 |

53 | 54 | 55 |
56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /templates/book_detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ book.title }} - Details 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |

My Book Library 📚

16 |
17 |
18 | Add a book 19 | Add an author 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |

{{ book.title }}

28 | {{ book.title }} cover 32 | 33 |

34 | Written by: {{ author.name }} 35 |

36 | 46 | 47 |

Publication Year: {{ book.publication_year }}

48 |

ISBN: {{ book.isbn }}

49 | 50 |

Description

51 |

{{ description if description else 'No description available.' }}

52 |
53 |
54 |
55 | 56 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /data_models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() 4 | 5 | 6 | class Author(db.Model): 7 | """ 8 | Author model representing an author in the database. 9 | 10 | Attributes: 11 | id (int): Primary key for the author. 12 | name (str): Name of the author. 13 | birth_date (str): Birth date of the author in 'YYYY-MM-DD' format. 14 | date_of_death (str): Date of death of the author in 'YYYY-MM-DD' format. 15 | """ 16 | __tablename__ = 'authors' 17 | 18 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 19 | name = db.Column(db.String, nullable=False) 20 | birth_date = db.Column(db.Date, nullable=True) 21 | date_of_death = db.Column(db.Date, nullable=True) 22 | 23 | def __repr__(self): 24 | """ 25 | Returns a string representation of the Author instance for debugging. 26 | """ 27 | return f"Author(id = {self.id}, name = {self.name})" 28 | 29 | def __str__(self): 30 | """ 31 | Returns a user-friendly string representation of the Author instance. 32 | """ 33 | return f"{self.id}. {self.name} ({self.birth_date} - {self.date_of_death})" 34 | 35 | 36 | class Book(db.Model): 37 | """ 38 | Book model representing a book in the database. 39 | 40 | Attributes: 41 | id (int): Primary key for the book. 42 | isbn (str): ISBN of the book, should be unique. 43 | title (str): Title of the book. 44 | publication_year (int): Year the book was published. 45 | author_id (int): Foreign key referencing the Author of the book. 46 | cover_url (str): URL of the book cover image. 47 | description (str): Description of the book. 48 | """ 49 | __tablename__ = 'books' 50 | 51 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 52 | isbn = db.Column(db.String, nullable=False, unique=True) 53 | title = db.Column(db.String, nullable=False) 54 | publication_year = db.Column(db.Integer, nullable=True) 55 | author_id = db.Column(db.Integer, db.ForeignKey('authors.id'), nullable=False) 56 | cover_url = db.Column(db.String, nullable=True) 57 | description = db.Column(db.String, nullable=True) 58 | 59 | author = db.relationship('Author', backref='books', lazy=True) 60 | 61 | def __repr__(self): 62 | return (f"Book(id = {self.id}, isbn = {self.isbn}, title = {self.title}, " 63 | f"publication_year = {self.publication_year}, cover_url = {self.cover_url}, " 64 | f"description = {self.description})") 65 | 66 | def __str__(self): 67 | """ 68 | Returns a user-friendly string representation of the Book instance. 69 | """ 70 | return f"{self.id}. {self.title} ({self.publication_year})" 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Book Alchemy 📚✨ 2 | 3 | Welcome to **Book Alchemy**, a web application built using Flask that allows users to manage their personal book library. The application provides features to view books, add new books and authors, sort the book collection, and display detailed book information. It integrates with the Google Books API to fetch additional book details like covers and descriptions. 4 | 5 | > *This project was developed as part of an assignment in the Software Engineer Bootcamp.* 🎓 6 | 7 | ## Features 🛠️ 8 | 9 | - **View Books**: Display a list of books in the library. 10 | - **Sort Books**: Sort books by author or title. 11 | - **Search Books**: Filter books by title for easier searching. 12 | - **Add Books**: Add new books to the library by entering the title, author, ISBN and publication year. 13 | - **Add Authors**: Add authors with their name and birth/death dates. 14 | - **Book Details**: View detailed information about each book, including author, publication year, ISBN, and a description fetched from the Google Books API. 15 | - **Responsive Design**: The app is styled with CSS to ensure a clean and responsive layout across devices. 16 | 17 | ## Installation ⚙️ 18 | 19 | ```bash 20 | # Clone the repository 21 | git clone https://github.com/Ell-716/Book-Alchemy.git 22 | 23 | # Install required dependencies 24 | pip install -r requirements.txt 25 | 26 | # Run the Flask application 27 | flask run 28 | ``` 29 | Visit http://localhost:5000 in your browser to view the app. 30 | 31 | ## Usage 📖 32 | 33 | ### Home Page 🏠 34 | - When the app is run, users are directed to the homepage, where they can view a list of books in the library. 35 | - Users can sort the books by author or title and search for books by title. 36 | 37 | ### Add a New Book 📚✍️ 38 | - To add a new book, click the Add a Book button in the header. 39 | - Fill out the form with the book's title, author, ISBN, publication year. 40 | 41 | ### Add a New Author ✍️ 42 | - To add a new author, click the Add an Author button in the header. 43 | - Enter the author's name and birth/death dates. 44 | 45 | ### Book Details 📃 46 | - To view detailed information about a book, click on the book title. 47 | - The page will display the book's title, author name, and birth/death dates, ISBN, publication year, and a description (if available) fetched from the Google Books API. 48 | 49 | ## Technologies Used 💻 50 | 51 | - **Flask**: Web framework used to create the server-side logic and handle routing. 52 | - **SQLAlchemy**: ORM for managing the database and interacting with book and author data. 53 | - **Jinja2**: Templating engine used to render dynamic content in HTML pages. 54 | - **HTML/CSS**: For designing the user interface. 55 | - **JavaScript**: Used for client-side interactivity, such as dynamic form submissions and sorting without page reloads. 56 | - **Google Books API**: Integrated to fetch additional book details like covers and descriptions. 57 | 58 | ## Project Requirements 🗂️ 59 | 60 | - Python 3.x 61 | - Flask 3.0.3 62 | - Flask-SQLAlchemy 3.1.1 63 | - Jinja2 3.1.4 64 | - requests 2.32.3 65 | - SQLAlchemy 2.0.36 66 | 67 | ## Contributions 🤝 68 | 69 | If you'd like to contribute to this project, feel free to submit a pull request. Contributions are welcome in the form of bug fixes, new features, or general improvements. Please ensure that your code is properly tested and follows the style guidelines before submitting. 70 | -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Home - Book Library 8 | 9 | 10 | 11 |
12 |
13 | 14 |

My Book Library 📚

15 |
16 |
17 | Add a book 18 | Add an author 19 | 20 |
21 | 25 | 26 |
27 |
28 |
29 |
30 | 31 | 32 |
33 |
34 |
35 | 42 | 43 |
44 |
45 |
46 | 47 | 48 | {% if search %} 49 |
50 | {% for book, author, cover in books %} 51 |
52 | {{ book.title }} cover 57 |

58 | {{ book.title }} 59 |

60 |

{{ author.name }}

61 |
66 | 67 |
68 |
69 | {% endfor %} 70 |
71 | {% endif %} 72 | 73 | 74 | {% if not search %} 75 |
76 |
77 | 78 |
79 | {% for book, author, cover in books %} 80 |
81 | {{ book.title }} cover 86 |

87 | {{ book.title }} 88 |

89 |

{{ author.name }}

90 |
95 | 96 |
97 |
98 | {% endfor %} 99 |
100 | 101 |
102 |
103 | {% endif %} 104 | 105 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | // Validate Add Author Form 3 | const addAuthorForm = document.querySelector("form[action='/add_author']"); 4 | if (addAuthorForm) { 5 | addAuthorForm.addEventListener("submit", (event) => { 6 | const nameInput = document.getElementById("name"); 7 | const birthDateInput = document.getElementById("birth_date"); 8 | const deathDateInput = document.getElementById("date_of_death"); 9 | 10 | if (!nameInput.value.trim()) { 11 | event.preventDefault(); 12 | alert("Author name is required!"); 13 | return; 14 | } 15 | 16 | if (birthDateInput.value && !/^\d{4}-\d{2}-\d{2}$/.test(birthDateInput.value)) { 17 | event.preventDefault(); 18 | alert("Birthdate must be in yyyy-mm-dd format!"); 19 | return; 20 | } 21 | 22 | if (deathDateInput.value && !/^\d{4}-\d{2}-\d{2}$/.test(deathDateInput.value)) { 23 | event.preventDefault(); 24 | alert("Date of death must be in yyyy-mm-dd format!"); 25 | return; 26 | } 27 | 28 | if (birthDateInput.value && deathDateInput.value && new Date(birthDateInput.value) > new Date(deathDateInput.value)) { 29 | event.preventDefault(); 30 | alert("Birthdate must be before the date of death!"); 31 | } 32 | }); 33 | } 34 | 35 | // Validate Add Book Form 36 | const addBookForm = document.querySelector("form[action='/add_book']"); 37 | if (addBookForm) { 38 | addBookForm.addEventListener("submit", (event) => { 39 | const isbnInput = document.getElementById("isbn"); 40 | const titleInput = document.getElementById("title"); 41 | const publicationYearInput = document.getElementById("publication_year"); 42 | const authorSelect = document.getElementById("author_id"); 43 | 44 | const isbnValue = isbnInput.value.trim(); 45 | if (!/^\d{10}(\d{3})?$/.test(isbnValue)) { 46 | event.preventDefault(); 47 | alert("ISBN must be 10 or 13 digits!"); 48 | return; 49 | } 50 | 51 | if (!titleInput.value.trim()) { 52 | event.preventDefault(); 53 | alert("Book title is required!"); 54 | return; 55 | } 56 | 57 | const currentYear = new Date().getFullYear(); 58 | if (publicationYearInput.value && (publicationYearInput.value < 1500 || publicationYearInput.value > currentYear)) { 59 | event.preventDefault(); 60 | alert(`Publication year must be between 1500 and ${currentYear}!`); 61 | return; 62 | } 63 | 64 | if (!authorSelect.value) { 65 | event.preventDefault(); 66 | alert("Please select an author!"); 67 | } 68 | }); 69 | } 70 | 71 | // Add confirmation prompt for delete buttons 72 | const deleteButtons = document.querySelectorAll("form[action^='/book/'][method='POST'] button"); 73 | deleteButtons.forEach(button => { 74 | button.addEventListener("click", (event) => { 75 | if (!confirm("Are you sure you want to delete this book? This action cannot be undone.")) { 76 | event.preventDefault(); 77 | } 78 | }); 79 | }); 80 | 81 | // Scroll functionality for book list 82 | const scrollLeftButton = document.querySelector(".scroll-button.left"); 83 | const scrollRightButton = document.querySelector(".scroll-button.right"); 84 | const bookGrid = document.querySelector(".book-grid"); 85 | 86 | if (scrollLeftButton && scrollRightButton && bookGrid && document.querySelector(".book-card")) { 87 | const bookWidth = document.querySelector(".book-card").offsetWidth; 88 | const gap = 20; 89 | const scrollStep = (bookWidth + gap) * 4; 90 | 91 | scrollLeftButton.addEventListener("click", () => { 92 | bookGrid.scrollBy({ 93 | left: -scrollStep, 94 | behavior: "smooth", 95 | }); 96 | }); 97 | 98 | scrollRightButton.addEventListener("click", () => { 99 | bookGrid.scrollBy({ 100 | left: scrollStep, 101 | behavior: "smooth", 102 | }); 103 | }); 104 | } 105 | 106 | // Clear search input on page load 107 | const searchInput = document.getElementById("search"); 108 | if (searchInput) { 109 | searchInput.value = ""; 110 | } 111 | }); 112 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | /* General styles */ 2 | body { 3 | font-family: Arial, sans-serif; 4 | margin: 0; 5 | padding: 0; 6 | display: flex; 7 | flex-direction: column; 8 | min-height: 100vh; 9 | background: url(background.jpeg); 10 | box-sizing: border-box; 11 | width: 100%; 12 | } 13 | 14 | header { 15 | padding: 20px; 16 | color: black; 17 | } 18 | 19 | .header-container { 20 | display: flex; 21 | justify-content: space-between; 22 | align-items: center; 23 | width: 100%; 24 | } 25 | 26 | header h1 { 27 | margin: 0; 28 | font-size: 45px; 29 | flex: 1; 30 | } 31 | 32 | .header-right { 33 | display: flex; 34 | align-items: center; 35 | gap: 15px; 36 | } 37 | 38 | .library-title { 39 | text-decoration: none; 40 | color: inherit; 41 | } 42 | 43 | .library-title h1 { 44 | display: inline; 45 | } 46 | 47 | .add-button { 48 | padding: 8px 15px; 49 | font-size: 14px; 50 | background-color: white; 51 | color: black; 52 | border: none; 53 | border-radius: 4px; 54 | cursor: pointer; 55 | } 56 | 57 | .add-button:hover { 58 | background-color: #F0FFFF; 59 | } 60 | 61 | .sort-form { 62 | margin: 0; 63 | } 64 | 65 | .sort-dropdown { 66 | padding: 8px; 67 | font-size: 14px; 68 | border: none; 69 | border-radius: 4px; 70 | background: white; 71 | } 72 | 73 | /* Search Form Section */ 74 | .search-section { 75 | display: flex; 76 | justify-content: center; 77 | margin-top: 40px; 78 | } 79 | 80 | .search-sort-form { 81 | display: flex; 82 | gap: 10px; 83 | align-items: center; 84 | } 85 | 86 | .input-group { 87 | display: flex; 88 | gap: 5px; 89 | } 90 | 91 | input#search { 92 | padding: 8px; 93 | font-size: 14px; 94 | border: 1px solid #ddd; 95 | border-radius: 4px; 96 | width: 300px; 97 | } 98 | 99 | .search-button { 100 | padding: 8px 12px; 101 | font-size: 14px; 102 | color: white; 103 | background-color: #0056b3; 104 | border: none; 105 | border-radius: 4px; 106 | cursor: pointer; 107 | } 108 | 109 | .search-button:hover { 110 | background-color: #004494; 111 | } 112 | 113 | /* Search Results Section */ 114 | .search-result-container { 115 | width: 100%; 116 | margin: 30px auto; 117 | display: flex; 118 | flex-wrap: wrap; 119 | gap: 20px; 120 | justify-content: center; 121 | background-color: transparent; 122 | border: none; 123 | padding: 0; 124 | box-shadow: none; 125 | } 126 | 127 | /* Main grid layout */ 128 | main { 129 | display: flex; 130 | justify-content: center; 131 | align-items: center; 132 | min-height: 60vh; 133 | padding: 20px; 134 | } 135 | 136 | /* Scroll container for books */ 137 | .scroll-container { 138 | display: flex; 139 | align-items: center; 140 | justify-content: center; 141 | width: 100%; 142 | max-width: 960px; 143 | overflow: hidden; 144 | } 145 | 146 | /* Scroll buttons */ 147 | .scroll-button { 148 | background-color: rgba(0, 0, 0, 0.1); 149 | border: none; 150 | font-size: 30px; 151 | cursor: pointer; 152 | padding: 10px; 153 | z-index: 10; 154 | position: absolute; 155 | top: 50%; 156 | transform: translateY(-50%); 157 | } 158 | 159 | .scroll-button.left { 160 | left: 10px; 161 | } 162 | 163 | .scroll-button.right { 164 | right: 10px; 165 | } 166 | 167 | .scroll-button:hover { 168 | background-color: rgba(0, 0, 0, 0.2); 169 | } 170 | 171 | /* Horizontal book grid */ 172 | .book-grid { 173 | display: flex; 174 | gap: 20px; 175 | overflow-x: scroll; 176 | scroll-behavior: smooth; 177 | scroll-snap-type: x mandatory; 178 | -webkit-overflow-scrolling: touch; 179 | padding: 10px; 180 | width: 100%; 181 | transition: transform 0.3s ease-in-out; 182 | } 183 | 184 | /* Single book card */ 185 | .book-card { 186 | flex: 0 0 200px; 187 | height: 450px; 188 | background-color: white; 189 | border: 1px solid #ddd; 190 | border-radius: 5px; 191 | padding: 10px; 192 | text-align: center; 193 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 194 | transition: all 0.5s ease-in-out; 195 | } 196 | 197 | /* Responsive adjustments */ 198 | @media (max-width: 1024px) { 199 | .book-card { 200 | flex: 0 0 calc(33.33% - 20px); 201 | } 202 | } 203 | 204 | @media (max-width: 768px) { 205 | .book-card { 206 | flex: 0 0 calc(50% - 20px); 207 | } 208 | } 209 | 210 | @media (max-width: 480px) { 211 | .book-card { 212 | flex: 0 0 calc(100% - 20px); 213 | } 214 | } 215 | 216 | /* Book cover styling */ 217 | .book-cover { 218 | width: 100%; 219 | height: 300px; 220 | border-radius: 5px; 221 | margin-bottom: 10px; 222 | } 223 | 224 | /* Title, author, and delete button */ 225 | .book-card h3 { 226 | margin: 2px 0; 227 | font-size: 16px; 228 | min-height: 50px; 229 | display: flex; 230 | align-items: center; 231 | justify-content: center; 232 | text-align: center; 233 | } 234 | 235 | .book-card h3 a { 236 | text-decoration: none; 237 | color: #007bff; 238 | font-weight: bold; 239 | font-size: 20px; 240 | } 241 | 242 | .book-card p { 243 | font-size: 14px; 244 | margin: 2px 0; 245 | } 246 | 247 | .book-card h3 a:hover { 248 | text-decoration: underline; 249 | color: #0056b3; 250 | } 251 | 252 | /* Delete button */ 253 | .delete-form { 254 | margin-top: auto; 255 | } 256 | 257 | .delete-form button { 258 | padding: 5px 10px; 259 | background-color: #dc3545; 260 | color: white; 261 | border: none; 262 | border-radius: 3px; 263 | cursor: pointer; 264 | margin-top: 10px; 265 | } 266 | 267 | .delete-form button:hover { 268 | background-color: #c82333; 269 | } 270 | 271 | /* Styling for Add Book and Add Author pages */ 272 | .styled-form { 273 | width: 100%; 274 | max-width: 800px; 275 | margin: 50px auto; 276 | background-color: #FFF; 277 | border: 1px solid #DDD; 278 | padding: 25px; 279 | border-radius: 8px; 280 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 281 | display: flex; 282 | flex-direction: column; 283 | box-sizing: border-box; 284 | } 285 | 286 | .styled-form label { 287 | font-weight: bold; 288 | margin-top: 10px; 289 | display: block; 290 | color: #333; 291 | } 292 | 293 | .styled-form input[type="text"], 294 | .styled-form input[type="date"], 295 | .styled-form select { 296 | padding: 12px 15px; 297 | font-size: 16px; 298 | border: 1px solid #ccc; 299 | border-radius: 5px; 300 | width: 100%; 301 | margin-bottom: 10px; 302 | box-sizing: border-box; 303 | } 304 | 305 | .styled-form select { 306 | padding-right: 30px; 307 | } 308 | 309 | .styled-form input[type="submit"] { 310 | background-color: #007bff; 311 | color: white; 312 | padding: 12px 0; 313 | font-size: 16px; 314 | font-weight: bold; 315 | border: none; 316 | border-radius: 5px; 317 | cursor: pointer; 318 | width: 100%; 319 | transition: background-color 0.3s; 320 | } 321 | 322 | .styled-form input[type="submit"]:hover { 323 | background-color: #0056b3; 324 | } 325 | 326 | /* Responsive adjustments */ 327 | @media (max-width: 768px) { 328 | .styled-form { 329 | max-width: 600px; 330 | padding: 20px; 331 | } 332 | 333 | .styled-form input[type="text"], 334 | .styled-form input[type="date"], 335 | .styled-form select { 336 | font-size: 14px; 337 | padding: 10px; 338 | } 339 | 340 | .styled-form input[type="submit"] { 341 | font-size: 14px; 342 | padding: 12px 0; 343 | } 344 | } 345 | 346 | @media (max-width: 480px) { 347 | .styled-form { 348 | max-width: 100%; 349 | padding: 15px; 350 | } 351 | 352 | .styled-form input[type="text"], 353 | .styled-form input[type="date"], 354 | .styled-form select { 355 | font-size: 14px; 356 | } 357 | 358 | .styled-form input[type="submit"] { 359 | font-size: 14px; 360 | padding: 10px 0; 361 | } 362 | } 363 | 364 | /* Success and warning messages */ 365 | .alert { 366 | padding: 10px; 367 | margin-bottom: 50px auto; 368 | border-radius: 4px; 369 | display: flex; 370 | justify-content: center; 371 | align-items: center; 372 | text-align: center; 373 | } 374 | 375 | .alert-success { 376 | background-color: #d4edda; 377 | color: #155724; 378 | } 379 | 380 | .alert-warning { 381 | background-color: #f8d7da; 382 | color: #721c24; 383 | } 384 | 385 | /* Detail page unified layout */ 386 | .detail-container { 387 | display: flex; 388 | justify-content: center; 389 | margin-top: 60px; 390 | } 391 | 392 | .book-info { 393 | background-color: white; 394 | border: 1px solid #ddd; 395 | border-radius: 8px; 396 | padding: 30px; 397 | max-width: 700px; 398 | width: 100%; 399 | text-align: center; 400 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); 401 | } 402 | 403 | .book-info h1 { 404 | font-size: 28px; 405 | margin-bottom: 25px; 406 | color: #007bff; 407 | } 408 | .book-cover-container { 409 | width: 150px; 410 | height: 200px; 411 | overflow: hidden; 412 | display: flex; 413 | justify-content: center; 414 | align-items: center; 415 | border-radius: 4px; 416 | } 417 | 418 | .author-info { 419 | margin: 10px 0 5px; 420 | font-size: 18px; 421 | color: #333; 422 | } 423 | 424 | .author-dates { 425 | font-size: 16px; 426 | color: #666; 427 | margin-bottom: 15px; 428 | } 429 | 430 | .book-info p { 431 | margin: 10px 0; 432 | font-size: 18px; 433 | } 434 | 435 | .book-info h3 { 436 | margin-top: 25px; 437 | font-size: 20px; 438 | color: #333; 439 | } 440 | 441 | footer { 442 | text-align: center; 443 | margin-top: 50px; 444 | } 445 | 446 | .author-dates .date { 447 | font-size: 0.8rem; 448 | font-style: italic; 449 | color: #555; 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from datetime import datetime 4 | from data_models import db, Author, Book 5 | from flask import Flask, request, render_template, redirect, url_for 6 | from sqlalchemy.exc import SQLAlchemyError, IntegrityError 7 | 8 | app = Flask(__name__) 9 | 10 | # Get the absolute path to the current directory 11 | base_dir = os.path.abspath(os.path.dirname(__file__)) 12 | app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{base_dir}/data/library.sqlite" 13 | 14 | db.init_app(app) 15 | 16 | 17 | # Create the database tables. Run once 18 | # with app.app_context(): 19 | # db.create_all() 20 | 21 | 22 | @app.route("/add_author", methods=["GET", "POST"]) 23 | def add_author(): 24 | """ 25 | Handles the creation of a new author. The function accepts both GET and POST requests. 26 | - GET: Renders the form for adding a new author. 27 | - POST: Processes the form submission, validates the input, and adds the author to the database. 28 | """ 29 | if request.method == "POST": 30 | name = request.form.get('name', '').strip() 31 | birth_date = request.form.get('birth_year', '').strip() 32 | date_of_death = request.form.get('death_year', '').strip() 33 | 34 | # Validate name: it must contain only alphabetic characters and spaces 35 | if not name or not name.replace(' ', '').isalpha(): 36 | warning_message = "Invalid name. Please try again!" 37 | return render_template("add_author.html", warning_message=warning_message) 38 | 39 | # Check if the author already exists 40 | existing_author = Author.query.filter_by(name=name).first() 41 | if existing_author: 42 | warning_message = "This author already exists!" 43 | return render_template("add_author.html", warning_message=warning_message) 44 | 45 | # Validate birth_date and date_of_death 46 | def validate_date(date_str, field_name): 47 | if date_str: 48 | try: 49 | return datetime.strptime(date_str, "%Y-%m-%d") 50 | except ValueError: 51 | raise ValueError(f"Invalid {field_name}. Please try again.") 52 | return None 53 | 54 | try: 55 | birth_date = validate_date(birth_date, "birth date") 56 | date_of_death = validate_date(date_of_death, "date of death") 57 | 58 | # Check if date_of_death is after birth_date 59 | if birth_date and date_of_death and date_of_death <= birth_date: 60 | warning_message = "Date of death must be after the birth date." 61 | return render_template("add_author.html", warning_message=warning_message) 62 | except ValueError as e: 63 | return render_template("add_author.html", warning_message=str(e)) 64 | 65 | # Create the Author object 66 | author = Author( 67 | name=name, 68 | birth_date=birth_date, 69 | date_of_death=date_of_death 70 | ) 71 | 72 | try: 73 | db.session.add(author) 74 | db.session.commit() 75 | success_message = "Author added successfully!" 76 | return render_template("add_author.html", success_message=success_message) 77 | except SQLAlchemyError: 78 | db.session.rollback() 79 | warning_message = f"Error adding author to the database!" 80 | return render_template("add_author.html", warning_message=warning_message) 81 | 82 | if request.method == "GET": 83 | return render_template("add_author.html") 84 | 85 | 86 | def fetch_book_details(isbn): 87 | """ 88 | Fetches book details, including the cover image URL and description, using the Google Books API. 89 | Args: 90 | isbn (str): The ISBN of the book to fetch details for. 91 | Returns: 92 | tuple: A tuple containing: 93 | - cover_url (str or None): The URL of the book's cover image if available, otherwise None. 94 | - description (str or None): The description of the book if available, otherwise None. 95 | """ 96 | if not isbn or not isbn.isdigit() or len(isbn) not in (10, 13): 97 | print(f"Invalid ISBN provided: {isbn}") 98 | return None, None 99 | 100 | api_url = f"https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn}" 101 | 102 | try: 103 | response = requests.get(api_url, timeout=15) 104 | response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) 105 | except requests.exceptions.Timeout: 106 | print(f"Request timed out while fetching details for ISBN: {isbn}") 107 | return None, None 108 | except requests.exceptions.ConnectionError: 109 | print(f"Connection error while fetching details for ISBN: {isbn}") 110 | return None, None 111 | except requests.exceptions.HTTPError as e: 112 | print(f"HTTP error occurred: {e}") 113 | return None, None 114 | except requests.exceptions.RequestException as e: 115 | print(f"An error occurred while fetching details for ISBN: {isbn}. Error: {e}") 116 | return None, None 117 | 118 | try: 119 | data = response.json() 120 | except ValueError: 121 | print(f"Error decoding JSON response for ISBN: {isbn}") 122 | return None, None 123 | 124 | if "items" not in data or not data["items"]: 125 | print(f"No book found for ISBN: {isbn}") 126 | return None, None 127 | 128 | try: 129 | volume_info = data["items"][0]["volumeInfo"] 130 | cover_url = volume_info.get("imageLinks", {}).get("thumbnail", None) 131 | description = volume_info.get("description", None) 132 | return cover_url, description 133 | except KeyError: 134 | print(f"Unexpected data structure in API response for ISBN: {isbn}") 135 | return None, None 136 | 137 | 138 | @app.route("/add_book", methods=["GET", "POST"]) 139 | def add_book(): 140 | """ 141 | Handles the creation of a new book. The function accepts both GET and POST requests. 142 | - GET: Renders the form for adding a new book. 143 | - POST: Processes the form submission, validates the input, and adds the book to the database. 144 | Returns: 145 | - Rendered HTML templates based on the success or failure of adding the book. 146 | """ 147 | if request.method == "POST": 148 | isbn = request.form.get('isbn', '').strip() 149 | title = request.form.get('title', '').strip() 150 | publication_year = request.form.get('publication_year', '').strip() 151 | author_id = request.form.get('author_id') 152 | cover_url = request.form.get('cover_url', '').strip() 153 | description = request.form.get('description', '').strip() 154 | 155 | # Validate title: it must not be empty and should contain letters 156 | if not title or not any(char.isalpha() for char in title): 157 | warning_message = "Invalid book title. Please try again!" 158 | return render_template("add_book.html", 159 | authors=Author.query.all(), 160 | warning_message=warning_message) 161 | 162 | # Validate ISBN: It should contain only digits and be 10 or 13 digits long 163 | if not isbn.isdigit() or len(isbn) not in [10, 13]: 164 | warning_message = "Invalid ISBN. It should be 10 or 13 digits." 165 | return render_template("add_book.html", 166 | authors=Author.query.all(), 167 | warning_message=warning_message) 168 | 169 | # Validate publication year: It should be a valid year 170 | current_year = datetime.now().year 171 | if publication_year: 172 | if not publication_year.isdigit() or not (1000 <= int(publication_year) <= current_year): 173 | warning_message = f"Invalid publication year. Must be between 1000 and {current_year}." 174 | return render_template("add_book.html", 175 | authors=Author.query.all(), 176 | warning_message=warning_message) 177 | 178 | # Check if the book already exists 179 | existing_book = Book.query.filter_by(isbn=isbn).first() 180 | if existing_book: 181 | warning_message = "This book already exists in the library!" 182 | return render_template("add_book.html", 183 | authors=Author.query.all(), 184 | warning_message=warning_message) 185 | 186 | book = Book( 187 | author_id=author_id, 188 | isbn=isbn, 189 | title=title, 190 | publication_year=int(publication_year) if publication_year else None, 191 | cover_url=cover_url, 192 | description=description 193 | ) 194 | 195 | try: 196 | db.session.add(book) 197 | db.session.commit() 198 | success_message = "Book added successfully!" 199 | return render_template("add_book.html", 200 | authors=Author.query.all(), 201 | success_message=success_message) 202 | except SQLAlchemyError: 203 | db.session.rollback() 204 | warning_message = f"Error adding the book!" 205 | return render_template("add_book.html", 206 | authors=Author.query.all(), 207 | warning_message=warning_message) 208 | 209 | if request.method == "GET": 210 | return render_template("add_book.html", authors=Author.query.all()) 211 | 212 | 213 | @app.route("/", methods=["GET"]) 214 | def home_page(): 215 | """ 216 | Displays the homepage with a list of books. The books can be sorted by author or title, 217 | and a search functionality is available to filter books by title. 218 | Returns: 219 | - Rendered homepage with books, sorted and/or filtered based on the user's input. 220 | """ 221 | sort = request.args.get('sort', 'author') 222 | search = request.args.get('search') or "" 223 | message = request.args.get('message') 224 | 225 | if search: 226 | books = db.session.query(Book, Author).join(Author) \ 227 | .filter(Book.title.like(f"%{search}%")) \ 228 | .order_by(Book.title).all() 229 | if not books: 230 | return render_template("home.html", books=[], search=search, 231 | message="No books found matching your search.") 232 | else: 233 | if sort == 'author': 234 | books = db.session.query(Book, Author).join(Author).order_by(Author.name).all() 235 | elif sort == 'title': 236 | books = db.session.query(Book, Author).join(Author).order_by(Book.title).all() 237 | else: 238 | books = db.session.query(Book, Author).join(Author).order_by(Author.name).all() 239 | 240 | books_with_cover = [] 241 | for book, author in books: 242 | cover_url, _ = fetch_book_details(book.isbn) 243 | books_with_cover.append((book, author, cover_url)) 244 | 245 | return render_template("home.html", books=books_with_cover, 246 | sort=sort, search=search, message=message) 247 | 248 | 249 | @app.route("/book//delete", methods=["POST"]) 250 | def delete_book(book_id): 251 | """ 252 | Deletes a book from the database and removes the author if they no longer have any books. 253 | Args: 254 | book_id (int): The ID of the book to be deleted. 255 | Returns: 256 | - Redirects to the homepage with a success or error message. 257 | """ 258 | try: 259 | book_to_delete = db.session.query(Book).filter(Book.id == book_id).first() 260 | if not book_to_delete: 261 | return redirect(url_for('home_page', message=f"Book with ID {book_id} not found!")) 262 | 263 | book_title = book_to_delete.title 264 | author_id = book_to_delete.author_id 265 | 266 | db.session.query(Book).filter(Book.id == book_id).delete() 267 | 268 | # Check if the author has other books, and delete the author if none exist 269 | if not db.session.query(Book).filter(Book.author_id == author_id).count(): 270 | db.session.query(Author).filter(Author.id == author_id).delete() 271 | 272 | db.session.commit() 273 | return redirect(url_for('home_page', message=f"Book '{book_title}' deleted successfully!")) 274 | 275 | except IntegrityError as e: 276 | db.session.rollback() 277 | print(f"IntegrityError: {e}") 278 | return redirect(url_for('home_page', message="Database integrity error occurred during deletion.")) 279 | except SQLAlchemyError as e: 280 | db.session.rollback() 281 | print(f"SQLAlchemyError: {e}") 282 | return redirect(url_for('home_page', message="An unexpected error occurred. Please try again.")) 283 | 284 | 285 | @app.route('/book/') 286 | def book_detail(book_id): 287 | """ 288 | Displays detailed information about a specific book. 289 | Args: 290 | book_id (int): The ID of the book to retrieve details for. 291 | Returns: 292 | - Rendered 'book_detail.html' template with: 293 | - `book`: The book object retrieved from the database. 294 | - `author`: The author object retrieved from the database. 295 | - `cover_url`: The book cover URL fetched from the Google Books API. 296 | - `description`: The book description fetched from the Google Books API. 297 | """ 298 | book = Book.query.get_or_404(book_id) 299 | cover_url, description = fetch_book_details(book.isbn) 300 | author = book.author 301 | 302 | return render_template( 303 | 'book_detail.html', 304 | book=book, 305 | author=author, 306 | cover_url=cover_url, 307 | description=description 308 | ) 309 | 310 | 311 | if __name__ == "__main__": 312 | app.run(host="0.0.0.0", port=5000, debug=True) 313 | --------------------------------------------------------------------------------