Add server-side validation and small improvements
This commit is contained in:
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
"gunicorn>=25.1.0",
|
"gunicorn>=25.1.0",
|
||||||
"jinja2-fragments>=1.11.0",
|
"jinja2-fragments>=1.11.0",
|
||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
|
"pydantic-extra-types>=2.11.1",
|
||||||
"pyparsing>=3.3.2",
|
"pyparsing>=3.3.2",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"sqlalchemy>=2.0.48",
|
"sqlalchemy>=2.0.48",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ def create_book(
|
|||||||
location_shelf: int | None = None,
|
location_shelf: int | None = None,
|
||||||
first_published: int | None = None,
|
first_published: int | None = None,
|
||||||
bought_date: date | None = None,
|
bought_date: date | None = None,
|
||||||
|
loaned_to: str | None = None,
|
||||||
|
loaned_date: date | None = None,
|
||||||
) -> Book:
|
) -> Book:
|
||||||
"""Create a new book with the given details."""
|
"""Create a new book with the given details."""
|
||||||
book = Book(
|
book = Book(
|
||||||
@@ -50,6 +52,8 @@ def create_book(
|
|||||||
location_shelf=location_shelf,
|
location_shelf=location_shelf,
|
||||||
first_published=first_published,
|
first_published=first_published,
|
||||||
bought_date=bought_date,
|
bought_date=bought_date,
|
||||||
|
loaned_to=loaned_to or "",
|
||||||
|
loaned_date=loaned_date,
|
||||||
)
|
)
|
||||||
db.session.add(book)
|
db.session.add(book)
|
||||||
|
|
||||||
@@ -90,6 +94,8 @@ def get_book(book_id: int) -> Book | None:
|
|||||||
|
|
||||||
def update_book(
|
def update_book(
|
||||||
book_id: int,
|
book_id: int,
|
||||||
|
set_all_fields: bool = False, # If True, all fields must be provided and will be set (even if None)
|
||||||
|
owner_id: int | None = None,
|
||||||
title: str | None = None,
|
title: str | None = None,
|
||||||
authors: list[str] | None = None,
|
authors: list[str] | None = None,
|
||||||
genres: list[str] | None = None,
|
genres: list[str] | None = None,
|
||||||
@@ -103,6 +109,8 @@ def update_book(
|
|||||||
location_shelf: int | None = None,
|
location_shelf: int | None = None,
|
||||||
first_published: int | None = None,
|
first_published: int | None = None,
|
||||||
bought_date: date | None = None,
|
bought_date: date | None = None,
|
||||||
|
loaned_to: str | None = None,
|
||||||
|
loaned_date: date | None = None,
|
||||||
) -> Book | None:
|
) -> Book | None:
|
||||||
"""Update a book with new details."""
|
"""Update a book with new details."""
|
||||||
book = get_book(book_id)
|
book = get_book(book_id)
|
||||||
@@ -110,43 +118,73 @@ def update_book(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Update scalar fields
|
# Update scalar fields
|
||||||
if title is not None:
|
if title is not None or set_all_fields:
|
||||||
|
assert title is not None, "Title is required when set_all_fields is True"
|
||||||
book.title = title
|
book.title = title
|
||||||
if isbn is not None:
|
if isbn is not None or set_all_fields:
|
||||||
|
assert isbn is not None, "ISBN is required when set_all_fields is True"
|
||||||
book.isbn = isbn
|
book.isbn = isbn
|
||||||
if publisher is not None:
|
if publisher is not None or set_all_fields:
|
||||||
|
assert publisher is not None, (
|
||||||
|
"Publisher is required when set_all_fields is True"
|
||||||
|
)
|
||||||
book.publisher = publisher
|
book.publisher = publisher
|
||||||
if edition is not None:
|
if edition is not None or set_all_fields:
|
||||||
|
assert edition is not None, "Edition is required when set_all_fields is True"
|
||||||
book.edition = edition
|
book.edition = edition
|
||||||
if description is not None:
|
if description is not None or set_all_fields:
|
||||||
|
assert description is not None, (
|
||||||
|
"Description is required when set_all_fields is True"
|
||||||
|
)
|
||||||
book.description = description
|
book.description = description
|
||||||
if notes is not None:
|
if notes is not None or set_all_fields:
|
||||||
|
assert notes is not None, "Notes is required when set_all_fields is True"
|
||||||
book.notes = notes
|
book.notes = notes
|
||||||
if location_place is not None:
|
if location_place is not None or set_all_fields:
|
||||||
|
assert location_place is not None, (
|
||||||
|
"Location place is required when set_all_fields is True"
|
||||||
|
)
|
||||||
book.location_place = location_place
|
book.location_place = location_place
|
||||||
if location_bookshelf is not None:
|
if location_bookshelf is not None or set_all_fields:
|
||||||
|
assert location_bookshelf is not None, (
|
||||||
|
"Location bookshelf is required when set_all_fields is True"
|
||||||
|
)
|
||||||
book.location_bookshelf = location_bookshelf
|
book.location_bookshelf = location_bookshelf
|
||||||
if location_shelf is not None:
|
if location_shelf is not None or set_all_fields:
|
||||||
book.location_shelf = location_shelf
|
book.location_shelf = location_shelf
|
||||||
if first_published is not None:
|
if first_published is not None or set_all_fields:
|
||||||
book.first_published = first_published
|
book.first_published = first_published
|
||||||
if bought_date is not None:
|
if bought_date is not None or set_all_fields:
|
||||||
book.bought_date = bought_date
|
book.bought_date = bought_date
|
||||||
|
if loaned_to is not None or set_all_fields:
|
||||||
|
assert loaned_to is not None, (
|
||||||
|
"Loaned to is required when set_all_fields is True"
|
||||||
|
)
|
||||||
|
book.loaned_to = loaned_to
|
||||||
|
if loaned_date is not None or set_all_fields:
|
||||||
|
book.loaned_date = loaned_date
|
||||||
|
|
||||||
# Update authors
|
# Update authors
|
||||||
if authors is not None:
|
if authors is not None or set_all_fields:
|
||||||
|
assert authors is not None, (
|
||||||
|
"Authors list is required when set_all_fields is True"
|
||||||
|
)
|
||||||
book.authors.clear()
|
book.authors.clear()
|
||||||
for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
|
for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
|
||||||
author = _get_or_create_author(author_name)
|
author = _get_or_create_author(author_name)
|
||||||
book.authors.append(author)
|
book.authors.append(author)
|
||||||
|
|
||||||
# Update genres
|
# Update genres
|
||||||
if genres is not None:
|
if genres is not None or set_all_fields:
|
||||||
|
assert genres is not None, "Genres list is required when set_all_fields is True"
|
||||||
book.genres.clear()
|
book.genres.clear()
|
||||||
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
|
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
|
||||||
genre = _get_or_create_genre(genre_name)
|
genre = _get_or_create_genre(genre_name)
|
||||||
book.genres.append(genre)
|
book.genres.append(genre)
|
||||||
|
|
||||||
|
if owner_id is not None or set_all_fields:
|
||||||
|
book.owner_id = owner_id
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return book
|
return book
|
||||||
|
|
||||||
@@ -632,6 +670,17 @@ def get_reading_history(user_id: int, limit: int = 50) -> Sequence[Reading]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_reading(reading_id: int) -> bool:
|
||||||
|
"""Delete a reading session."""
|
||||||
|
reading = db.session.get(Reading, reading_id)
|
||||||
|
if not reading:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.session.delete(reading)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def add_to_wishlist(book_id: int, user_id: int) -> Wishlist:
|
def add_to_wishlist(book_id: int, user_id: int) -> Wishlist:
|
||||||
"""Add a book to user's wishlist."""
|
"""Add a book to user's wishlist."""
|
||||||
# Check if book exists
|
# Check if book exists
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Main application routes for HXBooks frontend.
|
|||||||
Provides clean URL structure and integrates with library.py business logic.
|
Provides clean URL structure and integrates with library.py business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from datetime import date
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -17,8 +18,16 @@ from flask import (
|
|||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
from flask.typing import ResponseReturnValue
|
from flask.typing import ResponseReturnValue
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
BeforeValidator,
|
||||||
|
Field,
|
||||||
|
StringConstraints,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from pydantic_extra_types.isbn import ISBN
|
||||||
|
|
||||||
from hxbooks.models import User
|
from hxbooks.models import Reading, User
|
||||||
|
|
||||||
from . import library
|
from . import library
|
||||||
from .db import db
|
from .db import db
|
||||||
@@ -26,25 +35,56 @@ from .db import db
|
|||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
def save_search(user: User, search_name: str, query_params: str) -> bool:
|
# Pydantic validation models
|
||||||
"""Save a search for a user. Mock implementation."""
|
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||||
# Initialize saved_searches if None
|
StripStr = Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||||
|
ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||||
user.saved_searches = user.saved_searches | {search_name: query_params} # noqa: PLR6104
|
TextareaList = Annotated[
|
||||||
print(f"{user.saved_searches=}")
|
list[str],
|
||||||
db.session.commit()
|
BeforeValidator(
|
||||||
return True
|
lambda v: (
|
||||||
|
[line.strip() for line in v.split("\n") if line.strip()]
|
||||||
|
if isinstance(v, str)
|
||||||
|
else v or []
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
DateOrNone = Annotated[date | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||||
|
IntOrNone = Annotated[int | None, BeforeValidator(lambda v: v.strip() or None)]
|
||||||
|
|
||||||
|
|
||||||
def delete_saved_search(user: User, search_name: str) -> bool:
|
class BookFormData(BaseModel):
|
||||||
"""Delete a saved search for a user. Mock implementation."""
|
title: StripStr = Field(min_length=1)
|
||||||
if search_name in user.saved_searches:
|
owner: StrOrNone = None
|
||||||
user.saved_searches = {
|
isbn: ISBNOrNone = None
|
||||||
k: v for k, v in user.saved_searches.items() if k != search_name
|
authors: TextareaList = Field(default_factory=list)
|
||||||
} # needs to be a new object to trigger SQLAlchemy change detection
|
genres: TextareaList = Field(default_factory=list)
|
||||||
db.session.commit()
|
first_published: IntOrNone = Field(default=None, le=2030)
|
||||||
return True
|
publisher: StripStr = Field(default="")
|
||||||
return False
|
edition: StripStr = Field(default="")
|
||||||
|
description: StripStr = Field(default="")
|
||||||
|
notes: StripStr = Field(default="")
|
||||||
|
location_place: StripStr = Field(default="")
|
||||||
|
location_bookshelf: StripStr = Field(default="")
|
||||||
|
location_shelf: IntOrNone = Field(default=None, ge=1)
|
||||||
|
loaned_to: StripStr = Field(default="")
|
||||||
|
loaned_date: DateOrNone = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReadingFormData(BaseModel):
|
||||||
|
start_date: date
|
||||||
|
end_date: DateOrNone = None
|
||||||
|
dropped: bool = Field(default=False)
|
||||||
|
rating: IntOrNone = Field(default=None, ge=1, le=5)
|
||||||
|
comments: StripStr = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
def _flash_validation_errors(e: ValidationError) -> None:
|
||||||
|
"""Helper to flash validation errors."""
|
||||||
|
error = e.errors()[0]
|
||||||
|
loc = " -> ".join(str(v) for v in error.get("loc", []))
|
||||||
|
msg = error.get("msg", "Invalid input")
|
||||||
|
flash(f"Validation error in '{loc}': {msg}", "error")
|
||||||
|
|
||||||
|
|
||||||
@bp.before_app_request
|
@bp.before_app_request
|
||||||
@@ -101,61 +141,60 @@ def book_detail(book_id: int) -> ResponseReturnValue:
|
|||||||
flash("Book not found", "error")
|
flash("Book not found", "error")
|
||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
for reading in book.readings:
|
|
||||||
print(
|
|
||||||
f"Reading: {reading}, user: {reading.user.username if reading.user else 'N/A'}, dropped: {reading.dropped}, finished: {reading.finished}, end_date: {reading.end_date}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template("book/detail.html.j2", book=book)
|
return render_template("book/detail.html.j2", book=book)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_user(username: str) -> int:
|
||||||
|
"""Helper to get or create a user by username."""
|
||||||
|
owner = library.get_user_by_username(username)
|
||||||
|
if not owner:
|
||||||
|
# Create new user if username doesn't exist
|
||||||
|
owner = library.create_user(username)
|
||||||
|
|
||||||
|
return owner.id
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/book/new", methods=["GET", "POST"])
|
@bp.route("/book/new", methods=["GET", "POST"])
|
||||||
def create_book() -> ResponseReturnValue:
|
def create_book() -> ResponseReturnValue:
|
||||||
"""Create a new book."""
|
"""Create a new book."""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
title = request.form.get("title", "").strip()
|
|
||||||
if not title:
|
|
||||||
flash("Title is required", "error")
|
|
||||||
return render_template("book/create.html.j2")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get current viewing user as owner
|
# Validate form data with Pydantic
|
||||||
viewing_user = g.get("viewing_user")
|
form_data = BookFormData.model_validate(dict(request.form))
|
||||||
|
|
||||||
# Process textarea inputs for authors and genres
|
# Get owner ID if provided
|
||||||
authors = [
|
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
|
||||||
author.strip()
|
|
||||||
for author in request.form.get("authors", "").split("\n")
|
|
||||||
if author.strip()
|
|
||||||
]
|
|
||||||
genres = [
|
|
||||||
genre.strip()
|
|
||||||
for genre in request.form.get("genres", "").split("\n")
|
|
||||||
if genre.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
# Create book with submitted data
|
# Create book with validated data
|
||||||
book = library.create_book(
|
book = library.create_book(
|
||||||
title=title,
|
title=form_data.title,
|
||||||
owner_id=viewing_user.id if viewing_user else None,
|
owner_id=owner_id,
|
||||||
authors=authors,
|
authors=form_data.authors,
|
||||||
genres=genres,
|
genres=form_data.genres,
|
||||||
isbn=request.form.get("isbn"),
|
isbn=str(form_data.isbn) if form_data.isbn else None,
|
||||||
publisher=request.form.get("publisher"),
|
publisher=form_data.publisher,
|
||||||
edition=request.form.get("edition"),
|
edition=form_data.edition,
|
||||||
description=request.form.get("description"),
|
description=form_data.description,
|
||||||
notes=request.form.get("notes"),
|
notes=form_data.notes,
|
||||||
location_place=request.form.get("location_place"),
|
location_place=form_data.location_place,
|
||||||
location_bookshelf=request.form.get("location_bookshelf"),
|
location_bookshelf=form_data.location_bookshelf,
|
||||||
location_shelf=int(request.form.get("location_shelf") or 0) or None,
|
location_shelf=form_data.location_shelf,
|
||||||
first_published=int(request.form.get("first_published") or 0) or None,
|
first_published=form_data.first_published,
|
||||||
|
loaned_to=form_data.loaned_to,
|
||||||
|
loaned_date=form_data.loaned_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
flash(f"Book '{title}' created successfully!", "success")
|
flash(f"Book '{form_data.title}' created successfully!", "success")
|
||||||
return redirect(url_for("main.book_detail", book_id=book.id))
|
return redirect(url_for("main.book_detail", book_id=book.id))
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
_flash_validation_errors(e)
|
||||||
|
|
||||||
|
return render_template("book/create.html.j2", form_data=request.form)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error creating book: {e}", "error")
|
flash(f"Error creating book: {e}", "error")
|
||||||
|
return render_template("book/create.html.j2", form_data=request.form)
|
||||||
|
|
||||||
return render_template("book/create.html.j2")
|
return render_template("book/create.html.j2")
|
||||||
|
|
||||||
@@ -169,37 +208,39 @@ def update_book(book_id: int) -> ResponseReturnValue:
|
|||||||
return redirect(url_for("main.index"))
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Process textarea inputs for authors and genres
|
# Validate form data with Pydantic
|
||||||
authors = [
|
form_data = BookFormData.model_validate(dict(request.form))
|
||||||
author.strip()
|
|
||||||
for author in request.form.get("authors", "").split("\n")
|
|
||||||
if author.strip()
|
|
||||||
]
|
|
||||||
genres = [
|
|
||||||
genre.strip()
|
|
||||||
for genre in request.form.get("genres", "").split("\n")
|
|
||||||
if genre.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
# Update book with form data
|
# Get owner ID if provided
|
||||||
|
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
|
||||||
|
|
||||||
|
# Update book with validated data
|
||||||
library.update_book(
|
library.update_book(
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
title=request.form.get("title"),
|
set_all_fields=True, # Ensure all fields are updated, even if None/empty
|
||||||
authors=authors,
|
title=form_data.title,
|
||||||
genres=genres,
|
owner_id=owner_id,
|
||||||
isbn=request.form.get("isbn"),
|
authors=form_data.authors,
|
||||||
publisher=request.form.get("publisher"),
|
genres=form_data.genres,
|
||||||
edition=request.form.get("edition"),
|
isbn=str(form_data.isbn) if form_data.isbn else None,
|
||||||
description=request.form.get("description"),
|
publisher=form_data.publisher,
|
||||||
notes=request.form.get("notes"),
|
edition=form_data.edition,
|
||||||
location_place=request.form.get("location_place"),
|
description=form_data.description,
|
||||||
location_bookshelf=request.form.get("location_bookshelf"),
|
notes=form_data.notes,
|
||||||
location_shelf=int(request.form.get("location_shelf") or 0) or None,
|
location_place=form_data.location_place,
|
||||||
first_published=int(request.form.get("first_published") or 0) or None,
|
location_bookshelf=form_data.location_bookshelf,
|
||||||
|
location_shelf=form_data.location_shelf,
|
||||||
|
first_published=form_data.first_published,
|
||||||
|
loaned_to=form_data.loaned_to,
|
||||||
|
loaned_date=form_data.loaned_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
flash("Book updated successfully!", "success")
|
flash("Book updated successfully!", "success")
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
# Format validation errors for display
|
||||||
|
_flash_validation_errors(e)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Error updating book: {e}", "error")
|
flash(f"Error updating book: {e}", "error")
|
||||||
|
|
||||||
@@ -273,6 +314,26 @@ def set_viewing_user(username: str = "") -> ResponseReturnValue:
|
|||||||
return redirect(request.referrer or url_for("main.index"))
|
return redirect(request.referrer or url_for("main.index"))
|
||||||
|
|
||||||
|
|
||||||
|
def _save_search(user: User, search_name: str, query_params: str) -> bool:
|
||||||
|
"""Save a search for a user. Mock implementation."""
|
||||||
|
# Initialize saved_searches if None
|
||||||
|
|
||||||
|
user.saved_searches = user.saved_searches | {search_name: query_params} # noqa: PLR6104
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_saved_search(user: User, search_name: str) -> bool:
|
||||||
|
"""Delete a saved search for a user. Mock implementation."""
|
||||||
|
if search_name in user.saved_searches:
|
||||||
|
user.saved_searches = {
|
||||||
|
k: v for k, v in user.saved_searches.items() if k != search_name
|
||||||
|
} # needs to be a new object to trigger SQLAlchemy change detection
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/saved-search", methods=["POST"])
|
@bp.route("/saved-search", methods=["POST"])
|
||||||
def save_search_route() -> ResponseReturnValue:
|
def save_search_route() -> ResponseReturnValue:
|
||||||
"""Save a search for the current user."""
|
"""Save a search for the current user."""
|
||||||
@@ -283,16 +344,13 @@ def save_search_route() -> ResponseReturnValue:
|
|||||||
|
|
||||||
search_name = request.form.get("name", "").strip()
|
search_name = request.form.get("name", "").strip()
|
||||||
query_params = request.form.get("query_params", "")
|
query_params = request.form.get("query_params", "")
|
||||||
print(
|
|
||||||
f"Saving search for user {viewing_user.username}: {search_name} -> {query_params}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not search_name:
|
if not search_name:
|
||||||
flash("Search name is required", "error")
|
flash("Search name is required", "error")
|
||||||
return redirect(url_for("main.index", q=query_params))
|
return redirect(url_for("main.index", q=query_params))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = save_search(viewing_user, search_name, query_params)
|
success = _save_search(viewing_user, search_name, query_params)
|
||||||
if success:
|
if success:
|
||||||
flash(f"Search '{search_name}' saved successfully!", "success")
|
flash(f"Search '{search_name}' saved successfully!", "success")
|
||||||
else:
|
else:
|
||||||
@@ -411,6 +469,70 @@ def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue:
|
|||||||
return redirect(url_for("main.book_detail", book_id=book_id))
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/reading/<int:reading_id>/update", methods=["POST"])
|
||||||
|
def update_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
|
||||||
|
"""Update a single reading session."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to update readings", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get and verify the reading belongs to current user
|
||||||
|
reading = db.session.get(Reading, reading_id)
|
||||||
|
if not reading or reading.user_id != viewing_user.id:
|
||||||
|
flash("Reading not found or not yours to modify", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
# Validate the form data
|
||||||
|
form_data = ReadingFormData.model_validate(dict(request.form))
|
||||||
|
|
||||||
|
# Update the reading with validated data
|
||||||
|
reading.start_date = form_data.start_date
|
||||||
|
reading.end_date = form_data.end_date
|
||||||
|
reading.dropped = form_data.dropped
|
||||||
|
reading.rating = form_data.rating
|
||||||
|
reading.comments = form_data.comments
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash("Reading updated successfully!", "success")
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
db.session.rollback() # Rollback any partial changes on validation error
|
||||||
|
_flash_validation_errors(e)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback() # Rollback any partial changes on general error
|
||||||
|
flash(f"Error updating reading: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/reading/<int:reading_id>/delete", methods=["POST"])
|
||||||
|
def delete_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
|
||||||
|
"""Delete a reading session."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to delete readings", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify the reading belongs to the current user
|
||||||
|
reading = db.session.get(Reading, reading_id)
|
||||||
|
if reading and reading.user_id == viewing_user.id:
|
||||||
|
deleted = library.delete_reading(reading_id)
|
||||||
|
if deleted:
|
||||||
|
flash("Reading session deleted", "info")
|
||||||
|
else:
|
||||||
|
flash("Reading session not found", "warning")
|
||||||
|
else:
|
||||||
|
flash("Reading session not found or not yours to delete", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error deleting reading: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/saved-search/<search_name>/delete", methods=["GET", "POST"])
|
@bp.route("/saved-search/<search_name>/delete", methods=["GET", "POST"])
|
||||||
def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
|
def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
|
||||||
"""Delete a saved search (GET shows confirmation, POST performs deletion)."""
|
"""Delete a saved search (GET shows confirmation, POST performs deletion)."""
|
||||||
@@ -428,7 +550,7 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
# Perform the actual deletion
|
# Perform the actual deletion
|
||||||
try:
|
try:
|
||||||
success = delete_saved_search(viewing_user, search_name)
|
success = _delete_saved_search(viewing_user, search_name)
|
||||||
if success:
|
if success:
|
||||||
flash(f"Saved search '{search_name}' deleted successfully!", "success")
|
flash(f"Saved search '{search_name}' deleted successfully!", "success")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
.book-card:hover {
|
.book-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.book-card .card-img-top {
|
.book-card .card-img-top {
|
||||||
@@ -69,24 +69,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-card-footer {
|
.book-card-footer {
|
||||||
background-color: rgba(0,0,0,0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search Bar */
|
|
||||||
.search-container {
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Styles */
|
/* Form Styles */
|
||||||
.form-floating > .form-control:focus,
|
.form-floating>.form-control:focus,
|
||||||
.form-floating > .form-control:not(:placeholder-shown) {
|
.form-floating>.form-control:not(:placeholder-shown) {
|
||||||
padding-top: 1.625rem;
|
padding-top: 1.625rem;
|
||||||
padding-bottom: .625rem;
|
padding-bottom: .625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-floating > .form-control:focus ~ label,
|
.form-floating>.form-control:focus~label,
|
||||||
.form-floating > .form-control:not(:placeholder-shown) ~ label {
|
.form-floating>.form-control:not(:placeholder-shown)~label {
|
||||||
opacity: .65;
|
opacity: .65;
|
||||||
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,19 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}{% endblock %} - HXBooks</title>
|
<title>{% block title %}{% endblock %} - HXBooks</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}">
|
{#
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}"> #}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
|
{#
|
||||||
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script> #}
|
||||||
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script>
|
{#
|
||||||
|
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script> #}
|
||||||
|
{#
|
||||||
|
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script> #}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// HTMX error handling
|
// HTMX error handling
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h3 mb-0">Library</h1>
|
{# <h1 class="h3 mb-0">Library</h1> #}
|
||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="search-container mx-3 flex-grow-1">
|
<div class="search-container mx-3 flex-grow-1">
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-6">
|
||||||
<label for="title" class="form-label">Title *</label>
|
<label for="title" class="form-label">Title *</label>
|
||||||
<input type="text" class="form-control" id="title" name="title" value="{{ book.title if book else '' }}" required>
|
<input type="text" class="form-control" id="title" name="title" value="{{ book.title if book else '' }}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
|
<label for="owner" class="form-label">Owner</label>
|
||||||
|
<input type="text" class="form-control" id="owner" name="owner"
|
||||||
|
value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}"
|
||||||
|
placeholder="Username">
|
||||||
|
<div class="form-text">Leave empty for no owner</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
<label for="isbn" class="form-label">ISBN</label>
|
<label for="isbn" class="form-label">ISBN</label>
|
||||||
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}">
|
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}">
|
||||||
</div>
|
</div>
|
||||||
@@ -70,6 +77,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loan Information -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="loaned_to" class="form-label">Loaned To</label>
|
||||||
|
<input type="text" class="form-control" id="loaned_to" name="loaned_to"
|
||||||
|
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="loaned_date" class="form-label">Loan Date</label>
|
||||||
|
<input type="date" class="form-control" id="loaned_date" name="loaned_date"
|
||||||
|
value="{{ book.loaned_date.strftime('%Y-%m-%d') if book and book.loaned_date else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="notes" class="form-label">Personal Notes</label>
|
<label for="notes" class="form-label">Personal Notes</label>
|
||||||
|
|||||||
@@ -2,52 +2,124 @@
|
|||||||
{% if g.viewing_user %}
|
{% if g.viewing_user %}
|
||||||
{% set user_readings = book.readings | selectattr('user_id', 'equalto', g.viewing_user.id) | list %}
|
{% set user_readings = book.readings | selectattr('user_id', 'equalto', g.viewing_user.id) | list %}
|
||||||
{% set current_reading = user_readings | selectattr('end_date', 'none') | selectattr('dropped', 'false') | first %}
|
{% set current_reading = user_readings | selectattr('end_date', 'none') | selectattr('dropped', 'false') | first %}
|
||||||
{% set reading_history = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %}
|
{% set completed_readings = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %}
|
||||||
|
{% set latest_rated_reading = completed_readings | selectattr('rating') | first %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h6 class="text-muted mb-2">📖 Reading Status</h6>
|
<h6 class="text-muted mb-2">📖 Reading Status</h6>
|
||||||
|
|
||||||
<!-- Current Reading Status -->
|
<!-- Action Buttons -->
|
||||||
{% if current_reading %}
|
<div class="mb-3">
|
||||||
<div class="alert alert-info py-2">
|
{% if current_reading %}
|
||||||
<strong>Currently Reading</strong><br>
|
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
|
||||||
<small>Started: {{ current_reading.start_date.strftime('%B %d, %Y') }}</small>
|
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
|
||||||
</div>
|
</form>
|
||||||
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
|
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
|
||||||
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
|
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
|
{% else %}
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
|
<form action="/book/{{ book.id }}/reading/start" method="POST" class="d-inline">
|
||||||
</form>
|
<button type="submit" class="btn btn-primary btn-sm">▶️ Start Reading</button>
|
||||||
{% else %}
|
</form>
|
||||||
<!-- Not currently reading -->
|
|
||||||
{% if reading_history %}
|
|
||||||
<p class="text-muted small mb-2">Previously read</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-muted small mb-2">Not read yet</p>
|
|
||||||
{% endif %}
|
|
||||||
<form action="/book/{{ book.id }}/reading/start" method="POST" class="d-inline">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm">▶️ Start Reading</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Reading History Summary -->
|
|
||||||
{% if reading_history %}
|
|
||||||
<div class="mt-3">
|
|
||||||
<small class="text-muted">Reading History:</small>
|
|
||||||
{% for reading in reading_history[:3] %}
|
|
||||||
<div class="mt-1">
|
|
||||||
<small>
|
|
||||||
{% if not reading.dropped %} ✓ Finished {% else %} ⏸ Dropped {% endif %}
|
|
||||||
{{ reading.start_date.strftime('%m/%d/%Y') }} - {{ reading.end_date.strftime('%m/%d/%Y') }}
|
|
||||||
- ⭐{{ reading.rating or "-" }}/5
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% if reading_history | length > 3 %}
|
|
||||||
<small class="text-muted">... and {{ reading_history | length - 3 }} more</small>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Editable Reading Data -->
|
||||||
|
{% if user_readings %}
|
||||||
|
|
||||||
|
<!-- Current Book Rating (if any completed readings) -->
|
||||||
|
{% if completed_readings %}
|
||||||
|
<div class="alert alert-light border py-2 mb-3">
|
||||||
|
<form action="/book/{{ book.id }}/reading/{{ completed_readings[0].id }}/update" method="POST"
|
||||||
|
class="row align-items-center g-2">
|
||||||
|
<!-- Hidden fields to preserve other reading data -->
|
||||||
|
<input type="hidden" name="start_date" value="{{ completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
|
||||||
|
<input type="hidden" name="end_date"
|
||||||
|
value="{{ completed_readings[0].end_date.strftime('%Y-%m-%d') if completed_readings[0].end_date else '' }}">
|
||||||
|
<input type="hidden" name="dropped" value="1" {{ 'checked' if completed_readings[0].dropped else '' }}>
|
||||||
|
<input type="hidden" name="comments" value="{{ completed_readings[0].comments or '' }}">
|
||||||
|
|
||||||
|
<div class="col-auto">
|
||||||
|
<label class="form-label mb-0"><strong>Book Rating:</strong></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select class="form-select form-select-sm" name="rating" style="width: auto;">
|
||||||
|
<option value="">No rating</option>
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<option value="{{ i }}" {{ 'selected' if latest_rated_reading and latest_rated_reading.rating==i else '' }}>
|
||||||
|
{{ i }} ⭐</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">💾</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- All Reading Sessions -->
|
||||||
|
{% for reading in user_readings | sort(attribute='start_date', reverse=true) %}
|
||||||
|
<div class="border rounded p-3 mb-2 {% if reading == current_reading %}border-primary bg-light{% endif %}">
|
||||||
|
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label-sm">Start Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" name="start_date"
|
||||||
|
value="{{ reading.start_date.strftime('%Y-%m-%d') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label-sm">End Date</label>
|
||||||
|
<input type="date" class="form-control form-control-sm" name="end_date"
|
||||||
|
value="{{ reading.end_date.strftime('%Y-%m-%d') if reading.end_date else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="dropped" value="1" {{ 'checked' if reading.dropped
|
||||||
|
else '' }}>
|
||||||
|
<label class="form-check-label">Dropped</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="rating" value="{{ reading.rating or '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label-sm">Comments</label>
|
||||||
|
<textarea class="form-control form-control-sm" rows="2" name="comments"
|
||||||
|
placeholder="Reading notes and comments...">{{ reading.comments or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reading Status Display and Actions -->
|
||||||
|
<div class="mt-2 d-flex justify-content-between align-items-center">
|
||||||
|
<small class="text-muted">
|
||||||
|
{% if reading == current_reading %}
|
||||||
|
<span class="badge bg-primary">Currently Reading</span>
|
||||||
|
{% elif reading.dropped %}
|
||||||
|
<span class="badge bg-secondary">Dropped</span>
|
||||||
|
{% elif reading.end_date %}
|
||||||
|
<span class="badge bg-success">Completed</span>
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm me-1" title="Save changes">
|
||||||
|
💾
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="document.getElementById('delete-form-{{ reading.id }}').submit()" title="Delete reading session">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Separate delete form -->
|
||||||
|
<form id="delete-form-{{ reading.id }}" action="/book/{{ book.id }}/reading/{{ reading.id }}/delete" method="POST"
|
||||||
|
style="display: none;"></form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
15
uv.lock
generated
15
uv.lock
generated
@@ -221,6 +221,7 @@ dependencies = [
|
|||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "jinja2-fragments" },
|
{ name = "jinja2-fragments" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-extra-types" },
|
||||||
{ name = "pyparsing" },
|
{ name = "pyparsing" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
@@ -246,6 +247,7 @@ requires-dist = [
|
|||||||
{ name = "gunicorn", specifier = ">=25.1.0" },
|
{ name = "gunicorn", specifier = ">=25.1.0" },
|
||||||
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
|
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||||
|
{ name = "pydantic-extra-types", specifier = ">=2.11.1" },
|
||||||
{ name = "pyparsing", specifier = ">=3.3.2" },
|
{ name = "pyparsing", specifier = ">=3.3.2" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
{ name = "sqlalchemy", specifier = ">=2.0.48" },
|
||||||
@@ -480,6 +482,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-extra-types"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.19.2"
|
version = "2.19.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user