|
|
import re |
|
|
from enum import IntEnum |
|
|
from uuid import UUID |
|
|
|
|
|
import httpx |
|
|
|
|
|
BASE_URL = "http://127.0.0.1:8000" |
|
|
TABLE_PARTICIPANTS = "participants" |
|
|
TABLE_TOPICS = "topics" |
|
|
|
|
|
NEWLINE = "\n" |
|
|
SEPARATOR_60 = "=" * 60 |
|
|
SEPARATOR_90 = "-" * 90 |
|
|
DASH_LINE_60 = "-" * 60 |
|
|
MAIN_MENU_TITLE = " FORUM CLIENT - MAIN MENU" |
|
|
PROMPT_SELECT_OPTION = "Select option: " |
|
|
MSG_GOODBYE = "Goodbye!" |
|
|
LABEL_ALL_PARTICIPANTS = "ALL PARTICIPANTS:" |
|
|
LABEL_ALL_TOPICS = "ALL TOPICS:" |
|
|
LABEL_ALL_MESSAGES = "ALL MESSAGES:" |
|
|
LABEL_CREATE_PARTICIPANT = "CREATE NEW PARTICIPANT:" |
|
|
LABEL_CREATE_TOPIC = "CREATE NEW TOPIC:" |
|
|
LABEL_PUBLISH_MESSAGE = "PUBLISH MESSAGE TO TOPIC:" |
|
|
PROMPT_PARTICIPANT_ID = "Enter participant ID: " |
|
|
PROMPT_TOPIC_ID = "Enter topic ID: " |
|
|
PROMPT_TITLE = " Title: " |
|
|
PROMPT_DESCRIPTION = " Description: " |
|
|
PROMPT_FIRST_NAME = " First name: " |
|
|
PROMPT_LAST_NAME = " Last name: " |
|
|
PROMPT_NICKNAME = " Nickname: " |
|
|
PROMPT_ACTIVITY = " Activity rating: " |
|
|
PROMPT_TOPIC_ID_INLINE = " Topic ID: " |
|
|
PROMPT_PARTICIPANT_ID_INLINE = " Participant ID: " |
|
|
PROMPT_MESSAGE_CONTENT = " Message content: " |
|
|
MSG_NO_PARTICIPANTS = "No participants found." |
|
|
MSG_NO_TOPICS = "No topics found." |
|
|
MSG_INVALID_OPTION = "Invalid option. Please try again." |
|
|
MSG_INVALID_UUID = "Invalid UUID format." |
|
|
MSG_GOODBYE_INTERRUPT = "\n\nInterrupted. Goodbye!" |
|
|
MSG_TOPIC_ID_REQUIRED = "Topic ID is required." |
|
|
MSG_PARTICIPANT_ID_REQUIRED = "Participant ID is required." |
|
|
MSG_TOPIC_NOT_FOUND = "Topic not found." |
|
|
MSG_PARTICIPANT_NOT_FOUND = "Participant not found." |
|
|
MSG_NO_MESSAGES = " No messages yet." |
|
|
MSG_PARTICIPANT_CREATED = "\nParticipant created successfully!" |
|
|
MSG_TOPIC_CREATED = "\nTopic created successfully!" |
|
|
MSG_MESSAGE_PUBLISHED = "\nMessage published successfully!" |
|
|
MSG_ERROR_TEMPLATE = "Error: {status} - {text}" |
|
|
PARTICIPANT_NAME_RULE = "First name must contain only letters and '-'." |
|
|
PARTICIPANT_LAST_NAME_RULE = "Last name must contain only letters and '-'." |
|
|
PARTICIPANT_NICKNAME_RULE = "Nickname must contain only letters and '-'." |
|
|
ACTIVITY_RANGE_RULE = "Activity rating must be between 0.1 and 5.0." |
|
|
ACTIVITY_NUMBER_RULE = "Activity rating must be a number between 0.1 and 5.0." |
|
|
PARTICIPANT_TABLE_HEADER = f"{'ID':<40} {'Name':<25} {'Nickname':<15} {'Rating':<10}" |
|
|
PARTICIPANT_ROW_TEMPLATE = ( |
|
|
"{id:<40} {full_name:<25} {nickname:<15} {rating:<10}" |
|
|
) |
|
|
TOPIC_TITLE_TEMPLATE = "\nTopic: {title}" |
|
|
TOPIC_ID_TEMPLATE = " ID: {id}" |
|
|
TOPIC_DESCRIPTION_TEMPLATE = " Description: {description}" |
|
|
TOPIC_CREATED_TEMPLATE = " Created: {created_at}" |
|
|
TOPIC_MESSAGES_TEMPLATE = " Messages ({count}):" |
|
|
TOPIC_PARTICIPANTS_TEMPLATE = " Participants: {count}" |
|
|
TOPIC_MESSAGE_TEMPLATE = " {order}. [{participant_name}]: {content}" |
|
|
MESSAGE_LIST_TEMPLATE = "{topic_title} | #{order} [{participant_name}]: {content}" |
|
|
PARTICIPANT_SUMMARY_TEMPLATE = "\nParticipant: {first} {last}" |
|
|
PARTICIPANT_ID_TEMPLATE = " ID: {id}" |
|
|
PARTICIPANT_NICKNAME_TEMPLATE = " Nickname: {nickname}" |
|
|
PARTICIPANT_RATING_TEMPLATE = " Rating: {rating}" |
|
|
PARTICIPANT_REGISTERED_TEMPLATE = " Registered: {registered}" |
|
|
TOPIC_CREATED_ID_TEMPLATE = " ID: {id}" |
|
|
PARTICIPANT_CREATED_ID_TEMPLATE = " ID: {id}" |
|
|
TOPIC_MESSAGES_COUNT_TEMPLATE = "Topic now has {count} message(s)." |
|
|
RESULT_MESSAGE_TEMPLATE = "\n{result}" |
|
|
MENU_HEADER = NEWLINE + SEPARATOR_60 |
|
|
|
|
|
class ForumClient: |
|
|
def __init__(self, base_url: str = 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 get_messages_by_topic(self, topic_id: str) -> list: |
|
|
response = self.client.get(f"{self.base_url}/messages/topic/{topic_id}") |
|
|
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 close(self): |
|
|
self.client.close() |
|
|
|
|
|
|
|
|
def print_separator(): |
|
|
print(SEPARATOR_60) |
|
|
|
|
|
|
|
|
def print_participants(participants: list): |
|
|
if not participants: |
|
|
print(MSG_NO_PARTICIPANTS) |
|
|
return |
|
|
print(PARTICIPANT_TABLE_HEADER) |
|
|
print(SEPARATOR_90) |
|
|
for p in participants: |
|
|
full_name = f"{p['first_name']} {p['last_name']}" |
|
|
print( |
|
|
PARTICIPANT_ROW_TEMPLATE.format( |
|
|
id=p["id"], |
|
|
full_name=full_name, |
|
|
nickname=p["nickname"], |
|
|
rating=p["activity_rating"], |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
def print_topics(topics: list): |
|
|
if not topics: |
|
|
print(MSG_NO_TOPICS) |
|
|
return |
|
|
for topic in topics: |
|
|
print(TOPIC_TITLE_TEMPLATE.format(title=topic["title"])) |
|
|
print(TOPIC_ID_TEMPLATE.format(id=topic["id"])) |
|
|
print(TOPIC_DESCRIPTION_TEMPLATE.format(description=topic["description"])) |
|
|
print(TOPIC_CREATED_TEMPLATE.format(created_at=topic["created_at"])) |
|
|
print(TOPIC_MESSAGES_TEMPLATE.format(count=len(topic["messages"]))) |
|
|
for msg in topic["messages"]: |
|
|
print( |
|
|
TOPIC_MESSAGE_TEMPLATE.format( |
|
|
participant_name=msg["participant_name"], |
|
|
content=msg["content"], |
|
|
order=msg.get("order_in_topic", 0), |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
def print_topic_detail(topic: dict): |
|
|
print(TOPIC_TITLE_TEMPLATE.format(title=topic["title"])) |
|
|
print(TOPIC_ID_TEMPLATE.format(id=topic["id"])) |
|
|
print(TOPIC_DESCRIPTION_TEMPLATE.format(description=topic["description"])) |
|
|
print(TOPIC_CREATED_TEMPLATE.format(created_at=topic["created_at"])) |
|
|
print(TOPIC_PARTICIPANTS_TEMPLATE.format(count=len(topic["participants"]))) |
|
|
print(NEWLINE + TOPIC_MESSAGES_TEMPLATE.format(count=len(topic["messages"]))) |
|
|
if not topic["messages"]: |
|
|
print(MSG_NO_MESSAGES) |
|
|
for msg in topic["messages"]: |
|
|
print( |
|
|
TOPIC_MESSAGE_TEMPLATE.format( |
|
|
participant_name=msg["participant_name"], |
|
|
content=msg["content"], |
|
|
order=msg.get("order_in_topic", 0), |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
def print_messages(messages: list): |
|
|
if not messages: |
|
|
print(MSG_NO_MESSAGES) |
|
|
return |
|
|
for msg in messages: |
|
|
print( |
|
|
MESSAGE_LIST_TEMPLATE.format( |
|
|
topic_title=msg["topic_title"], |
|
|
order=msg["order_in_topic"], |
|
|
participant_name=msg["participant_name"], |
|
|
content=msg["content"], |
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
def main_menu(): |
|
|
print(MENU_HEADER) |
|
|
print(MAIN_MENU_TITLE) |
|
|
print(SEPARATOR_60) |
|
|
for option in MENU_OPTIONS: |
|
|
print(option) |
|
|
print(DASH_LINE_60) |
|
|
return input(PROMPT_SELECT_OPTION).strip() |
|
|
|
|
|
|
|
|
class MenuChoice(IntEnum): |
|
|
LIST_PARTICIPANTS = 1, "List all participants" |
|
|
GET_PARTICIPANT = 2, "Get participant by ID" |
|
|
CREATE_PARTICIPANT = 3, "Create new participant" |
|
|
LIST_TOPICS = 4, "List all topics" |
|
|
GET_TOPIC = 5, "Get topic by ID" |
|
|
CREATE_TOPIC = 6, "Create new topic" |
|
|
PUBLISH_MESSAGE = 7, "Publish message to topic" |
|
|
LIST_MESSAGES = 8, "List all messages" |
|
|
EXIT = 0, "Exit" |
|
|
|
|
|
def __new__(cls, value, label=""): |
|
|
obj = int.__new__(cls, value) |
|
|
obj._value_ = value |
|
|
obj.label = label |
|
|
return obj |
|
|
|
|
|
|
|
|
MENU_OPTIONS = tuple(f"{choice.value}. {choice.label}" for choice in MenuChoice) |
|
|
|
|
|
|
|
|
def main(): |
|
|
client = ForumClient() |
|
|
|
|
|
try: |
|
|
while True: |
|
|
raw_choice = main_menu() |
|
|
try: |
|
|
choice = MenuChoice(int(raw_choice)) |
|
|
except (ValueError, TypeError): |
|
|
print(MSG_INVALID_OPTION) |
|
|
continue |
|
|
|
|
|
if choice == MenuChoice.EXIT: |
|
|
print(MSG_GOODBYE) |
|
|
break |
|
|
|
|
|
elif choice == MenuChoice.LIST_PARTICIPANTS: |
|
|
print_separator() |
|
|
print(LABEL_ALL_PARTICIPANTS) |
|
|
try: |
|
|
participants = client.get_participants() |
|
|
print_participants(participants) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.GET_PARTICIPANT: |
|
|
print_separator() |
|
|
participant_id = input(PROMPT_PARTICIPANT_ID).strip() |
|
|
try: |
|
|
UUID(participant_id) |
|
|
participant = client.get_participant(participant_id) |
|
|
print( |
|
|
PARTICIPANT_SUMMARY_TEMPLATE.format( |
|
|
first=participant["first_name"], |
|
|
last=participant["last_name"], |
|
|
) |
|
|
) |
|
|
print(PARTICIPANT_ID_TEMPLATE.format(id=participant["id"])) |
|
|
print( |
|
|
PARTICIPANT_NICKNAME_TEMPLATE.format( |
|
|
nickname=participant["nickname"] |
|
|
) |
|
|
) |
|
|
print( |
|
|
PARTICIPANT_RATING_TEMPLATE.format( |
|
|
rating=participant["activity_rating"] |
|
|
) |
|
|
) |
|
|
print( |
|
|
PARTICIPANT_REGISTERED_TEMPLATE.format( |
|
|
registered=participant["registered_at"] |
|
|
) |
|
|
) |
|
|
except ValueError: |
|
|
print(MSG_INVALID_UUID) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.CREATE_PARTICIPANT: |
|
|
print_separator() |
|
|
print(LABEL_CREATE_PARTICIPANT) |
|
|
while True: |
|
|
first_name = input(PROMPT_FIRST_NAME).strip() |
|
|
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", first_name): |
|
|
print(PARTICIPANT_NAME_RULE) |
|
|
continue |
|
|
break |
|
|
|
|
|
while True: |
|
|
last_name = input(PROMPT_LAST_NAME).strip() |
|
|
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", last_name): |
|
|
print(PARTICIPANT_LAST_NAME_RULE) |
|
|
continue |
|
|
break |
|
|
|
|
|
while True: |
|
|
nickname = input(PROMPT_NICKNAME).strip() |
|
|
if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", nickname): |
|
|
print(PARTICIPANT_NICKNAME_RULE) |
|
|
continue |
|
|
break |
|
|
|
|
|
while True: |
|
|
activity_rating_raw = input(PROMPT_ACTIVITY).strip() |
|
|
try: |
|
|
activity_rating = float(activity_rating_raw) |
|
|
if activity_rating < 0.1 or activity_rating > 5.0: |
|
|
print(ACTIVITY_RANGE_RULE) |
|
|
continue |
|
|
except ValueError: |
|
|
print(ACTIVITY_NUMBER_RULE) |
|
|
continue |
|
|
break |
|
|
|
|
|
try: |
|
|
participant = client.create_participant( |
|
|
first_name, last_name, nickname, activity_rating |
|
|
) |
|
|
print(MSG_PARTICIPANT_CREATED) |
|
|
print( |
|
|
PARTICIPANT_CREATED_ID_TEMPLATE.format(id=participant["id"]) |
|
|
) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.LIST_TOPICS: |
|
|
print_separator() |
|
|
print(LABEL_ALL_TOPICS) |
|
|
try: |
|
|
topics = client.get_topics() |
|
|
print_topics(topics) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.LIST_MESSAGES: |
|
|
print_separator() |
|
|
print(LABEL_ALL_MESSAGES) |
|
|
try: |
|
|
messages = client.get_messages() |
|
|
print_messages(messages) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.GET_TOPIC: |
|
|
print_separator() |
|
|
topic_id = input(PROMPT_TOPIC_ID).strip() |
|
|
try: |
|
|
UUID(topic_id) |
|
|
topic = client.get_topic(topic_id) |
|
|
print_topic_detail(topic) |
|
|
except ValueError: |
|
|
print(MSG_INVALID_UUID) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.CREATE_TOPIC: |
|
|
print_separator() |
|
|
print(LABEL_CREATE_TOPIC) |
|
|
title = input(PROMPT_TITLE).strip() |
|
|
description = input(PROMPT_DESCRIPTION).strip() |
|
|
try: |
|
|
topic = client.create_topic(title, description) |
|
|
print(MSG_TOPIC_CREATED) |
|
|
print(TOPIC_CREATED_ID_TEMPLATE.format(id=topic["id"])) |
|
|
except httpx.HTTPStatusError as e: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
|
|
|
elif choice == MenuChoice.PUBLISH_MESSAGE: |
|
|
print_separator() |
|
|
print(LABEL_PUBLISH_MESSAGE) |
|
|
while True: |
|
|
topic_id = input(PROMPT_TOPIC_ID_INLINE).strip() |
|
|
if not topic_id: |
|
|
print(MSG_TOPIC_ID_REQUIRED) |
|
|
continue |
|
|
try: |
|
|
client.get_topic(topic_id) |
|
|
except httpx.HTTPStatusError as e: |
|
|
if e.response.status_code in (404, 422): |
|
|
print(MSG_TOPIC_NOT_FOUND) |
|
|
else: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
continue |
|
|
break |
|
|
|
|
|
while True: |
|
|
participant_id = input(PROMPT_PARTICIPANT_ID_INLINE).strip() |
|
|
if not participant_id: |
|
|
print(MSG_PARTICIPANT_ID_REQUIRED) |
|
|
continue |
|
|
try: |
|
|
client.get_participant(participant_id) |
|
|
except httpx.HTTPStatusError as e: |
|
|
if e.response.status_code in (404, 422): |
|
|
print(MSG_PARTICIPANT_NOT_FOUND) |
|
|
else: |
|
|
print( |
|
|
MSG_ERROR_TEMPLATE.format( |
|
|
status=e.response.status_code, text=e.response.text |
|
|
) |
|
|
) |
|
|
continue |
|
|
break |
|
|
|
|
|
content = input(PROMPT_MESSAGE_CONTENT).strip() |
|
|
|
|
|
result = client.publish_message(topic_id, participant_id, content) |
|
|
if isinstance(result, str): |
|
|
print(RESULT_MESSAGE_TEMPLATE.format(result=result)) |
|
|
else: |
|
|
print(MSG_MESSAGE_PUBLISHED) |
|
|
print( |
|
|
TOPIC_MESSAGES_COUNT_TEMPLATE.format( |
|
|
count=len(result["messages"]) |
|
|
) |
|
|
) |
|
|
|
|
|
else: |
|
|
print(MSG_INVALID_OPTION) |
|
|
|
|
|
except KeyboardInterrupt: |
|
|
print(MSG_GOODBYE_INTERRUPT) |
|
|
finally: |
|
|
client.close() |
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
main() |
|
|
|