|
|
import json |
|
|
import re |
|
|
from datetime import date, datetime |
|
|
from enum import IntEnum |
|
|
from pathlib import Path |
|
|
from typing import Any, List, Optional, Callable |
|
|
|
|
|
import httpx |
|
|
|
|
|
BASE_URL = "https://brestok-vika-server.hf.space" |
|
|
DOWNLOADS_DIR = Path(__file__).parent / "downloads" |
|
|
SEPARATOR = "=" * 70 |
|
|
PROMPT = "Select option: " |
|
|
|
|
|
GENRES = [ |
|
|
"Fiction", |
|
|
"Non-Fiction", |
|
|
"Mystery", |
|
|
"Sci-Fi", |
|
|
"Fantasy", |
|
|
"Biography", |
|
|
"History", |
|
|
"Romance", |
|
|
"Thriller", |
|
|
"Horror", |
|
|
"Poetry", |
|
|
"Drama", |
|
|
"Comics", |
|
|
"Other", |
|
|
] |
|
|
|
|
|
BOOK_STATUSES = ["Available", "Borrowed"] |
|
|
|
|
|
WORK_DAYS = [ |
|
|
"Monday", |
|
|
"Tuesday", |
|
|
"Wednesday", |
|
|
"Thursday", |
|
|
"Friday", |
|
|
"Saturday", |
|
|
"Sunday", |
|
|
] |
|
|
|
|
|
NAME_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ\s\-']+$") |
|
|
TITLE_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ0-9\s\-'.,!?:;\"()]+$") |
|
|
UUID_PATTERN = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") |
|
|
|
|
|
|
|
|
MSG_IS_REQUIRED = "is required." |
|
|
MSG_AT_LEAST_2_CHARS = "must be at least 2 characters." |
|
|
MSG_AT_MOST_50_CHARS = "must be at most 50 characters." |
|
|
MSG_ONLY_LETTERS_SPACES_HYPHENS_APOSTROPHES = "must contain only letters, spaces, hyphens, and apostrophes." |
|
|
MSG_AT_MOST_100_CHARS = "must be at most 100 characters." |
|
|
MSG_CONTAINS_INVALID_CHARS = "contains invalid characters." |
|
|
MSG_AUTHOR_REQUIRED = "Author is required." |
|
|
MSG_AUTHOR_AT_LEAST_2_CHARS = "Author must be at least 2 characters." |
|
|
MSG_AUTHOR_AT_MOST_100_CHARS = "Author must be at most 100 characters." |
|
|
MSG_AUTHOR_ONLY_LETTERS = "Author must contain only letters, spaces, hyphens, and apostrophes." |
|
|
MSG_PAGES_AT_LEAST_1 = "Pages must be at least 1." |
|
|
MSG_PAGES_AT_MOST_10000 = "Pages must be at most 10000." |
|
|
MSG_YEAR_AT_LEAST_1000 = "Year must be at least 1000." |
|
|
MSG_YEAR_CANNOT_BE_GREATER = "Year cannot be greater than {current_year}." |
|
|
MSG_GENRE_MUST_BE_ONE_OF = "Genre must be one of: {', '.join(GENRES)}" |
|
|
MSG_STATUS_MUST_BE_ONE_OF = "Status must be one of: {', '.join(BOOK_STATUSES)}" |
|
|
MSG_EXPERIENCE_NEGATIVE = "Experience cannot be negative." |
|
|
MSG_EXPERIENCE_AT_MOST_60 = "Experience must be at most 60 years." |
|
|
MSG_AT_LEAST_ONE_WORK_DAY = "At least one work day is required." |
|
|
MSG_INVALID_WORK_DAYS = "Invalid work days: {', '.join(invalid)}. Valid options: {', '.join(WORK_DAYS)}" |
|
|
MSG_MUST_BE_VALID_UUID = "must be a valid UUID format." |
|
|
MSG_DATE_YYYY_MM_DD = "Date must be in YYYY-MM-DD format." |
|
|
MSG_AT_LEAST_ONE_ITEM_REQUIRED = "At least one item is required." |
|
|
MSG_ITEM_EMPTY = "Item #{i} is empty." |
|
|
MSG_ITEM_INVALID_UUID = "Item #{i} ({id_val}) is not a valid UUID." |
|
|
|
|
|
|
|
|
MSG_ERROR_ENTER_VALID_INTEGER = " Error: Enter a valid integer." |
|
|
MSG_ERROR_INVALID_CHOICE = " Error: Invalid choice." |
|
|
MSG_ERROR_INVALID_NUMBER = " Error: Invalid number {p}." |
|
|
MSG_ERROR_INVALID_VALUE = " Error: Invalid value '{p}'." |
|
|
MSG_ERROR_AT_LEAST_ONE_VALUE = " Error: At least one value is required." |
|
|
MSG_ERROR_BOOK_NOT_FOUND = " Error: Book with ID {book_id} not found." |
|
|
MSG_ERROR_BOOK_NOT_FOUND_VALIDATED = " Error: Book with ID {validated} not found." |
|
|
MSG_ERROR_VISITOR_NOT_FOUND = " Error: Visitor with ID {validated} not found." |
|
|
MSG_ERROR_WORKER_NOT_FOUND = " Error: Worker with ID {validated} not found." |
|
|
MSG_NO_BOOKS_FOUND = "No books found." |
|
|
MSG_NO_VISITORS_FOUND = "No visitors found." |
|
|
MSG_NO_WORKERS_FOUND = "No workers found." |
|
|
MSG_ERROR_PREFIX = "Error: " |
|
|
MSG_INVALID_OPTION = "Invalid option." |
|
|
MSG_GOODBYE = "Goodbye." |
|
|
MSG_NO_FIELDS_TO_UPDATE = "No fields to update." |
|
|
MSG_DELETED = "Deleted." |
|
|
MSG_FILE_SAVED_TO = "File saved to: " |
|
|
MSG_CURRENT_BOOKS = "\nCurrent books:" |
|
|
MSG_HISTORY = "\nHistory:" |
|
|
MSG_VISITOR_DELETED = "Visitor deleted." |
|
|
MSG_WORKER_DELETED = "Worker deleted." |
|
|
MSG_INTERRUPTED_GOODBYE = "\nInterrupted. Goodbye." |
|
|
|
|
|
|
|
|
MSG_USE_COMMA_OR_STAR = "Use comma to separate values or type * for all." |
|
|
MSG_ENTER_NUMBERS_OR_NAMES = "Enter numbers or names: " |
|
|
MSG_USE_COMMA_STAR_OR_EMPTY = "Use comma to separate values, * for all, or leave empty to skip." |
|
|
MSG_ENTER_NUMBERS_OR_NAMES_EMPTY_SKIP = "Enter numbers or names (empty to skip): " |
|
|
MSG_PICK_NUMBER = "Pick number: " |
|
|
MSG_PICK_NUMBER_SKIP = "Pick number (0 to skip): " |
|
|
MSG_BOOK_ID_PROMPT = "Book ID: " |
|
|
MSG_BOOK_IDS_COMMA_SEPARATED = "Book IDs (comma separated): " |
|
|
MSG_VISITOR_ID_PROMPT = "Visitor ID: " |
|
|
MSG_WORKER_ID_PROMPT = "Worker ID: " |
|
|
MSG_TITLE_PROMPT = "Title: " |
|
|
MSG_AUTHOR_PROMPT = "Author: " |
|
|
MSG_PAGES_PROMPT = "Pages: " |
|
|
MSG_YEAR_PROMPT = "Year: " |
|
|
MSG_GENRE_PROMPT = "Genre:" |
|
|
MSG_STATUS_PROMPT = "Status:" |
|
|
MSG_NAME_PROMPT = "Name: " |
|
|
MSG_SURNAME_PROMPT = "Surname: " |
|
|
MSG_EXPERIENCE_PROMPT = "Experience (years): " |
|
|
MSG_WORK_DAYS_PROMPT = "Work days:" |
|
|
MSG_SELECT_WORK_DAYS = "Select work days:" |
|
|
MSG_TITLE_SKIP_PROMPT = "Title (empty to skip): " |
|
|
MSG_AUTHOR_SKIP_PROMPT = "Author (empty to skip): " |
|
|
MSG_PAGES_SKIP_PROMPT = "Pages (empty to skip): " |
|
|
MSG_YEAR_SKIP_PROMPT = "Year (empty to skip): " |
|
|
MSG_NAME_SKIP_PROMPT = "Name (empty to skip): " |
|
|
MSG_SURNAME_SKIP_PROMPT = "Surname (empty to skip): " |
|
|
MSG_EXPERIENCE_SKIP_PROMPT = "Experience (empty to skip): " |
|
|
MSG_LEAVE_EMPTY_TO_SKIP = "Leave fields empty to skip them." |
|
|
MSG_BORROW_DATE_PROMPT = "Borrow date (YYYY-MM-DD)" |
|
|
MSG_RETURN_DATE_PROMPT = "Return date (YYYY-MM-DD)" |
|
|
|
|
|
|
|
|
class ValidationError(Exception): |
|
|
pass |
|
|
|
|
|
|
|
|
def ensure_downloads_dir(): |
|
|
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
|
|
|
def save_to_file(table: str, entity_id: str, content: str) -> str: |
|
|
ensure_downloads_dir() |
|
|
safe_id = entity_id.replace("/", "_").replace("\\", "_") |
|
|
filename = f"{table}-{safe_id}.json" |
|
|
filepath = DOWNLOADS_DIR / filename |
|
|
if isinstance(content, (dict, list)): |
|
|
text = json.dumps(content, ensure_ascii=False, indent=2) |
|
|
else: |
|
|
try: |
|
|
parsed = json.loads(content) |
|
|
text = json.dumps(parsed, ensure_ascii=False, indent=2) |
|
|
except Exception: |
|
|
text = content |
|
|
filepath.write_text(text, encoding="utf-8") |
|
|
return str(filepath) |
|
|
|
|
|
|
|
|
def validate_name(value: str, field_name: str = "Name") -> str: |
|
|
value = value.strip() |
|
|
if not value: |
|
|
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}") |
|
|
if len(value) < 2: |
|
|
raise ValidationError(f"{field_name} {MSG_AT_LEAST_2_CHARS}") |
|
|
if len(value) > 50: |
|
|
raise ValidationError(f"{field_name} {MSG_AT_MOST_50_CHARS}") |
|
|
if not NAME_PATTERN.match(value): |
|
|
raise ValidationError(f"{field_name} {MSG_ONLY_LETTERS_SPACES_HYPHENS_APOSTROPHES}") |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_title(value: str, field_name: str = "Title") -> str: |
|
|
value = value.strip() |
|
|
if not value: |
|
|
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}") |
|
|
if len(value) < 2: |
|
|
raise ValidationError(f"{field_name} {MSG_AT_LEAST_2_CHARS}") |
|
|
if len(value) > 100: |
|
|
raise ValidationError(f"{field_name} {MSG_AT_MOST_100_CHARS}") |
|
|
if not TITLE_PATTERN.match(value): |
|
|
raise ValidationError(f"{field_name} {MSG_CONTAINS_INVALID_CHARS}") |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_author(value: str) -> str: |
|
|
value = value.strip() |
|
|
if not value: |
|
|
raise ValidationError(MSG_AUTHOR_REQUIRED) |
|
|
if len(value) < 2: |
|
|
raise ValidationError(MSG_AUTHOR_AT_LEAST_2_CHARS) |
|
|
if len(value) > 100: |
|
|
raise ValidationError(MSG_AUTHOR_AT_MOST_100_CHARS) |
|
|
if not NAME_PATTERN.match(value): |
|
|
raise ValidationError(MSG_AUTHOR_ONLY_LETTERS) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_pages(value: int) -> int: |
|
|
if value < 1: |
|
|
raise ValidationError(MSG_PAGES_AT_LEAST_1) |
|
|
if value > 10000: |
|
|
raise ValidationError(MSG_PAGES_AT_MOST_10000) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_year(value: int) -> int: |
|
|
current_year = date.today().year |
|
|
if value < 1000: |
|
|
raise ValidationError(MSG_YEAR_AT_LEAST_1000) |
|
|
if value > current_year: |
|
|
raise ValidationError(MSG_YEAR_CANNOT_BE_GREATER) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_genre(value: str) -> str: |
|
|
if value not in GENRES: |
|
|
raise ValidationError(MSG_GENRE_MUST_BE_ONE_OF) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_status(value: str) -> str: |
|
|
if value not in BOOK_STATUSES: |
|
|
raise ValidationError(MSG_STATUS_MUST_BE_ONE_OF) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_experience(value: int) -> int: |
|
|
if value < 0: |
|
|
raise ValidationError(MSG_EXPERIENCE_NEGATIVE) |
|
|
if value > 60: |
|
|
raise ValidationError(MSG_EXPERIENCE_AT_MOST_60) |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_work_days(days: List[str]) -> List[str]: |
|
|
if not days: |
|
|
raise ValidationError(MSG_AT_LEAST_ONE_WORK_DAY) |
|
|
invalid = [d for d in days if d not in WORK_DAYS] |
|
|
if invalid: |
|
|
raise ValidationError(MSG_INVALID_WORK_DAYS) |
|
|
return days |
|
|
|
|
|
|
|
|
def validate_uuid(value: str, field_name: str = "ID") -> str: |
|
|
value = value.strip() |
|
|
if not value: |
|
|
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}") |
|
|
if not UUID_PATTERN.match(value): |
|
|
raise ValidationError(f"{field_name} {MSG_MUST_BE_VALID_UUID}") |
|
|
return value |
|
|
|
|
|
|
|
|
def validate_date(value: str) -> str: |
|
|
value = value.strip() |
|
|
if not value: |
|
|
return date.today().isoformat() |
|
|
try: |
|
|
datetime.strptime(value, "%Y-%m-%d") |
|
|
return value |
|
|
except ValueError: |
|
|
raise ValidationError(MSG_DATE_YYYY_MM_DD) |
|
|
|
|
|
|
|
|
def validate_ids_list(ids: List[str]) -> List[str]: |
|
|
if not ids: |
|
|
raise ValidationError(MSG_AT_LEAST_ONE_ITEM_REQUIRED) |
|
|
validated = [] |
|
|
for i, id_val in enumerate(ids, 1): |
|
|
id_val = id_val.strip() |
|
|
if not id_val: |
|
|
raise ValidationError(MSG_ITEM_EMPTY) |
|
|
if not UUID_PATTERN.match(id_val): |
|
|
raise ValidationError(MSG_ITEM_INVALID_UUID) |
|
|
validated.append(id_val) |
|
|
return validated |
|
|
|
|
|
|
|
|
class LibraryClient: |
|
|
def __init__(self, base_url: str = BASE_URL): |
|
|
self.client = httpx.Client(base_url=base_url, timeout=30.0) |
|
|
|
|
|
def close(self): |
|
|
self.client.close() |
|
|
|
|
|
def _handle_response(self, response: httpx.Response): |
|
|
content_type = response.headers.get("content-type", "").lower() |
|
|
if response.status_code >= 400: |
|
|
message = response.text |
|
|
try: |
|
|
payload = response.json() |
|
|
if isinstance(payload, dict): |
|
|
message = ( |
|
|
payload.get("error", {}).get("message") |
|
|
or payload.get("message") |
|
|
or str(payload) |
|
|
) |
|
|
except Exception: |
|
|
message = response.text |
|
|
raise RuntimeError(f"{response.status_code}: {message}") |
|
|
if "application/json" in content_type: |
|
|
payload = response.json() |
|
|
if isinstance(payload, dict) and "successful" in payload: |
|
|
if payload.get("successful"): |
|
|
return payload.get("data") |
|
|
error = payload.get("error") or {} |
|
|
raise RuntimeError(error.get("message") or str(payload)) |
|
|
return payload |
|
|
return response |
|
|
|
|
|
def get(self, path: str, **kwargs): |
|
|
response = self.client.get(path, **kwargs) |
|
|
return self._handle_response(response) |
|
|
|
|
|
def post(self, path: str, **kwargs): |
|
|
response = self.client.post(path, **kwargs) |
|
|
return self._handle_response(response) |
|
|
|
|
|
def patch(self, path: str, **kwargs): |
|
|
response = self.client.patch(path, **kwargs) |
|
|
return self._handle_response(response) |
|
|
|
|
|
def delete(self, path: str, **kwargs): |
|
|
response = self.client.delete(path, **kwargs) |
|
|
return self._handle_response(response) |
|
|
|
|
|
def list_books(self): |
|
|
return self.get("/books/all") |
|
|
|
|
|
def get_book(self, book_id: str): |
|
|
return self.get(f"/books/{book_id}") |
|
|
|
|
|
def book_exists(self, book_id: str) -> bool: |
|
|
try: |
|
|
self.get_book(book_id) |
|
|
return True |
|
|
except RuntimeError: |
|
|
return False |
|
|
|
|
|
def create_book( |
|
|
self, |
|
|
title: str, |
|
|
author: str, |
|
|
pages: int, |
|
|
year: int, |
|
|
genre: str, |
|
|
status: str = "Available", |
|
|
): |
|
|
payload = { |
|
|
"title": title, |
|
|
"author": author, |
|
|
"pages": pages, |
|
|
"year": year, |
|
|
"genre": genre, |
|
|
"status": status, |
|
|
} |
|
|
return self.post("/books/create", json=payload) |
|
|
|
|
|
def update_book( |
|
|
self, |
|
|
book_id: str, |
|
|
title: Optional[str] = None, |
|
|
author: Optional[str] = None, |
|
|
pages: Optional[int] = None, |
|
|
year: Optional[int] = None, |
|
|
genre: Optional[str] = None, |
|
|
status: Optional[str] = None, |
|
|
): |
|
|
payload = { |
|
|
k: v |
|
|
for k, v in { |
|
|
"title": title, |
|
|
"author": author, |
|
|
"pages": pages, |
|
|
"year": year, |
|
|
"genre": genre, |
|
|
"status": status, |
|
|
}.items() |
|
|
if v not in (None, "") |
|
|
} |
|
|
return self.patch(f"/books/{book_id}", json=payload) |
|
|
|
|
|
def delete_book(self, book_id: str): |
|
|
return self.delete(f"/books/{book_id}") |
|
|
|
|
|
def borrow_books( |
|
|
self, book_ids: List[str], visitor_id: str, worker_id: str, borrow_date: str |
|
|
): |
|
|
payload = { |
|
|
"bookIds": book_ids, |
|
|
"visitorId": visitor_id, |
|
|
"workerId": worker_id, |
|
|
"borrowDate": borrow_date, |
|
|
} |
|
|
return self.post("/books/borrow", json=payload) |
|
|
|
|
|
def return_books( |
|
|
self, book_ids: List[str], visitor_id: str, worker_id: str, return_date: str |
|
|
): |
|
|
payload = { |
|
|
"bookIds": book_ids, |
|
|
"visitorId": visitor_id, |
|
|
"workerId": worker_id, |
|
|
"returnDate": return_date, |
|
|
} |
|
|
return self.post("/books/return", json=payload) |
|
|
|
|
|
def download_book(self, book_id: str) -> str: |
|
|
response = self.get(f"/books/{book_id}/download") |
|
|
return response.text if isinstance(response, httpx.Response) else str(response) |
|
|
|
|
|
def list_visitors(self): |
|
|
return self.get("/visitors/all") |
|
|
|
|
|
def get_visitor(self, visitor_id: str): |
|
|
return self.get(f"/visitors/{visitor_id}") |
|
|
|
|
|
def visitor_exists(self, visitor_id: str) -> bool: |
|
|
try: |
|
|
self.get_visitor(visitor_id) |
|
|
return True |
|
|
except RuntimeError: |
|
|
return False |
|
|
|
|
|
def create_visitor(self, name: str, surname: str): |
|
|
payload = {"name": name, "surname": surname} |
|
|
return self.post("/visitors/create", json=payload) |
|
|
|
|
|
def update_visitor( |
|
|
self, |
|
|
visitor_id: str, |
|
|
name: Optional[str] = None, |
|
|
surname: Optional[str] = None, |
|
|
): |
|
|
payload = {k: v for k, v in {"name": name, "surname": surname}.items() if v} |
|
|
return self.patch(f"/visitors/{visitor_id}", json=payload) |
|
|
|
|
|
def delete_visitor(self, visitor_id: str): |
|
|
return self.delete(f"/visitors/delete/{visitor_id}") |
|
|
|
|
|
def download_visitor(self, visitor_id: str) -> str: |
|
|
response = self.get(f"/visitors/{visitor_id}/download") |
|
|
return response.text if isinstance(response, httpx.Response) else str(response) |
|
|
|
|
|
def list_workers(self): |
|
|
return self.get("/workers/all") |
|
|
|
|
|
def get_worker(self, worker_id: str): |
|
|
return self.get(f"/workers/{worker_id}") |
|
|
|
|
|
def worker_exists(self, worker_id: str) -> bool: |
|
|
try: |
|
|
self.get_worker(worker_id) |
|
|
return True |
|
|
except RuntimeError: |
|
|
return False |
|
|
|
|
|
def create_worker( |
|
|
self, name: str, surname: str, experience: int, work_days: List[str] |
|
|
): |
|
|
payload = { |
|
|
"name": name, |
|
|
"surname": surname, |
|
|
"experience": experience, |
|
|
"workDays": work_days, |
|
|
} |
|
|
return self.post("/workers/create", json=payload) |
|
|
|
|
|
def update_worker( |
|
|
self, |
|
|
worker_id: str, |
|
|
name: Optional[str] = None, |
|
|
surname: Optional[str] = None, |
|
|
experience: Optional[int] = None, |
|
|
work_days: Optional[List[str]] = None, |
|
|
): |
|
|
payload = { |
|
|
k: v |
|
|
for k, v in { |
|
|
"name": name, |
|
|
"surname": surname, |
|
|
"experience": experience, |
|
|
"workDays": work_days, |
|
|
}.items() |
|
|
if v not in (None, "") |
|
|
} |
|
|
return self.patch(f"/workers/{worker_id}", json=payload) |
|
|
|
|
|
def delete_worker(self, worker_id: str): |
|
|
return self.delete(f"/workers/{worker_id}") |
|
|
|
|
|
def workers_by_days(self, work_days: List[str]): |
|
|
params = [("workDays", day) for day in work_days] |
|
|
return self.get("/workers/by-work-days", params=params) |
|
|
|
|
|
def download_worker(self, worker_id: str) -> str: |
|
|
response = self.get(f"/workers/{worker_id}/download") |
|
|
return response.text if isinstance(response, httpx.Response) else str(response) |
|
|
|
|
|
|
|
|
class MenuChoice(IntEnum): |
|
|
LIST_BOOKS = 1 |
|
|
VIEW_BOOK = 2 |
|
|
CREATE_BOOK = 3 |
|
|
UPDATE_BOOK = 4 |
|
|
DELETE_BOOK = 5 |
|
|
BORROW_BOOKS = 6 |
|
|
RETURN_BOOKS = 7 |
|
|
DOWNLOAD_BOOK = 8 |
|
|
LIST_VISITORS = 9 |
|
|
VIEW_VISITOR = 10 |
|
|
CREATE_VISITOR = 11 |
|
|
UPDATE_VISITOR = 12 |
|
|
DELETE_VISITOR = 13 |
|
|
DOWNLOAD_VISITOR = 14 |
|
|
LIST_WORKERS = 15 |
|
|
VIEW_WORKER = 16 |
|
|
CREATE_WORKER = 17 |
|
|
UPDATE_WORKER = 18 |
|
|
DELETE_WORKER = 19 |
|
|
WORKERS_BY_DAYS = 20 |
|
|
DOWNLOAD_WORKER = 21 |
|
|
EXIT = 0 |
|
|
|
|
|
|
|
|
MENU_TEXT = { |
|
|
MenuChoice.LIST_BOOKS: "List books", |
|
|
MenuChoice.VIEW_BOOK: "Get book by ID", |
|
|
MenuChoice.CREATE_BOOK: "Create book", |
|
|
MenuChoice.UPDATE_BOOK: "Update book", |
|
|
MenuChoice.DELETE_BOOK: "Delete book", |
|
|
MenuChoice.BORROW_BOOKS: "Borrow books", |
|
|
MenuChoice.RETURN_BOOKS: "Return books", |
|
|
MenuChoice.DOWNLOAD_BOOK: "Download book as file", |
|
|
MenuChoice.LIST_VISITORS: "List visitors", |
|
|
MenuChoice.VIEW_VISITOR: "Get visitor by ID", |
|
|
MenuChoice.CREATE_VISITOR: "Create visitor", |
|
|
MenuChoice.UPDATE_VISITOR: "Update visitor", |
|
|
MenuChoice.DELETE_VISITOR: "Delete visitor", |
|
|
MenuChoice.DOWNLOAD_VISITOR: "Download visitor as file", |
|
|
MenuChoice.LIST_WORKERS: "List workers", |
|
|
MenuChoice.VIEW_WORKER: "Get worker by ID", |
|
|
MenuChoice.CREATE_WORKER: "Create worker", |
|
|
MenuChoice.UPDATE_WORKER: "Update worker", |
|
|
MenuChoice.DELETE_WORKER: "Delete worker", |
|
|
MenuChoice.WORKERS_BY_DAYS: "Find workers by work days", |
|
|
MenuChoice.DOWNLOAD_WORKER: "Download worker as file", |
|
|
MenuChoice.EXIT: "Exit", |
|
|
} |
|
|
|
|
|
|
|
|
def prompt_with_validation(label: str, validator: Callable[[str], str]) -> str: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
try: |
|
|
return validator(raw) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_int_with_validation(label: str, validator: Callable[[int], int]) -> int: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
try: |
|
|
value = int(raw) |
|
|
return validator(value) |
|
|
except ValueError: |
|
|
print(MSG_ERROR_ENTER_VALID_INTEGER) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_optional_with_validation(label: str, validator: Callable[[str], str]) -> Optional[str]: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
if not raw: |
|
|
return None |
|
|
try: |
|
|
return validator(raw) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_optional_int_with_validation(label: str, validator: Callable[[int], int]) -> Optional[int]: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
if not raw: |
|
|
return None |
|
|
try: |
|
|
value = int(raw) |
|
|
return validator(value) |
|
|
except ValueError: |
|
|
print(MSG_ERROR_ENTER_VALID_INTEGER) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_choice(label: str, options: List[str]) -> str: |
|
|
while True: |
|
|
print(label) |
|
|
for idx, option in enumerate(options, 1): |
|
|
print(f" {idx}. {option}") |
|
|
raw = input(MSG_PICK_NUMBER).strip() |
|
|
try: |
|
|
selected = int(raw) |
|
|
if 1 <= selected <= len(options): |
|
|
return options[selected - 1] |
|
|
except ValueError: |
|
|
pass |
|
|
print(MSG_ERROR_INVALID_CHOICE) |
|
|
|
|
|
|
|
|
def prompt_optional_choice(label: str, options: List[str]) -> Optional[str]: |
|
|
while True: |
|
|
print(label) |
|
|
print(" 0. Skip") |
|
|
for idx, option in enumerate(options, 1): |
|
|
print(f" {idx}. {option}") |
|
|
raw = input(MSG_PICK_NUMBER_SKIP).strip() |
|
|
try: |
|
|
selected = int(raw) |
|
|
if selected == 0: |
|
|
return None |
|
|
if 1 <= selected <= len(options): |
|
|
return options[selected - 1] |
|
|
except ValueError: |
|
|
pass |
|
|
print(MSG_ERROR_INVALID_CHOICE) |
|
|
|
|
|
|
|
|
def prompt_multi_choice(label: str, options: List[str]) -> List[str]: |
|
|
print(label) |
|
|
print(MSG_USE_COMMA_OR_STAR) |
|
|
for idx, option in enumerate(options, 1): |
|
|
print(f" {idx}. {option}") |
|
|
while True: |
|
|
raw = input(MSG_ENTER_NUMBERS_OR_NAMES).strip() |
|
|
if raw == "*": |
|
|
return options[:] |
|
|
parts = [p.strip() for p in raw.split(",") if p.strip()] |
|
|
result = [] |
|
|
valid = True |
|
|
for p in parts: |
|
|
if p in options: |
|
|
result.append(p) |
|
|
elif p.isdigit(): |
|
|
idx = int(p) |
|
|
if 1 <= idx <= len(options): |
|
|
result.append(options[idx - 1]) |
|
|
else: |
|
|
print(MSG_ERROR_INVALID_NUMBER) |
|
|
valid = False |
|
|
break |
|
|
else: |
|
|
print(MSG_ERROR_INVALID_VALUE) |
|
|
valid = False |
|
|
break |
|
|
if valid and result: |
|
|
try: |
|
|
return validate_work_days(result) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
elif valid and not result: |
|
|
print(MSG_ERROR_AT_LEAST_ONE_VALUE) |
|
|
|
|
|
|
|
|
def prompt_optional_multi_choice(label: str, options: List[str]) -> Optional[List[str]]: |
|
|
print(label) |
|
|
print(MSG_USE_COMMA_STAR_OR_EMPTY) |
|
|
for idx, option in enumerate(options, 1): |
|
|
print(f" {idx}. {option}") |
|
|
while True: |
|
|
raw = input(MSG_ENTER_NUMBERS_OR_NAMES_EMPTY_SKIP).strip() |
|
|
if not raw: |
|
|
return None |
|
|
if raw == "*": |
|
|
return options[:] |
|
|
parts = [p.strip() for p in raw.split(",") if p.strip()] |
|
|
result = [] |
|
|
valid = True |
|
|
for p in parts: |
|
|
if p in options: |
|
|
result.append(p) |
|
|
elif p.isdigit(): |
|
|
idx = int(p) |
|
|
if 1 <= idx <= len(options): |
|
|
result.append(options[idx - 1]) |
|
|
else: |
|
|
print(MSG_ERROR_INVALID_NUMBER) |
|
|
valid = False |
|
|
break |
|
|
else: |
|
|
print(MSG_ERROR_INVALID_VALUE) |
|
|
valid = False |
|
|
break |
|
|
if valid and result: |
|
|
try: |
|
|
return validate_work_days(result) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
elif valid and not result: |
|
|
return None |
|
|
|
|
|
|
|
|
def prompt_book_ids(label: str, client: LibraryClient) -> List[str]: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
parts = [p.strip() for p in raw.split(",") if p.strip()] |
|
|
try: |
|
|
validated = validate_ids_list(parts) |
|
|
for book_id in validated: |
|
|
if not client.book_exists(book_id): |
|
|
print(MSG_ERROR_BOOK_NOT_FOUND) |
|
|
raise ValidationError("Book not found") |
|
|
return validated |
|
|
except ValidationError as e: |
|
|
if "not found" not in str(e): |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_book_id(label: str, client: LibraryClient, check_exists: bool = True) -> str: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
try: |
|
|
validated = validate_uuid(raw, "Book ID") |
|
|
if check_exists and not client.book_exists(validated): |
|
|
print(MSG_ERROR_BOOK_NOT_FOUND_VALIDATED) |
|
|
continue |
|
|
return validated |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_visitor_id(label: str, client: LibraryClient, check_exists: bool = True) -> str: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
try: |
|
|
validated = validate_uuid(raw, "Visitor ID") |
|
|
if check_exists and not client.visitor_exists(validated): |
|
|
print(MSG_ERROR_VISITOR_NOT_FOUND) |
|
|
continue |
|
|
return validated |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_worker_id(label: str, client: LibraryClient, check_exists: bool = True) -> str: |
|
|
while True: |
|
|
raw = input(label).strip() |
|
|
try: |
|
|
validated = validate_uuid(raw, "Worker ID") |
|
|
if check_exists and not client.worker_exists(validated): |
|
|
print(MSG_ERROR_WORKER_NOT_FOUND) |
|
|
continue |
|
|
return validated |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def prompt_date(label: str) -> str: |
|
|
today = date.today().isoformat() |
|
|
while True: |
|
|
raw = input(f"{label} [{today}]: ").strip() |
|
|
try: |
|
|
return validate_date(raw) |
|
|
except ValidationError as e: |
|
|
print(f" Error: {e}") |
|
|
|
|
|
|
|
|
def print_books(books: Any): |
|
|
if not books: |
|
|
print(MSG_NO_BOOKS_FOUND) |
|
|
return |
|
|
for book in books: |
|
|
print(SEPARATOR) |
|
|
print(f"ID: {book.get('id')}") |
|
|
print(f"Title: {book.get('title')}") |
|
|
print(f"Author: {book.get('author')}") |
|
|
print(f"Pages: {book.get('pages')}") |
|
|
print(f"Year: {book.get('year')}") |
|
|
print(f"Genre: {book.get('genre')}") |
|
|
print(f"Status: {book.get('status')}") |
|
|
|
|
|
|
|
|
def print_visitors(visitors: Any): |
|
|
if not visitors: |
|
|
print(MSG_NO_VISITORS_FOUND) |
|
|
return |
|
|
for visitor in visitors: |
|
|
print(SEPARATOR) |
|
|
print(f"ID: {visitor.get('id')}") |
|
|
print(f"Name: {visitor.get('name')} {visitor.get('surname')}") |
|
|
print(f"Registered: {visitor.get('registrationDate')}") |
|
|
current = visitor.get("currentBooks") or [] |
|
|
history = visitor.get("history") or [] |
|
|
print(f"Current books: {len(current)}") |
|
|
print(f"History: {len(history)}") |
|
|
|
|
|
|
|
|
def print_workers(workers: Any): |
|
|
if not workers: |
|
|
print(MSG_NO_WORKERS_FOUND) |
|
|
return |
|
|
for worker in workers: |
|
|
print(SEPARATOR) |
|
|
print(f"ID: {worker.get('id')}") |
|
|
print(f"Name: {worker.get('name')} {worker.get('surname')}") |
|
|
print(f"Experience: {worker.get('experience')} years") |
|
|
print(f"Work days: {', '.join(worker.get('workDays', []))}") |
|
|
issued = worker.get("issuedBooks") or [] |
|
|
print(f"Issued books: {len(issued)}") |
|
|
|
|
|
|
|
|
def main_menu() -> Optional[MenuChoice]: |
|
|
print("\n" + SEPARATOR) |
|
|
for choice in MenuChoice: |
|
|
print(f"{choice.value}. {MENU_TEXT[choice]}") |
|
|
raw = input(PROMPT).strip() |
|
|
try: |
|
|
return MenuChoice(int(raw)) |
|
|
except Exception: |
|
|
return None |
|
|
|
|
|
|
|
|
def handle_error(error: Exception): |
|
|
print(f"{MSG_ERROR_PREFIX}{error}") |
|
|
|
|
|
|
|
|
def main(): |
|
|
client = LibraryClient() |
|
|
try: |
|
|
while True: |
|
|
choice = main_menu() |
|
|
if choice is None: |
|
|
print(MSG_INVALID_OPTION) |
|
|
continue |
|
|
if choice == MenuChoice.EXIT: |
|
|
print(MSG_GOODBYE) |
|
|
break |
|
|
try: |
|
|
if choice == MenuChoice.LIST_BOOKS: |
|
|
print_books(client.list_books()) |
|
|
|
|
|
elif choice == MenuChoice.VIEW_BOOK: |
|
|
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client) |
|
|
print_books([client.get_book(book_id)]) |
|
|
|
|
|
elif choice == MenuChoice.CREATE_BOOK: |
|
|
title = prompt_with_validation(MSG_TITLE_PROMPT, validate_title) |
|
|
author = prompt_with_validation(MSG_AUTHOR_PROMPT, validate_author) |
|
|
pages = prompt_int_with_validation(MSG_PAGES_PROMPT, validate_pages) |
|
|
year = prompt_int_with_validation(MSG_YEAR_PROMPT, validate_year) |
|
|
genre = prompt_choice(MSG_GENRE_PROMPT, GENRES) |
|
|
status = prompt_choice(MSG_STATUS_PROMPT, BOOK_STATUSES) |
|
|
print_books([client.create_book(title, author, pages, year, genre, status)]) |
|
|
|
|
|
elif choice == MenuChoice.UPDATE_BOOK: |
|
|
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client) |
|
|
print(MSG_LEAVE_EMPTY_TO_SKIP) |
|
|
title = prompt_optional_with_validation(MSG_TITLE_SKIP_PROMPT, validate_title) |
|
|
author = prompt_optional_with_validation(MSG_AUTHOR_SKIP_PROMPT, validate_author) |
|
|
pages = prompt_optional_int_with_validation(MSG_PAGES_SKIP_PROMPT, validate_pages) |
|
|
year = prompt_optional_int_with_validation(MSG_YEAR_SKIP_PROMPT, validate_year) |
|
|
genre = prompt_optional_choice(MSG_GENRE_PROMPT, GENRES) |
|
|
status = prompt_optional_choice(MSG_STATUS_PROMPT, BOOK_STATUSES) |
|
|
if not any([title, author, pages, year, genre, status]): |
|
|
print(MSG_NO_FIELDS_TO_UPDATE) |
|
|
continue |
|
|
updated = client.update_book(book_id, title, author, pages, year, genre, status) |
|
|
print_books([updated]) |
|
|
|
|
|
elif choice == MenuChoice.DELETE_BOOK: |
|
|
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client) |
|
|
result = client.delete_book(book_id) |
|
|
print(result.get("message") if isinstance(result, dict) else MSG_DELETED) |
|
|
|
|
|
elif choice == MenuChoice.BORROW_BOOKS: |
|
|
book_ids = prompt_book_ids(MSG_BOOK_IDS_COMMA_SEPARATED, client) |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
borrow_date = prompt_date(MSG_BORROW_DATE_PROMPT) |
|
|
result = client.borrow_books(book_ids, visitor_id, worker_id, borrow_date) |
|
|
print(result.get("message") if isinstance(result, dict) else result) |
|
|
|
|
|
elif choice == MenuChoice.RETURN_BOOKS: |
|
|
book_ids = prompt_book_ids(MSG_BOOK_IDS_COMMA_SEPARATED, client) |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
return_date = prompt_date(MSG_RETURN_DATE_PROMPT) |
|
|
result = client.return_books(book_ids, visitor_id, worker_id, return_date) |
|
|
print(result.get("message") if isinstance(result, dict) else result) |
|
|
|
|
|
elif choice == MenuChoice.DOWNLOAD_BOOK: |
|
|
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client) |
|
|
content = client.download_book(book_id) |
|
|
filepath = save_to_file("books", book_id, content) |
|
|
print(f"{MSG_FILE_SAVED_TO}{filepath}") |
|
|
|
|
|
elif choice == MenuChoice.LIST_VISITORS: |
|
|
print_visitors(client.list_visitors()) |
|
|
|
|
|
elif choice == MenuChoice.VIEW_VISITOR: |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
visitor = client.get_visitor(visitor_id) |
|
|
print_visitors([visitor]) |
|
|
current = visitor.get("currentBooks") or [] |
|
|
history = visitor.get("history") or [] |
|
|
if current: |
|
|
print(MSG_CURRENT_BOOKS) |
|
|
print_books(current) |
|
|
if history: |
|
|
print(MSG_HISTORY) |
|
|
print_books(history) |
|
|
|
|
|
elif choice == MenuChoice.CREATE_VISITOR: |
|
|
name = prompt_with_validation(MSG_NAME_PROMPT, lambda v: validate_name(v, "Name")) |
|
|
surname = prompt_with_validation(MSG_SURNAME_PROMPT, lambda v: validate_name(v, "Surname")) |
|
|
print_visitors([client.create_visitor(name, surname)]) |
|
|
|
|
|
elif choice == MenuChoice.UPDATE_VISITOR: |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
print(MSG_LEAVE_EMPTY_TO_SKIP) |
|
|
name = prompt_optional_with_validation(MSG_NAME_PROMPT + " (empty to skip): ", |
|
|
lambda v: validate_name(v, "Name")) |
|
|
surname = prompt_optional_with_validation(MSG_SURNAME_PROMPT + " (empty to skip): ", |
|
|
lambda v: validate_name(v, "Surname")) |
|
|
if not name and not surname: |
|
|
print(MSG_NO_FIELDS_TO_UPDATE) |
|
|
continue |
|
|
visitor = client.update_visitor(visitor_id, name, surname) |
|
|
print_visitors([visitor]) |
|
|
|
|
|
elif choice == MenuChoice.DELETE_VISITOR: |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
client.delete_visitor(visitor_id) |
|
|
print(MSG_VISITOR_DELETED) |
|
|
|
|
|
elif choice == MenuChoice.DOWNLOAD_VISITOR: |
|
|
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client) |
|
|
content = client.download_visitor(visitor_id) |
|
|
filepath = save_to_file("visitors", visitor_id, content) |
|
|
print(f"{MSG_FILE_SAVED_TO}{filepath}") |
|
|
|
|
|
elif choice == MenuChoice.LIST_WORKERS: |
|
|
print_workers(client.list_workers()) |
|
|
|
|
|
elif choice == MenuChoice.VIEW_WORKER: |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
worker = client.get_worker(worker_id) |
|
|
print_workers([worker]) |
|
|
issued = worker.get("issuedBooks") or [] |
|
|
if issued: |
|
|
print("\nIssued books:") |
|
|
print_books(issued) |
|
|
|
|
|
elif choice == MenuChoice.CREATE_WORKER: |
|
|
name = prompt_with_validation(MSG_NAME_PROMPT, lambda v: validate_name(v, "Name")) |
|
|
surname = prompt_with_validation(MSG_SURNAME_PROMPT, lambda v: validate_name(v, "Surname")) |
|
|
experience = prompt_int_with_validation(MSG_EXPERIENCE_PROMPT, validate_experience) |
|
|
work_days = prompt_multi_choice(MSG_WORK_DAYS_PROMPT, WORK_DAYS) |
|
|
print_workers([client.create_worker(name, surname, experience, work_days)]) |
|
|
|
|
|
elif choice == MenuChoice.UPDATE_WORKER: |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
print(MSG_LEAVE_EMPTY_TO_SKIP) |
|
|
name = prompt_optional_with_validation(MSG_NAME_PROMPT + " (empty to skip): ", |
|
|
lambda v: validate_name(v, "Name")) |
|
|
surname = prompt_optional_with_validation(MSG_SURNAME_PROMPT + " (empty to skip): ", |
|
|
lambda v: validate_name(v, "Surname")) |
|
|
experience = prompt_optional_int_with_validation(MSG_EXPERIENCE_PROMPT + " (empty to skip): ", |
|
|
validate_experience) |
|
|
work_days = prompt_optional_multi_choice(MSG_WORK_DAYS_PROMPT, WORK_DAYS) |
|
|
if not any([name, surname, experience is not None, work_days]): |
|
|
print(MSG_NO_FIELDS_TO_UPDATE) |
|
|
continue |
|
|
worker = client.update_worker(worker_id, name, surname, experience, work_days) |
|
|
print_workers([worker]) |
|
|
|
|
|
elif choice == MenuChoice.DELETE_WORKER: |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
client.delete_worker(worker_id) |
|
|
print(MSG_WORKER_DELETED) |
|
|
|
|
|
elif choice == MenuChoice.WORKERS_BY_DAYS: |
|
|
work_days = prompt_multi_choice(MSG_SELECT_WORK_DAYS, WORK_DAYS) |
|
|
print_workers(client.workers_by_days(work_days)) |
|
|
|
|
|
elif choice == MenuChoice.DOWNLOAD_WORKER: |
|
|
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client) |
|
|
content = client.download_worker(worker_id) |
|
|
filepath = save_to_file("workers", worker_id, content) |
|
|
print(f"{MSG_FILE_SAVED_TO}{filepath}") |
|
|
|
|
|
else: |
|
|
print(MSG_INVALID_OPTION) |
|
|
|
|
|
except (RuntimeError, httpx.HTTPError, ValueError) as error: |
|
|
handle_error(error) |
|
|
except KeyboardInterrupt: |
|
|
print(MSG_INTERRUPTED_GOODBYE) |
|
|
finally: |
|
|
client.close() |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|