lab4 / unified_client.py
brestok's picture
init
a08f988
import json
import re
from datetime import date, datetime
from enum import IntEnum
from pathlib import Path
from typing import Any, List, Optional, Callable
from uuid import UUID
import httpx
FORUM_BASE_URL = "https://brestok-lab4.hf.space"
LIBRARY_BASE_URL = "https://brestok-vika-server.hf.space"
DOWNLOADS_DIR = Path(__file__).parent / "downloads"
SEPARATOR = "=" * 70
SEPARATOR_60 = "=" * 60
SEPARATOR_90 = "-" * 90
DASH_LINE_60 = "-" * 60
PROMPT = "Select option: "
TABLE_PARTICIPANTS = "participants"
TABLE_TOPICS = "topics"
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}$")
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} is required.")
if len(value) < 2:
raise ValidationError(f"{field_name} must be at least 2 characters.")
if len(value) > 50:
raise ValidationError(f"{field_name} must be at most 50 characters.")
if not NAME_PATTERN.match(value):
raise ValidationError(f"{field_name} must contain only letters, spaces, hyphens, and apostrophes.")
return value
def validate_title(value: str, field_name: str = "Title") -> str:
value = value.strip()
if not value:
raise ValidationError(f"{field_name} is required.")
if len(value) < 2:
raise ValidationError(f"{field_name} must be at least 2 characters.")
if len(value) > 100:
raise ValidationError(f"{field_name} must be at most 100 characters.")
if not TITLE_PATTERN.match(value):
raise ValidationError(f"{field_name} contains invalid characters.")
return value
def validate_author(value: str) -> str:
value = value.strip()
if not value:
raise ValidationError("Author is required.")
if len(value) < 2:
raise ValidationError("Author must be at least 2 characters.")
if len(value) > 100:
raise ValidationError("Author must be at most 100 characters.")
if not NAME_PATTERN.match(value):
raise ValidationError("Author must contain only letters, spaces, hyphens, and apostrophes.")
return value
def validate_pages(value: int) -> int:
if value < 1:
raise ValidationError("Pages must be at least 1.")
if value > 10000:
raise ValidationError("Pages must be at most 10000.")
return value
def validate_year(value: int) -> int:
current_year = date.today().year
if value < 1000:
raise ValidationError("Year must be at least 1000.")
if value > current_year:
raise ValidationError(f"Year cannot be greater than {current_year}.")
return value
def validate_genre(value: str) -> str:
if value not in GENRES:
raise ValidationError(f"Genre must be one of: {', '.join(GENRES)}")
return value
def validate_status(value: str) -> str:
if value not in BOOK_STATUSES:
raise ValidationError(f"Status must be one of: {', '.join(BOOK_STATUSES)}")
return value
def validate_experience(value: int) -> int:
if value < 0:
raise ValidationError("Experience cannot be negative.")
if value > 60:
raise ValidationError("Experience must be at most 60 years.")
return value
def validate_work_days(days: List[str]) -> List[str]:
if not days:
raise ValidationError("At least one work day is required.")
invalid = [d for d in days if d not in WORK_DAYS]
if invalid:
raise ValidationError(f"Invalid work days: {', '.join(invalid)}. Valid options: {', '.join(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} is required.")
if not UUID_PATTERN.match(value):
raise ValidationError(f"{field_name} must be a valid UUID format.")
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("Date must be in YYYY-MM-DD format.")
def validate_ids_list(ids: List[str]) -> List[str]:
if not ids:
raise ValidationError("At least one item is required.")
validated = []
for i, id_val in enumerate(ids, 1):
id_val = id_val.strip()
if not id_val:
raise ValidationError(f"Item #{i} is empty.")
if not UUID_PATTERN.match(id_val):
raise ValidationError(f"Item #{i} ({id_val}) is not a valid UUID.")
validated.append(id_val)
return validated
def validate_activity_rating(value: float) -> float:
if value < 0.1 or value > 5.0:
raise ValidationError("Activity rating must be between 0.1 and 5.0.")
return value
class ForumClient:
def __init__(self, base_url: str = FORUM_BASE_URL):
self.base_url = base_url
self.client = httpx.Client(timeout=30.0)
@staticmethod
def _link(table: str, value: str) -> dict:
return {"table": table, "value": value}
def get_participants(self) -> list:
response = self.client.get(f"{self.base_url}/participants/")
response.raise_for_status()
return response.json()
def get_participant(self, participant_id: str) -> dict:
response = self.client.get(f"{self.base_url}/participants/{participant_id}")
response.raise_for_status()
return response.json()
def create_participant(self, first_name: str, last_name: str, nickname: str, activity_rating: float) -> dict:
payload = {
"first_name": first_name,
"last_name": last_name,
"nickname": nickname,
"activity_rating": activity_rating,
}
response = self.client.post(f"{self.base_url}/participants/", json=payload)
response.raise_for_status()
return response.json()
def get_topics(self) -> list:
response = self.client.get(f"{self.base_url}/topics/")
response.raise_for_status()
return response.json()
def get_topic(self, topic_id: str) -> dict:
response = self.client.get(f"{self.base_url}/topics/{topic_id}")
response.raise_for_status()
return response.json()
def get_messages(self) -> list:
response = self.client.get(f"{self.base_url}/messages/")
response.raise_for_status()
return response.json()
def create_topic(self, title: str, description: str, participants: list = None) -> dict:
payload = {
"title": title,
"description": description,
"participants": [self._link(TABLE_PARTICIPANTS, p) for p in (participants or [])],
"messages": [],
}
response = self.client.post(f"{self.base_url}/topics/", json=payload)
response.raise_for_status()
return response.json()
def publish_message(self, topic_id: str, participant_id: str, content: str) -> dict | str:
payload = {
"participant_id": self._link(TABLE_PARTICIPANTS, participant_id),
"content": content,
}
response = self.client.post(f"{self.base_url}/topics/{topic_id}/messages", json=payload)
if response.status_code == 400:
error_detail = response.json().get("detail", "Unknown error")
return f"ERROR: {error_detail}"
response.raise_for_status()
return response.json()
def download_participant(self, participant_id: str) -> dict:
response = self.client.get(f"{self.base_url}/participants/{participant_id}/download")
response.raise_for_status()
return response.json()
def download_topic(self, topic_id: str) -> dict:
response = self.client.get(f"{self.base_url}/topics/{topic_id}/download")
response.raise_for_status()
return response.json()
def download_message(self, message_id: str) -> dict:
response = self.client.get(f"{self.base_url}/messages/{message_id}/download")
response.raise_for_status()
return response.json()
def close(self):
self.client.close()
class LibraryClient:
def __init__(self, base_url: str = LIBRARY_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 ForumMenuChoice(IntEnum):
LIST_PARTICIPANTS = 1
GET_PARTICIPANT = 2
CREATE_PARTICIPANT = 3
DOWNLOAD_PARTICIPANT = 4
LIST_TOPICS = 5
GET_TOPIC = 6
CREATE_TOPIC = 7
DOWNLOAD_TOPIC = 8
PUBLISH_MESSAGE = 9
LIST_MESSAGES = 10
DOWNLOAD_MESSAGE = 11
BACK = 0
FORUM_MENU_TEXT = {
ForumMenuChoice.LIST_PARTICIPANTS: "List all participants",
ForumMenuChoice.GET_PARTICIPANT: "Get participant by ID",
ForumMenuChoice.CREATE_PARTICIPANT: "Create new participant",
ForumMenuChoice.DOWNLOAD_PARTICIPANT: "Download participant as file",
ForumMenuChoice.LIST_TOPICS: "List all topics",
ForumMenuChoice.GET_TOPIC: "Get topic by ID",
ForumMenuChoice.CREATE_TOPIC: "Create new topic",
ForumMenuChoice.DOWNLOAD_TOPIC: "Download topic as file",
ForumMenuChoice.PUBLISH_MESSAGE: "Publish message to topic",
ForumMenuChoice.LIST_MESSAGES: "List all messages",
ForumMenuChoice.DOWNLOAD_MESSAGE: "Download message as file",
ForumMenuChoice.BACK: "Back to server selection",
}
class LibraryMenuChoice(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
BACK = 0
LIBRARY_MENU_TEXT = {
LibraryMenuChoice.LIST_BOOKS: "List books",
LibraryMenuChoice.VIEW_BOOK: "Get book by ID",
LibraryMenuChoice.CREATE_BOOK: "Create book",
LibraryMenuChoice.UPDATE_BOOK: "Update book",
LibraryMenuChoice.DELETE_BOOK: "Delete book",
LibraryMenuChoice.BORROW_BOOKS: "Borrow books",
LibraryMenuChoice.RETURN_BOOKS: "Return books",
LibraryMenuChoice.DOWNLOAD_BOOK: "Download book as file",
LibraryMenuChoice.LIST_VISITORS: "List visitors",
LibraryMenuChoice.VIEW_VISITOR: "Get visitor by ID",
LibraryMenuChoice.CREATE_VISITOR: "Create visitor",
LibraryMenuChoice.UPDATE_VISITOR: "Update visitor",
LibraryMenuChoice.DELETE_VISITOR: "Delete visitor",
LibraryMenuChoice.DOWNLOAD_VISITOR: "Download visitor as file",
LibraryMenuChoice.LIST_WORKERS: "List workers",
LibraryMenuChoice.VIEW_WORKER: "Get worker by ID",
LibraryMenuChoice.CREATE_WORKER: "Create worker",
LibraryMenuChoice.UPDATE_WORKER: "Update worker",
LibraryMenuChoice.DELETE_WORKER: "Delete worker",
LibraryMenuChoice.WORKERS_BY_DAYS: "Find workers by work days",
LibraryMenuChoice.DOWNLOAD_WORKER: "Download worker as file",
LibraryMenuChoice.BACK: "Back to server selection",
}
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(" Error: Enter a valid integer.")
except ValidationError as e:
print(f" Error: {e}")
def prompt_float_with_validation(label: str, validator: Callable[[float], float]) -> float:
while True:
raw = input(label).strip()
try:
value = float(raw)
return validator(value)
except ValueError:
print(" Error: Enter a valid number.")
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(" Error: Enter a 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("Pick number: ").strip()
try:
selected = int(raw)
if 1 <= selected <= len(options):
return options[selected - 1]
except ValueError:
pass
print(" 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("Pick number (0 to skip): ").strip()
try:
selected = int(raw)
if selected == 0:
return None
if 1 <= selected <= len(options):
return options[selected - 1]
except ValueError:
pass
print(" Error: Invalid choice.")
def prompt_multi_choice(label: str, options: List[str]) -> List[str]:
print(label)
print("Use comma to separate values or type * for all.")
for idx, option in enumerate(options, 1):
print(f" {idx}. {option}")
while True:
raw = input("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(f" Error: Invalid number {p}.")
valid = False
break
else:
print(f" Error: Invalid value '{p}'.")
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(" Error: At least one value is required.")
def prompt_optional_multi_choice(label: str, options: List[str]) -> Optional[List[str]]:
print(label)
print("Use comma to separate values, * for all, or leave empty to skip.")
for idx, option in enumerate(options, 1):
print(f" {idx}. {option}")
while True:
raw = input("Enter numbers or names (empty to 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(f" Error: Invalid number {p}.")
valid = False
break
else:
print(f" Error: Invalid value '{p}'.")
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(f" Error: Book with ID {book_id} 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(f" Error: Book with ID {validated} not found.")
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(f" Error: Visitor with ID {validated} 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(f" Error: Worker with ID {validated} 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("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("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("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 print_participants(participants: list):
if not participants:
print("No participants found.")
return
header = f"{'ID':<40} {'Name':<25} {'Nickname':<15} {'Rating':<10}"
print(header)
print(SEPARATOR_90)
for p in participants:
full_name = f"{p['first_name']} {p['last_name']}"
print(f"{p['id']:<40} {full_name:<25} {p['nickname']:<15} {p['activity_rating']:<10}")
def print_topics(topics: list):
if not topics:
print("No topics found.")
return
for topic in topics:
print(f"\nTopic: {topic['title']}")
print(f" ID: {topic['id']}")
print(f" Description: {topic['description']}")
print(f" Created: {topic['created_at']}")
print(f" Messages ({len(topic['messages'])}):")
for msg in topic["messages"]:
print(f" {msg.get('order_in_topic', 0)}. [{msg['participant_name']}]: {msg['content']}")
def print_topic_detail(topic: dict):
print(f"\nTopic: {topic['title']}")
print(f" ID: {topic['id']}")
print(f" Description: {topic['description']}")
print(f" Created: {topic['created_at']}")
print(f" Participants: {len(topic['participants'])}")
print(f"\n Messages ({len(topic['messages'])}):")
if not topic["messages"]:
print(" No messages yet.")
for msg in topic["messages"]:
print(f" {msg.get('order_in_topic', 0)}. [{msg['participant_name']}]: {msg['content']}")
def print_messages(messages: list):
if not messages:
print(" No messages yet.")
return
for msg in messages:
print(f"{msg['topic_title']} | #{msg['order_in_topic']} [{msg['participant_name']}]: {msg['content']}")
def server_selection_menu() -> Optional[int]:
print("\n" + SEPARATOR)
print(" UNIFIED CLIENT - SERVER SELECTION")
print(SEPARATOR)
print("1. Forum Server (Topics, Participants, Messages)")
print("2. Library Server (Books, Visitors, Workers)")
print("0. Exit")
print(DASH_LINE_60)
raw = input(PROMPT).strip()
try:
choice = int(raw)
if choice in (0, 1, 2):
return choice
except ValueError:
pass
return None
def forum_menu() -> Optional[ForumMenuChoice]:
print("\n" + SEPARATOR_60)
print(" FORUM CLIENT - MAIN MENU")
print(SEPARATOR_60)
for choice in ForumMenuChoice:
print(f"{choice.value}. {FORUM_MENU_TEXT[choice]}")
print(DASH_LINE_60)
raw = input(PROMPT).strip()
try:
return ForumMenuChoice(int(raw))
except Exception:
return None
def library_menu() -> Optional[LibraryMenuChoice]:
print("\n" + SEPARATOR)
for choice in LibraryMenuChoice:
print(f"{choice.value}. {LIBRARY_MENU_TEXT[choice]}")
raw = input(PROMPT).strip()
try:
return LibraryMenuChoice(int(raw))
except Exception:
return None
def run_forum_client():
client = ForumClient()
try:
while True:
choice = forum_menu()
if choice is None:
print("Invalid option. Please try again.")
continue
if choice == ForumMenuChoice.BACK:
break
try:
if choice == ForumMenuChoice.LIST_PARTICIPANTS:
print(SEPARATOR_60)
print("ALL PARTICIPANTS:")
print_participants(client.get_participants())
elif choice == ForumMenuChoice.GET_PARTICIPANT:
print(SEPARATOR_60)
participant_id = input("Enter participant ID: ").strip()
try:
UUID(participant_id)
participant = client.get_participant(participant_id)
print(f"\nParticipant: {participant['first_name']} {participant['last_name']}")
print(f" ID: {participant['id']}")
print(f" Nickname: {participant['nickname']}")
print(f" Rating: {participant['activity_rating']}")
print(f" Registered: {participant['registered_at']}")
except ValueError:
print("Invalid UUID format.")
elif choice == ForumMenuChoice.CREATE_PARTICIPANT:
print(SEPARATOR_60)
print("CREATE NEW PARTICIPANT:")
first_name = prompt_with_validation(" First name: ",
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
ValidationError("First name must contain only letters and '-'.")))
last_name = prompt_with_validation(" Last name: ",
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
ValidationError("Last name must contain only letters and '-'.")))
nickname = prompt_with_validation(" Nickname: ",
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
ValidationError("Nickname must contain only letters and '-'.")))
activity_rating = prompt_float_with_validation(" Activity rating: ", validate_activity_rating)
participant = client.create_participant(first_name, last_name, nickname, activity_rating)
print("\nParticipant created successfully!")
print(f" ID: {participant['id']}")
elif choice == ForumMenuChoice.LIST_TOPICS:
print(SEPARATOR_60)
print("ALL TOPICS:")
print_topics(client.get_topics())
elif choice == ForumMenuChoice.GET_TOPIC:
print(SEPARATOR_60)
topic_id = input("Enter topic ID: ").strip()
try:
UUID(topic_id)
topic = client.get_topic(topic_id)
print_topic_detail(topic)
except ValueError:
print("Invalid UUID format.")
elif choice == ForumMenuChoice.CREATE_TOPIC:
print(SEPARATOR_60)
print("CREATE NEW TOPIC:")
title = input(" Title: ").strip()
description = input(" Description: ").strip()
topic = client.create_topic(title, description)
print("\nTopic created successfully!")
print(f" ID: {topic['id']}")
elif choice == ForumMenuChoice.PUBLISH_MESSAGE:
print(SEPARATOR_60)
print("PUBLISH MESSAGE TO TOPIC:")
while True:
topic_id = input(" Topic ID: ").strip()
if not topic_id:
print("Topic ID is required.")
continue
try:
client.get_topic(topic_id)
break
except httpx.HTTPStatusError as e:
if e.response.status_code in (404, 422):
print("Topic not found.")
else:
print(f"Error: {e.response.status_code} - {e.response.text}")
while True:
participant_id = input(" Participant ID: ").strip()
if not participant_id:
print("Participant ID is required.")
continue
try:
client.get_participant(participant_id)
break
except httpx.HTTPStatusError as e:
if e.response.status_code in (404, 422):
print("Participant not found.")
else:
print(f"Error: {e.response.status_code} - {e.response.text}")
content = input(" Message content: ").strip()
result = client.publish_message(topic_id, participant_id, content)
if isinstance(result, str):
print(f"\n{result}")
else:
print("\nMessage published successfully!")
print(f"Topic now has {len(result['messages'])} message(s).")
elif choice == ForumMenuChoice.LIST_MESSAGES:
print(SEPARATOR_60)
print("ALL MESSAGES:")
print_messages(client.get_messages())
elif choice == ForumMenuChoice.DOWNLOAD_PARTICIPANT:
print(SEPARATOR_60)
participant_id = input("Enter participant ID: ").strip()
try:
UUID(participant_id)
content = client.download_participant(participant_id)
filepath = save_to_file("participants", participant_id, content)
print(f"File saved to: {filepath}")
except ValueError:
print("Invalid UUID format.")
elif choice == ForumMenuChoice.DOWNLOAD_TOPIC:
print(SEPARATOR_60)
topic_id = input("Enter topic ID: ").strip()
try:
UUID(topic_id)
content = client.download_topic(topic_id)
filepath = save_to_file("topics", topic_id, content)
print(f"File saved to: {filepath}")
except ValueError:
print("Invalid UUID format.")
elif choice == ForumMenuChoice.DOWNLOAD_MESSAGE:
print(SEPARATOR_60)
message_id = input("Enter message ID: ").strip()
try:
UUID(message_id)
content = client.download_message(message_id)
filepath = save_to_file("messages", message_id, content)
print(f"File saved to: {filepath}")
except ValueError:
print("Invalid UUID format.")
except httpx.HTTPStatusError as e:
print(f"Error: {e.response.status_code} - {e.response.text}")
finally:
client.close()
def run_library_client():
client = LibraryClient()
try:
while True:
choice = library_menu()
if choice is None:
print("Invalid option.")
continue
if choice == LibraryMenuChoice.BACK:
break
try:
if choice == LibraryMenuChoice.LIST_BOOKS:
print_books(client.list_books())
elif choice == LibraryMenuChoice.VIEW_BOOK:
book_id = prompt_book_id("Book ID: ", client)
print_books([client.get_book(book_id)])
elif choice == LibraryMenuChoice.CREATE_BOOK:
title = prompt_with_validation("Title: ", validate_title)
author = prompt_with_validation("Author: ", validate_author)
pages = prompt_int_with_validation("Pages: ", validate_pages)
year = prompt_int_with_validation("Year: ", validate_year)
genre = prompt_choice("Genre:", GENRES)
status = prompt_choice("Status:", BOOK_STATUSES)
print_books([client.create_book(title, author, pages, year, genre, status)])
elif choice == LibraryMenuChoice.UPDATE_BOOK:
book_id = prompt_book_id("Book ID: ", client)
print("Leave fields empty to skip them.")
title = prompt_optional_with_validation("Title (empty to skip): ", validate_title)
author = prompt_optional_with_validation("Author (empty to skip): ", validate_author)
pages = prompt_optional_int_with_validation("Pages (empty to skip): ", validate_pages)
year = prompt_optional_int_with_validation("Year (empty to skip): ", validate_year)
genre = prompt_optional_choice("Genre:", GENRES)
status = prompt_optional_choice("Status:", BOOK_STATUSES)
if not any([title, author, pages, year, genre, status]):
print("No fields to update.")
continue
print_books([client.update_book(book_id, title, author, pages, year, genre, status)])
elif choice == LibraryMenuChoice.DELETE_BOOK:
book_id = prompt_book_id("Book ID: ", client)
result = client.delete_book(book_id)
print(result.get("message") if isinstance(result, dict) else "Deleted.")
elif choice == LibraryMenuChoice.BORROW_BOOKS:
book_ids = prompt_book_ids("Book IDs (comma separated): ", client)
visitor_id = prompt_visitor_id("Visitor ID: ", client)
worker_id = prompt_worker_id("Worker ID: ", client)
borrow_date = prompt_date("Borrow date (YYYY-MM-DD)")
result = client.borrow_books(book_ids, visitor_id, worker_id, borrow_date)
print(result.get("message") if isinstance(result, dict) else result)
elif choice == LibraryMenuChoice.RETURN_BOOKS:
book_ids = prompt_book_ids("Book IDs (comma separated): ", client)
visitor_id = prompt_visitor_id("Visitor ID: ", client)
worker_id = prompt_worker_id("Worker ID: ", client)
return_date = prompt_date("Return date (YYYY-MM-DD)")
result = client.return_books(book_ids, visitor_id, worker_id, return_date)
print(result.get("message") if isinstance(result, dict) else result)
elif choice == LibraryMenuChoice.DOWNLOAD_BOOK:
book_id = prompt_book_id("Book ID: ", client)
content = client.download_book(book_id)
filepath = save_to_file("books", book_id, content)
print(f"File saved to: {filepath}")
elif choice == LibraryMenuChoice.LIST_VISITORS:
print_visitors(client.list_visitors())
elif choice == LibraryMenuChoice.VIEW_VISITOR:
visitor_id = prompt_visitor_id("Visitor ID: ", client)
visitor = client.get_visitor(visitor_id)
print_visitors([visitor])
current = visitor.get("currentBooks") or []
history = visitor.get("history") or []
if current:
print("\nCurrent books:")
print_books(current)
if history:
print("\nHistory:")
print_books(history)
elif choice == LibraryMenuChoice.CREATE_VISITOR:
name = prompt_with_validation("Name: ", lambda v: validate_name(v, "Name"))
surname = prompt_with_validation("Surname: ", lambda v: validate_name(v, "Surname"))
print_visitors([client.create_visitor(name, surname)])
elif choice == LibraryMenuChoice.UPDATE_VISITOR:
visitor_id = prompt_visitor_id("Visitor ID: ", client)
print("Leave fields empty to skip them.")
name = prompt_optional_with_validation("Name (empty to skip): ", lambda v: validate_name(v, "Name"))
surname = prompt_optional_with_validation("Surname (empty to skip): ", lambda v: validate_name(v, "Surname"))
if not name and not surname:
print("No fields to update.")
continue
print_visitors([client.update_visitor(visitor_id, name, surname)])
elif choice == LibraryMenuChoice.DELETE_VISITOR:
visitor_id = prompt_visitor_id("Visitor ID: ", client)
client.delete_visitor(visitor_id)
print("Visitor deleted.")
elif choice == LibraryMenuChoice.DOWNLOAD_VISITOR:
visitor_id = prompt_visitor_id("Visitor ID: ", client)
content = client.download_visitor(visitor_id)
filepath = save_to_file("visitors", visitor_id, content)
print(f"File saved to: {filepath}")
elif choice == LibraryMenuChoice.LIST_WORKERS:
print_workers(client.list_workers())
elif choice == LibraryMenuChoice.VIEW_WORKER:
worker_id = prompt_worker_id("Worker ID: ", 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 == LibraryMenuChoice.CREATE_WORKER:
name = prompt_with_validation("Name: ", lambda v: validate_name(v, "Name"))
surname = prompt_with_validation("Surname: ", lambda v: validate_name(v, "Surname"))
experience = prompt_int_with_validation("Experience (years): ", validate_experience)
work_days = prompt_multi_choice("Work days:", WORK_DAYS)
print_workers([client.create_worker(name, surname, experience, work_days)])
elif choice == LibraryMenuChoice.UPDATE_WORKER:
worker_id = prompt_worker_id("Worker ID: ", client)
print("Leave fields empty to skip them.")
name = prompt_optional_with_validation("Name (empty to skip): ", lambda v: validate_name(v, "Name"))
surname = prompt_optional_with_validation("Surname (empty to skip): ", lambda v: validate_name(v, "Surname"))
experience = prompt_optional_int_with_validation("Experience (empty to skip): ", validate_experience)
work_days = prompt_optional_multi_choice("Work days:", WORK_DAYS)
if not any([name, surname, experience is not None, work_days]):
print("No fields to update.")
continue
print_workers([client.update_worker(worker_id, name, surname, experience, work_days)])
elif choice == LibraryMenuChoice.DELETE_WORKER:
worker_id = prompt_worker_id("Worker ID: ", client)
client.delete_worker(worker_id)
print("Worker deleted.")
elif choice == LibraryMenuChoice.WORKERS_BY_DAYS:
work_days = prompt_multi_choice("Select work days:", WORK_DAYS)
print_workers(client.workers_by_days(work_days))
elif choice == LibraryMenuChoice.DOWNLOAD_WORKER:
worker_id = prompt_worker_id("Worker ID: ", client)
content = client.download_worker(worker_id)
filepath = save_to_file("workers", worker_id, content)
print(f"File saved to: {filepath}")
except (RuntimeError, httpx.HTTPError, ValueError) as error:
print(f"Error: {error}")
finally:
client.close()
def main():
try:
while True:
server_choice = server_selection_menu()
if server_choice is None:
print("Invalid option. Please try again.")
continue
if server_choice == 0:
print("Goodbye!")
break
elif server_choice == 1:
run_forum_client()
elif server_choice == 2:
run_library_client()
except KeyboardInterrupt:
print("\nInterrupted. Goodbye!")
if __name__ == "__main__":
main()