init
Browse files- .idea/vcs.xml +6 -0
- app/api/messages/parser.py +4 -3
- app/api/messages/schemas.py +3 -3
- app/api/messages/services.py +14 -0
- app/api/messages/views.py +44 -18
- app/api/participants/services.py +4 -0
- app/api/participants/views.py +13 -0
- app/api/topics/parser.py +6 -6
- app/api/topics/schemas.py +5 -5
- app/api/topics/services.py +8 -4
- app/api/topics/views.py +29 -5
- app/core/config.py +5 -0
- app/core/crypto.py +103 -0
- app/core/file_manager.py +27 -3
- app/core/link.py +23 -9
- client.py +8 -2
- client2.py +1005 -0
- data/messages.txt +2 -1
- data/participants.txt +2 -7
- data/topics.txt +2 -3
- unified_client.py +1205 -0
.idea/vcs.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="VcsDirectoryMappings">
|
| 4 |
+
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
app/api/messages/parser.py
CHANGED
|
@@ -3,6 +3,7 @@ from datetime import datetime
|
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from app.api.messages.schemas import Message
|
|
|
|
| 6 |
from app.core.config import (
|
| 7 |
ENCODING_ASCII,
|
| 8 |
ENCODING_UTF8,
|
|
@@ -13,7 +14,7 @@ from app.core.config import (
|
|
| 13 |
TABLE_PARTICIPANTS,
|
| 14 |
TABLE_TOPICS,
|
| 15 |
)
|
| 16 |
-
from app.core.link import Link
|
| 17 |
|
| 18 |
|
| 19 |
class MessageLineParser:
|
|
@@ -49,9 +50,9 @@ class MessageLineParser:
|
|
| 49 |
|
| 50 |
return Message(
|
| 51 |
id=UUID(message_id),
|
| 52 |
-
topic_id=Link.from_raw(topic_raw
|
| 53 |
order_in_topic=int(order_in_topic),
|
| 54 |
-
participant_id=Link.from_raw(participant_raw
|
| 55 |
created_at=datetime.fromisoformat(created_at_value),
|
| 56 |
content=cls._decode_content(encoded_content),
|
| 57 |
)
|
|
|
|
| 3 |
from uuid import UUID
|
| 4 |
|
| 5 |
from app.api.messages.schemas import Message
|
| 6 |
+
from app.api.participants.schemas import Participant
|
| 7 |
from app.core.config import (
|
| 8 |
ENCODING_ASCII,
|
| 9 |
ENCODING_UTF8,
|
|
|
|
| 14 |
TABLE_PARTICIPANTS,
|
| 15 |
TABLE_TOPICS,
|
| 16 |
)
|
| 17 |
+
from app.core.link import Link, TopicLink, ParticipantLink
|
| 18 |
|
| 19 |
|
| 20 |
class MessageLineParser:
|
|
|
|
| 50 |
|
| 51 |
return Message(
|
| 52 |
id=UUID(message_id),
|
| 53 |
+
topic_id=Link.from_raw(topic_raw),
|
| 54 |
order_in_topic=int(order_in_topic),
|
| 55 |
+
participant_id=Link.from_raw(participant_raw),
|
| 56 |
created_at=datetime.fromisoformat(created_at_value),
|
| 57 |
content=cls._decode_content(encoded_content),
|
| 58 |
)
|
app/api/messages/schemas.py
CHANGED
|
@@ -4,12 +4,12 @@ from uuid import UUID
|
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
|
| 6 |
from app.core.config import TOPIC_MESSAGE_MAX_LENGTH, TOPIC_MESSAGE_MIN_LENGTH
|
| 7 |
-
from app.core.link import Link
|
| 8 |
|
| 9 |
|
| 10 |
class MessageBase(BaseModel):
|
| 11 |
-
topic_id:
|
| 12 |
-
participant_id:
|
| 13 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
| 14 |
|
| 15 |
|
|
|
|
| 4 |
from pydantic import BaseModel, Field
|
| 5 |
|
| 6 |
from app.core.config import TOPIC_MESSAGE_MAX_LENGTH, TOPIC_MESSAGE_MIN_LENGTH
|
| 7 |
+
from app.core.link import Link, ParticipantLink, TopicLink
|
| 8 |
|
| 9 |
|
| 10 |
class MessageBase(BaseModel):
|
| 11 |
+
topic_id: TopicLink
|
| 12 |
+
participant_id: ParticipantLink
|
| 13 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
| 14 |
|
| 15 |
|
app/api/messages/services.py
CHANGED
|
@@ -13,6 +13,7 @@ from app.core.config import (
|
|
| 13 |
FORBIDDEN_WORDS,
|
| 14 |
FORBIDDEN_WORDS_MESSAGE,
|
| 15 |
PARTICIPANT_NOT_FOUND_MESSAGE,
|
|
|
|
| 16 |
TABLE_PARTICIPANTS,
|
| 17 |
TABLE_TOPICS,
|
| 18 |
TOPIC_NOT_FOUND_MESSAGE,
|
|
@@ -126,3 +127,16 @@ class MessageService:
|
|
| 126 |
self.file_manager.append_line(serialized)
|
| 127 |
return message
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
FORBIDDEN_WORDS,
|
| 14 |
FORBIDDEN_WORDS_MESSAGE,
|
| 15 |
PARTICIPANT_NOT_FOUND_MESSAGE,
|
| 16 |
+
MESSAGE_NOT_FOUND_MESSAGE,
|
| 17 |
TABLE_PARTICIPANTS,
|
| 18 |
TABLE_TOPICS,
|
| 19 |
TOPIC_NOT_FOUND_MESSAGE,
|
|
|
|
| 127 |
self.file_manager.append_line(serialized)
|
| 128 |
return message
|
| 129 |
|
| 130 |
+
def get_message(self, message_id: UUID) -> Message:
|
| 131 |
+
for message in self.list_messages():
|
| 132 |
+
if message.id == message_id:
|
| 133 |
+
return message
|
| 134 |
+
raise HTTPException(
|
| 135 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 136 |
+
detail=MESSAGE_NOT_FOUND_MESSAGE,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def export_message(self, message_id: UUID) -> str:
|
| 140 |
+
message = self.get_message(message_id)
|
| 141 |
+
return MessageLineParser.serialize(message)
|
| 142 |
+
|
app/api/messages/views.py
CHANGED
|
@@ -11,33 +11,40 @@ from app.api.topics.schemas import Topic
|
|
| 11 |
from app.core.config import (
|
| 12 |
MESSAGES_FILE,
|
| 13 |
PARTICIPANTS_FILE,
|
|
|
|
| 14 |
TABLE_TOPICS,
|
| 15 |
TOPICS_FILE,
|
| 16 |
UNKNOWN_PARTICIPANT_NAME,
|
| 17 |
UNKNOWN_TOPIC_TITLE,
|
| 18 |
)
|
| 19 |
from app.core.file_manager import FileManager
|
| 20 |
-
from app.core.link import Link
|
| 21 |
|
| 22 |
|
| 23 |
router = APIRouter()
|
| 24 |
-
message_service = MessageService(
|
|
|
|
|
|
|
| 25 |
participant_service = ParticipantService(FileManager(PARTICIPANTS_FILE))
|
| 26 |
topic_file_manager = FileManager(TOPICS_FILE)
|
| 27 |
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def _list_topics() -> List[Topic]:
|
|
@@ -52,7 +59,9 @@ def _list_topics() -> List[Topic]:
|
|
| 52 |
return topics
|
| 53 |
|
| 54 |
|
| 55 |
-
def _message_to_response(
|
|
|
|
|
|
|
| 56 |
return MessageResponse(
|
| 57 |
id=message.id,
|
| 58 |
topic_id=message.topic_id,
|
|
@@ -60,8 +69,8 @@ def _message_to_response(message: Message, participants: List[Participant], topi
|
|
| 60 |
participant_id=message.participant_id,
|
| 61 |
created_at=message.created_at,
|
| 62 |
content=message.content,
|
| 63 |
-
participant_name=
|
| 64 |
-
topic_title=
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
@@ -76,10 +85,27 @@ async def list_messages() -> List[MessageResponse]:
|
|
| 76 |
|
| 77 |
@router.get("/topic/{topic_id}", response_model=List[MessageResponse])
|
| 78 |
async def list_messages_by_topic(topic_id: UUID) -> List[MessageResponse]:
|
| 79 |
-
topic_link =
|
| 80 |
message_service.get_topic(topic_link)
|
| 81 |
messages = message_service.list_messages_by_topic(topic_link)
|
| 82 |
participants = participant_service.list_participants()
|
| 83 |
topics = _list_topics()
|
| 84 |
return [_message_to_response(message, participants, topics) for message in messages]
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from app.core.config import (
|
| 12 |
MESSAGES_FILE,
|
| 13 |
PARTICIPANTS_FILE,
|
| 14 |
+
TABLE_PARTICIPANTS,
|
| 15 |
TABLE_TOPICS,
|
| 16 |
TOPICS_FILE,
|
| 17 |
UNKNOWN_PARTICIPANT_NAME,
|
| 18 |
UNKNOWN_TOPIC_TITLE,
|
| 19 |
)
|
| 20 |
from app.core.file_manager import FileManager
|
| 21 |
+
from app.core.link import Link, ParticipantLink, TopicLink
|
| 22 |
|
| 23 |
|
| 24 |
router = APIRouter()
|
| 25 |
+
message_service = MessageService(
|
| 26 |
+
FileManager(MESSAGES_FILE), FileManager(TOPICS_FILE), FileManager(PARTICIPANTS_FILE)
|
| 27 |
+
)
|
| 28 |
participant_service = ParticipantService(FileManager(PARTICIPANTS_FILE))
|
| 29 |
topic_file_manager = FileManager(TOPICS_FILE)
|
| 30 |
|
| 31 |
|
| 32 |
+
def _resolve_link_value(link: Link, participants: List[Participant], topics: List[Topic]) -> str:
|
| 33 |
+
resolvers = {
|
| 34 |
+
TABLE_PARTICIPANTS: lambda identifier: next(
|
| 35 |
+
(
|
| 36 |
+
f"{participant.first_name} {participant.last_name}".strip()
|
| 37 |
+
for participant in participants
|
| 38 |
+
if participant.id == identifier
|
| 39 |
+
),
|
| 40 |
+
UNKNOWN_PARTICIPANT_NAME,
|
| 41 |
+
),
|
| 42 |
+
TABLE_TOPICS: lambda identifier: next(
|
| 43 |
+
(topic.title for topic in topics if topic.id == identifier),
|
| 44 |
+
UNKNOWN_TOPIC_TITLE,
|
| 45 |
+
),
|
| 46 |
+
}
|
| 47 |
+
return link.resolve(resolvers)
|
| 48 |
|
| 49 |
|
| 50 |
def _list_topics() -> List[Topic]:
|
|
|
|
| 59 |
return topics
|
| 60 |
|
| 61 |
|
| 62 |
+
def _message_to_response(
|
| 63 |
+
message: Message, participants: List[Participant], topics: List[Topic]
|
| 64 |
+
) -> MessageResponse:
|
| 65 |
return MessageResponse(
|
| 66 |
id=message.id,
|
| 67 |
topic_id=message.topic_id,
|
|
|
|
| 69 |
participant_id=message.participant_id,
|
| 70 |
created_at=message.created_at,
|
| 71 |
content=message.content,
|
| 72 |
+
participant_name=_resolve_link_value(message.participant_id, participants, topics),
|
| 73 |
+
topic_title=_resolve_link_value(message.topic_id, participants, topics),
|
| 74 |
)
|
| 75 |
|
| 76 |
|
|
|
|
| 85 |
|
| 86 |
@router.get("/topic/{topic_id}", response_model=List[MessageResponse])
|
| 87 |
async def list_messages_by_topic(topic_id: UUID) -> List[MessageResponse]:
|
| 88 |
+
topic_link = TopicLink(value=topic_id)
|
| 89 |
message_service.get_topic(topic_link)
|
| 90 |
messages = message_service.list_messages_by_topic(topic_link)
|
| 91 |
participants = participant_service.list_participants()
|
| 92 |
topics = _list_topics()
|
| 93 |
return [_message_to_response(message, participants, topics) for message in messages]
|
| 94 |
|
| 95 |
+
|
| 96 |
+
@router.get("/{message_id}/download")
|
| 97 |
+
async def download_message(message_id: UUID) -> dict:
|
| 98 |
+
message = message_service.get_message(message_id)
|
| 99 |
+
participants = participant_service.list_participants()
|
| 100 |
+
topics = _list_topics()
|
| 101 |
+
response = _message_to_response(message, participants, topics)
|
| 102 |
+
return {
|
| 103 |
+
"id": str(response.id),
|
| 104 |
+
"topic_id": {"table": response.topic_id.table, "value": str(response.topic_id.value)},
|
| 105 |
+
"participant_id": {"table": response.participant_id.table, "value": str(response.participant_id.value)},
|
| 106 |
+
"content": response.content,
|
| 107 |
+
"order_in_topic": response.order_in_topic,
|
| 108 |
+
"created_at": response.created_at.isoformat(),
|
| 109 |
+
"participant_name": response.participant_name,
|
| 110 |
+
"topic_title": response.topic_title,
|
| 111 |
+
}
|
app/api/participants/services.py
CHANGED
|
@@ -45,5 +45,9 @@ class ParticipantService:
|
|
| 45 |
self.file_manager.append_line(serialized)
|
| 46 |
return participant
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
|
|
|
| 45 |
self.file_manager.append_line(serialized)
|
| 46 |
return participant
|
| 47 |
|
| 48 |
+
def export_participant(self, participant_id: UUID) -> str:
|
| 49 |
+
participant = self.get_participant(participant_id)
|
| 50 |
+
return ParticipantLineParser.serialize(participant)
|
| 51 |
+
|
| 52 |
|
| 53 |
|
app/api/participants/views.py
CHANGED
|
@@ -28,4 +28,17 @@ async def create_participant(payload: ParticipantCreate) -> Participant:
|
|
| 28 |
return participant_service.create_participant(payload)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
|
|
|
| 28 |
return participant_service.create_participant(payload)
|
| 29 |
|
| 30 |
|
| 31 |
+
@router.get("/{participant_id}/download")
|
| 32 |
+
async def download_participant(participant_id: UUID) -> dict:
|
| 33 |
+
participant = participant_service.get_participant(participant_id)
|
| 34 |
+
return {
|
| 35 |
+
"id": str(participant.id),
|
| 36 |
+
"first_name": participant.first_name,
|
| 37 |
+
"last_name": participant.last_name,
|
| 38 |
+
"nickname": participant.nickname,
|
| 39 |
+
"activity_rating": participant.activity_rating,
|
| 40 |
+
"registered_at": participant.registered_at.isoformat(),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
|
app/api/topics/parser.py
CHANGED
|
@@ -16,7 +16,7 @@ from app.core.config import (
|
|
| 16 |
TOPIC_PARSE_INVALID_ERROR,
|
| 17 |
TOPIC_SEPARATOR,
|
| 18 |
)
|
| 19 |
-
from app.core.link import Link
|
| 20 |
|
| 21 |
|
| 22 |
class TopicLineParser:
|
|
@@ -35,14 +35,14 @@ class TopicLineParser:
|
|
| 35 |
return base64.b64encode(value.encode(ENCODING_UTF8)).decode(ENCODING_ASCII)
|
| 36 |
|
| 37 |
@classmethod
|
| 38 |
-
def _parse_participants(cls, raw: str) -> List[
|
| 39 |
if not raw:
|
| 40 |
return []
|
| 41 |
-
links: List[
|
| 42 |
for value in raw.split(cls.INNER_SEPARATOR):
|
| 43 |
if not value:
|
| 44 |
continue
|
| 45 |
-
links.append(
|
| 46 |
return links
|
| 47 |
|
| 48 |
@classmethod
|
|
@@ -59,14 +59,14 @@ class TopicLineParser:
|
|
| 59 |
continue
|
| 60 |
messages.append(
|
| 61 |
TopicMessage(
|
| 62 |
-
participant_id=
|
| 63 |
content=cls._decode_message(encoded_content),
|
| 64 |
)
|
| 65 |
)
|
| 66 |
return messages
|
| 67 |
|
| 68 |
@classmethod
|
| 69 |
-
def _serialize_participants(cls, participants: List[
|
| 70 |
return cls.INNER_SEPARATOR.join(participant.as_path() for participant in participants)
|
| 71 |
|
| 72 |
@classmethod
|
|
|
|
| 16 |
TOPIC_PARSE_INVALID_ERROR,
|
| 17 |
TOPIC_SEPARATOR,
|
| 18 |
)
|
| 19 |
+
from app.core.link import Link, ParticipantLink
|
| 20 |
|
| 21 |
|
| 22 |
class TopicLineParser:
|
|
|
|
| 35 |
return base64.b64encode(value.encode(ENCODING_UTF8)).decode(ENCODING_ASCII)
|
| 36 |
|
| 37 |
@classmethod
|
| 38 |
+
def _parse_participants(cls, raw: str) -> List[ParticipantLink]:
|
| 39 |
if not raw:
|
| 40 |
return []
|
| 41 |
+
links: List[ParticipantLink] = []
|
| 42 |
for value in raw.split(cls.INNER_SEPARATOR):
|
| 43 |
if not value:
|
| 44 |
continue
|
| 45 |
+
links.append(ParticipantLink.from_raw(value))
|
| 46 |
return links
|
| 47 |
|
| 48 |
@classmethod
|
|
|
|
| 59 |
continue
|
| 60 |
messages.append(
|
| 61 |
TopicMessage(
|
| 62 |
+
participant_id=ParticipantLink.from_raw(participant_raw),
|
| 63 |
content=cls._decode_message(encoded_content),
|
| 64 |
)
|
| 65 |
)
|
| 66 |
return messages
|
| 67 |
|
| 68 |
@classmethod
|
| 69 |
+
def _serialize_participants(cls, participants: List[ParticipantLink]) -> str:
|
| 70 |
return cls.INNER_SEPARATOR.join(participant.as_path() for participant in participants)
|
| 71 |
|
| 72 |
@classmethod
|
app/api/topics/schemas.py
CHANGED
|
@@ -12,11 +12,11 @@ from app.core.config import (
|
|
| 12 |
TOPIC_TITLE_MAX_LENGTH,
|
| 13 |
TOPIC_TITLE_MIN_LENGTH,
|
| 14 |
)
|
| 15 |
-
from app.core.link import
|
| 16 |
|
| 17 |
|
| 18 |
class TopicMessageBase(BaseModel):
|
| 19 |
-
participant_id:
|
| 20 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
| 21 |
order_in_topic: int | None = None
|
| 22 |
|
|
@@ -39,7 +39,7 @@ class TopicBase(BaseModel):
|
|
| 39 |
min_length=TOPIC_DESCRIPTION_MIN_LENGTH,
|
| 40 |
max_length=TOPIC_DESCRIPTION_MAX_LENGTH,
|
| 41 |
)
|
| 42 |
-
participants: List[
|
| 43 |
|
| 44 |
|
| 45 |
class TopicCreate(TopicBase):
|
|
@@ -51,7 +51,7 @@ class Topic(BaseModel):
|
|
| 51 |
title: str
|
| 52 |
description: str
|
| 53 |
created_at: datetime
|
| 54 |
-
participants: List[
|
| 55 |
messages: List[TopicMessage] = Field(default_factory=list)
|
| 56 |
|
| 57 |
|
|
@@ -62,5 +62,5 @@ class TopicResponse(TopicBase):
|
|
| 62 |
|
| 63 |
|
| 64 |
class AddMessageRequest(BaseModel):
|
| 65 |
-
participant_id:
|
| 66 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
|
|
|
| 12 |
TOPIC_TITLE_MAX_LENGTH,
|
| 13 |
TOPIC_TITLE_MIN_LENGTH,
|
| 14 |
)
|
| 15 |
+
from app.core.link import ParticipantLink
|
| 16 |
|
| 17 |
|
| 18 |
class TopicMessageBase(BaseModel):
|
| 19 |
+
participant_id: ParticipantLink
|
| 20 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
| 21 |
order_in_topic: int | None = None
|
| 22 |
|
|
|
|
| 39 |
min_length=TOPIC_DESCRIPTION_MIN_LENGTH,
|
| 40 |
max_length=TOPIC_DESCRIPTION_MAX_LENGTH,
|
| 41 |
)
|
| 42 |
+
participants: List[ParticipantLink] = Field(default_factory=list)
|
| 43 |
|
| 44 |
|
| 45 |
class TopicCreate(TopicBase):
|
|
|
|
| 51 |
title: str
|
| 52 |
description: str
|
| 53 |
created_at: datetime
|
| 54 |
+
participants: List[ParticipantLink] = Field(default_factory=list)
|
| 55 |
messages: List[TopicMessage] = Field(default_factory=list)
|
| 56 |
|
| 57 |
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
class AddMessageRequest(BaseModel):
|
| 65 |
+
participant_id: ParticipantLink
|
| 66 |
content: str = Field(min_length=TOPIC_MESSAGE_MIN_LENGTH, max_length=TOPIC_MESSAGE_MAX_LENGTH)
|
app/api/topics/services.py
CHANGED
|
@@ -15,7 +15,7 @@ from app.core.config import (
|
|
| 15 |
TOPIC_NOT_FOUND_MESSAGE,
|
| 16 |
)
|
| 17 |
from app.core.file_manager import FileManager
|
| 18 |
-
from app.core.link import Link
|
| 19 |
|
| 20 |
|
| 21 |
class TopicService:
|
|
@@ -74,7 +74,7 @@ class TopicService:
|
|
| 74 |
for message in payload.messages:
|
| 75 |
self.message_service.create_message(
|
| 76 |
MessageCreate(
|
| 77 |
-
topic_id=
|
| 78 |
participant_id=message.participant_id,
|
| 79 |
content=message.content,
|
| 80 |
)
|
|
@@ -102,8 +102,8 @@ class TopicService:
|
|
| 102 |
if self.message_service:
|
| 103 |
self.message_service.create_message(
|
| 104 |
MessageCreate(
|
| 105 |
-
topic_id=
|
| 106 |
-
participant_id=payload.participant_id,
|
| 107 |
content=payload.content,
|
| 108 |
)
|
| 109 |
)
|
|
@@ -132,3 +132,7 @@ class TopicService:
|
|
| 132 |
self.file_manager.write_lines(lines)
|
| 133 |
|
| 134 |
return topics[topic_index]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
TOPIC_NOT_FOUND_MESSAGE,
|
| 16 |
)
|
| 17 |
from app.core.file_manager import FileManager
|
| 18 |
+
from app.core.link import Link, TopicLink, ParticipantLink
|
| 19 |
|
| 20 |
|
| 21 |
class TopicService:
|
|
|
|
| 74 |
for message in payload.messages:
|
| 75 |
self.message_service.create_message(
|
| 76 |
MessageCreate(
|
| 77 |
+
topic_id=TopicLink(value=topic.id),
|
| 78 |
participant_id=message.participant_id,
|
| 79 |
content=message.content,
|
| 80 |
)
|
|
|
|
| 102 |
if self.message_service:
|
| 103 |
self.message_service.create_message(
|
| 104 |
MessageCreate(
|
| 105 |
+
topic_id=TopicLink(value=resolved_id),
|
| 106 |
+
participant_id=ParticipantLink(value=payload.participant_id.value),
|
| 107 |
content=payload.content,
|
| 108 |
)
|
| 109 |
)
|
|
|
|
| 132 |
self.file_manager.write_lines(lines)
|
| 133 |
|
| 134 |
return topics[topic_index]
|
| 135 |
+
|
| 136 |
+
def export_topic(self, topic_id: UUID | Link) -> str:
|
| 137 |
+
topic = self.get_topic(topic_id)
|
| 138 |
+
return TopicLineParser.serialize(topic)
|
app/api/topics/views.py
CHANGED
|
@@ -19,12 +19,11 @@ from app.core.config import (
|
|
| 19 |
HTTP_STATUS_CREATED,
|
| 20 |
MESSAGES_FILE,
|
| 21 |
PARTICIPANTS_FILE,
|
| 22 |
-
TABLE_TOPICS,
|
| 23 |
TOPICS_FILE,
|
| 24 |
UNKNOWN_PARTICIPANT_NAME,
|
| 25 |
)
|
| 26 |
from app.core.file_manager import FileManager
|
| 27 |
-
from app.core.link import Link
|
| 28 |
|
| 29 |
|
| 30 |
router = APIRouter()
|
|
@@ -77,7 +76,7 @@ async def list_topics() -> List[TopicResponse]:
|
|
| 77 |
|
| 78 |
@router.get("/{topic_id}", response_model=TopicResponse)
|
| 79 |
async def get_topic(topic_id: UUID) -> TopicResponse:
|
| 80 |
-
topic_link =
|
| 81 |
topic = topic_service.get_topic(topic_link)
|
| 82 |
participants = participant_service.list_participants()
|
| 83 |
topic_messages = message_service.list_messages_by_topic(topic_link)
|
|
@@ -88,14 +87,39 @@ async def get_topic(topic_id: UUID) -> TopicResponse:
|
|
| 88 |
async def create_topic(payload: TopicCreate) -> TopicResponse:
|
| 89 |
topic = topic_service.create_topic(payload)
|
| 90 |
participants = participant_service.list_participants()
|
| 91 |
-
topic_messages = message_service.list_messages_by_topic(
|
| 92 |
return _topic_to_response(topic, participants, topic_messages)
|
| 93 |
|
| 94 |
|
| 95 |
@router.post("/{topic_id}/messages", response_model=TopicResponse, status_code=HTTP_STATUS_CREATED)
|
| 96 |
async def add_message(topic_id: UUID, payload: AddMessageRequest) -> TopicResponse:
|
| 97 |
-
topic_link =
|
| 98 |
topic = topic_service.add_message(topic_link, payload)
|
| 99 |
participants = participant_service.list_participants()
|
| 100 |
topic_messages = message_service.list_messages_by_topic(topic_link)
|
| 101 |
return _topic_to_response(topic, participants, topic_messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
HTTP_STATUS_CREATED,
|
| 20 |
MESSAGES_FILE,
|
| 21 |
PARTICIPANTS_FILE,
|
|
|
|
| 22 |
TOPICS_FILE,
|
| 23 |
UNKNOWN_PARTICIPANT_NAME,
|
| 24 |
)
|
| 25 |
from app.core.file_manager import FileManager
|
| 26 |
+
from app.core.link import Link, TopicLink
|
| 27 |
|
| 28 |
|
| 29 |
router = APIRouter()
|
|
|
|
| 76 |
|
| 77 |
@router.get("/{topic_id}", response_model=TopicResponse)
|
| 78 |
async def get_topic(topic_id: UUID) -> TopicResponse:
|
| 79 |
+
topic_link = TopicLink(value=topic_id)
|
| 80 |
topic = topic_service.get_topic(topic_link)
|
| 81 |
participants = participant_service.list_participants()
|
| 82 |
topic_messages = message_service.list_messages_by_topic(topic_link)
|
|
|
|
| 87 |
async def create_topic(payload: TopicCreate) -> TopicResponse:
|
| 88 |
topic = topic_service.create_topic(payload)
|
| 89 |
participants = participant_service.list_participants()
|
| 90 |
+
topic_messages = message_service.list_messages_by_topic(TopicLink(value=topic.id))
|
| 91 |
return _topic_to_response(topic, participants, topic_messages)
|
| 92 |
|
| 93 |
|
| 94 |
@router.post("/{topic_id}/messages", response_model=TopicResponse, status_code=HTTP_STATUS_CREATED)
|
| 95 |
async def add_message(topic_id: UUID, payload: AddMessageRequest) -> TopicResponse:
|
| 96 |
+
topic_link = TopicLink(value=topic_id)
|
| 97 |
topic = topic_service.add_message(topic_link, payload)
|
| 98 |
participants = participant_service.list_participants()
|
| 99 |
topic_messages = message_service.list_messages_by_topic(topic_link)
|
| 100 |
return _topic_to_response(topic, participants, topic_messages)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@router.get("/{topic_id}/download")
|
| 104 |
+
async def download_topic(topic_id: UUID) -> dict:
|
| 105 |
+
topic_link = TopicLink(value=topic_id)
|
| 106 |
+
topic = topic_service.get_topic(topic_link)
|
| 107 |
+
participants = participant_service.list_participants()
|
| 108 |
+
topic_messages = message_service.list_messages_by_topic(topic_link)
|
| 109 |
+
response = _topic_to_response(topic, participants, topic_messages)
|
| 110 |
+
return {
|
| 111 |
+
"id": str(response.id),
|
| 112 |
+
"title": response.title,
|
| 113 |
+
"description": response.description,
|
| 114 |
+
"created_at": response.created_at.isoformat(),
|
| 115 |
+
"participants": [{"table": p.table, "value": str(p.value)} for p in response.participants],
|
| 116 |
+
"messages": [
|
| 117 |
+
{
|
| 118 |
+
"participant_id": {"table": m.participant_id.table, "value": str(m.participant_id.value)},
|
| 119 |
+
"content": m.content,
|
| 120 |
+
"participant_name": m.participant_name,
|
| 121 |
+
"order_in_topic": m.order_in_topic,
|
| 122 |
+
}
|
| 123 |
+
for m in response.messages
|
| 124 |
+
],
|
| 125 |
+
}
|
app/core/config.py
CHANGED
|
@@ -25,6 +25,11 @@ MESSAGE_EXPECTED_PARTS = 6
|
|
| 25 |
ENCODING_UTF8 = "utf-8"
|
| 26 |
ENCODING_ASCII = "ascii"
|
| 27 |
NEWLINE = "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
NAME_MIN_LENGTH = 1
|
| 30 |
NAME_MAX_LENGTH = 100
|
|
|
|
| 25 |
ENCODING_UTF8 = "utf-8"
|
| 26 |
ENCODING_ASCII = "ascii"
|
| 27 |
NEWLINE = "\n"
|
| 28 |
+
ALPHABET = (
|
| 29 |
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
| 30 |
+
"0123456789"
|
| 31 |
+
r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ """
|
| 32 |
+
)
|
| 33 |
|
| 34 |
NAME_MIN_LENGTH = 1
|
| 35 |
NAME_MAX_LENGTH = 100
|
app/core/crypto.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Tuple
|
| 3 |
+
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
from app.core.config import ALPHABET, ENCODING_UTF8
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ENV_KEY_SHIFT = 5
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ShiftCipher:
|
| 13 |
+
@staticmethod
|
| 14 |
+
def _shift_char(ch: str, shift: int) -> str:
|
| 15 |
+
index = ALPHABET.find(ch)
|
| 16 |
+
if index == -1:
|
| 17 |
+
raise ValueError(f"Character '{ch}' is not in the configured alphabet")
|
| 18 |
+
return ALPHABET[(index + shift) % len(ALPHABET)]
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def apply(data: bytes, shift: int) -> bytes:
|
| 22 |
+
text = data.decode(ENCODING_UTF8)
|
| 23 |
+
shifted = "".join(ShiftCipher._shift_char(ch, shift) for ch in text)
|
| 24 |
+
return shifted.encode(ENCODING_UTF8)
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def transform_text(value: str, shift: int) -> str:
|
| 28 |
+
return "".join(ShiftCipher._shift_char(ch, shift) for ch in value)
|
| 29 |
+
|
| 30 |
+
class LZ78Codec:
|
| 31 |
+
@staticmethod
|
| 32 |
+
def compress(text: str) -> bytes:
|
| 33 |
+
dictionary: dict[str, int] = {}
|
| 34 |
+
current = ""
|
| 35 |
+
output: List[Tuple[int, str]] = []
|
| 36 |
+
for ch in text:
|
| 37 |
+
candidate = current + ch
|
| 38 |
+
if candidate in dictionary:
|
| 39 |
+
current = candidate
|
| 40 |
+
continue
|
| 41 |
+
index = dictionary.get(current, 0)
|
| 42 |
+
output.append((index, ch))
|
| 43 |
+
dictionary[candidate] = len(dictionary) + 1
|
| 44 |
+
current = ""
|
| 45 |
+
if current:
|
| 46 |
+
output.append((dictionary.get(current, 0), ""))
|
| 47 |
+
serialized = ";".join(f"{index}:{ord(ch) if ch else -1}" for index, ch in output)
|
| 48 |
+
return serialized.encode(ENCODING_UTF8)
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def decompress(data: bytes) -> str:
|
| 52 |
+
if not data:
|
| 53 |
+
return ""
|
| 54 |
+
serialized = data.decode(ENCODING_UTF8)
|
| 55 |
+
if not serialized:
|
| 56 |
+
return ""
|
| 57 |
+
pairs: List[Tuple[int, str]] = []
|
| 58 |
+
for chunk in serialized.split(";"):
|
| 59 |
+
if not chunk:
|
| 60 |
+
continue
|
| 61 |
+
index_part, code_part = chunk.split(":", 1)
|
| 62 |
+
idx = int(index_part)
|
| 63 |
+
code = int(code_part)
|
| 64 |
+
ch = "" if code == -1 else chr(code)
|
| 65 |
+
pairs.append((idx, ch))
|
| 66 |
+
dictionary: dict[int, str] = {0: ""}
|
| 67 |
+
result_parts: List[str] = []
|
| 68 |
+
for idx, ch in pairs:
|
| 69 |
+
prefix = dictionary.get(idx, "")
|
| 70 |
+
entry = prefix + ch
|
| 71 |
+
result_parts.append(entry)
|
| 72 |
+
dictionary[len(dictionary)] = entry
|
| 73 |
+
return "".join(result_parts)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class DataCipher:
|
| 77 |
+
def __init__(self, key: str) -> None:
|
| 78 |
+
self.key = key
|
| 79 |
+
self.shift = self._derive_shift(key)
|
| 80 |
+
|
| 81 |
+
@staticmethod
|
| 82 |
+
def _derive_shift(key: str) -> int:
|
| 83 |
+
value = sum(ord(ch) for ch in key) % 256
|
| 84 |
+
return value if value else 1
|
| 85 |
+
|
| 86 |
+
def encrypt(self, plain: str) -> str:
|
| 87 |
+
compressed = LZ78Codec.compress(plain)
|
| 88 |
+
encrypted = ShiftCipher.apply(compressed, self.shift)
|
| 89 |
+
return encrypted.hex()
|
| 90 |
+
|
| 91 |
+
def decrypt(self, encoded: str) -> str:
|
| 92 |
+
encrypted = bytes.fromhex(encoded)
|
| 93 |
+
decrypted = ShiftCipher.apply(encrypted, -self.shift)
|
| 94 |
+
return LZ78Codec.decompress(decrypted)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_encryption_key() -> str:
|
| 98 |
+
load_dotenv()
|
| 99 |
+
encrypted_key = os.getenv("APP_KEY_ENC")
|
| 100 |
+
if not encrypted_key:
|
| 101 |
+
raise RuntimeError("APP_KEY_ENC is not set")
|
| 102 |
+
return ShiftCipher.transform_text(encrypted_key, -ENV_KEY_SHIFT)
|
| 103 |
+
|
app/core/file_manager.py
CHANGED
|
@@ -2,26 +2,50 @@ from pathlib import Path
|
|
| 2 |
from typing import Iterable, List
|
| 3 |
|
| 4 |
from app.core.config import ENCODING_UTF8, NEWLINE
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class FileManager:
|
|
|
|
|
|
|
| 8 |
def __init__(self, file_path: Path) -> None:
|
| 9 |
self.file_path = file_path
|
| 10 |
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 11 |
self.file_path.touch(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def read_lines(self) -> List[str]:
|
| 14 |
with self.file_path.open("r", encoding=ENCODING_UTF8) as file:
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
def write_lines(self, lines: Iterable[str]) -> None:
|
| 18 |
with self.file_path.open("w", encoding=ENCODING_UTF8) as file:
|
| 19 |
for line in lines:
|
| 20 |
-
file.write(f"{line}{NEWLINE}")
|
| 21 |
|
| 22 |
def append_line(self, line: str) -> None:
|
| 23 |
with self.file_path.open("a", encoding=ENCODING_UTF8) as file:
|
| 24 |
-
file.write(f"{line}{NEWLINE}")
|
| 25 |
|
| 26 |
|
| 27 |
|
|
|
|
| 2 |
from typing import Iterable, List
|
| 3 |
|
| 4 |
from app.core.config import ENCODING_UTF8, NEWLINE
|
| 5 |
+
from app.core.crypto import DataCipher, load_encryption_key
|
| 6 |
|
| 7 |
|
| 8 |
class FileManager:
|
| 9 |
+
_cipher_instance: DataCipher | None = None
|
| 10 |
+
|
| 11 |
def __init__(self, file_path: Path) -> None:
|
| 12 |
self.file_path = file_path
|
| 13 |
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 14 |
self.file_path.touch(exist_ok=True)
|
| 15 |
+
self._cipher = self._get_cipher()
|
| 16 |
+
|
| 17 |
+
@classmethod
|
| 18 |
+
def _get_cipher(cls) -> DataCipher:
|
| 19 |
+
if cls._cipher_instance is None:
|
| 20 |
+
cls._cipher_instance = DataCipher(load_encryption_key())
|
| 21 |
+
return cls._cipher_instance
|
| 22 |
+
|
| 23 |
+
def _decode_line(self, raw: str) -> str:
|
| 24 |
+
try:
|
| 25 |
+
return self._cipher.decrypt(raw)
|
| 26 |
+
except Exception:
|
| 27 |
+
return raw
|
| 28 |
+
|
| 29 |
+
def _encode_line(self, plain: str) -> str:
|
| 30 |
+
return self._cipher.encrypt(plain)
|
| 31 |
|
| 32 |
def read_lines(self) -> List[str]:
|
| 33 |
with self.file_path.open("r", encoding=ENCODING_UTF8) as file:
|
| 34 |
+
decoded: List[str] = []
|
| 35 |
+
for line in file:
|
| 36 |
+
if not line.strip():
|
| 37 |
+
continue
|
| 38 |
+
decoded.append(self._decode_line(line.rstrip(NEWLINE)))
|
| 39 |
+
return decoded
|
| 40 |
|
| 41 |
def write_lines(self, lines: Iterable[str]) -> None:
|
| 42 |
with self.file_path.open("w", encoding=ENCODING_UTF8) as file:
|
| 43 |
for line in lines:
|
| 44 |
+
file.write(f"{self._encode_line(line)}{NEWLINE}")
|
| 45 |
|
| 46 |
def append_line(self, line: str) -> None:
|
| 47 |
with self.file_path.open("a", encoding=ENCODING_UTF8) as file:
|
| 48 |
+
file.write(f"{self._encode_line(line)}{NEWLINE}")
|
| 49 |
|
| 50 |
|
| 51 |
|
app/core/link.py
CHANGED
|
@@ -2,7 +2,9 @@ from typing import Any, Callable
|
|
| 2 |
from uuid import UUID
|
| 3 |
|
| 4 |
from fastapi import HTTPException, status
|
| 5 |
-
from pydantic import BaseModel
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
class Link(BaseModel):
|
|
@@ -23,16 +25,28 @@ class Link(BaseModel):
|
|
| 23 |
return f"/{path}" if leading_slash else path
|
| 24 |
|
| 25 |
@classmethod
|
| 26 |
-
def from_raw(cls, raw: str
|
| 27 |
text = raw.strip()
|
| 28 |
if not text:
|
| 29 |
raise ValueError("Cannot parse empty link value")
|
| 30 |
normalized = text.lstrip("/")
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
|
|
|
| 2 |
from uuid import UUID
|
| 3 |
|
| 4 |
from fastapi import HTTPException, status
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
from app.core.config import TABLE_PARTICIPANTS, TABLE_TOPICS
|
| 8 |
|
| 9 |
|
| 10 |
class Link(BaseModel):
|
|
|
|
| 25 |
return f"/{path}" if leading_slash else path
|
| 26 |
|
| 27 |
@classmethod
|
| 28 |
+
def from_raw(cls, raw: str) -> "Link":
|
| 29 |
text = raw.strip()
|
| 30 |
if not text:
|
| 31 |
raise ValueError("Cannot parse empty link value")
|
| 32 |
normalized = text.lstrip("/")
|
| 33 |
+
table, value = normalized.split("/", 1)
|
| 34 |
+
link_cls = LINK_TYPE_REGISTRY.get(table, Link)
|
| 35 |
+
if link_cls is Link:
|
| 36 |
+
return link_cls(table=table, value=UUID(value))
|
| 37 |
+
return link_cls(value=UUID(value))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TopicLink(Link):
|
| 41 |
+
table: str = Field(default=TABLE_TOPICS)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ParticipantLink(Link):
|
| 45 |
+
table: str = Field(default=TABLE_PARTICIPANTS)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
LINK_TYPE_REGISTRY: dict[str, type[Link]] = {
|
| 49 |
+
TABLE_TOPICS: TopicLink,
|
| 50 |
+
TABLE_PARTICIPANTS: ParticipantLink,
|
| 51 |
+
}
|
| 52 |
|
client.py
CHANGED
|
@@ -5,6 +5,8 @@ from uuid import UUID
|
|
| 5 |
import httpx
|
| 6 |
|
| 7 |
BASE_URL = "http://127.0.0.1:8000"
|
|
|
|
|
|
|
| 8 |
|
| 9 |
NEWLINE = "\n"
|
| 10 |
SEPARATOR_60 = "=" * 60
|
|
@@ -77,6 +79,10 @@ class ForumClient:
|
|
| 77 |
self.base_url = base_url
|
| 78 |
self.client = httpx.Client(timeout=30.0)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
def get_participants(self) -> list:
|
| 81 |
response = self.client.get(f"{self.base_url}/participants/")
|
| 82 |
response.raise_for_status()
|
|
@@ -126,7 +132,7 @@ class ForumClient:
|
|
| 126 |
payload = {
|
| 127 |
"title": title,
|
| 128 |
"description": description,
|
| 129 |
-
"participants": participants or [],
|
| 130 |
"messages": [],
|
| 131 |
}
|
| 132 |
response = self.client.post(f"{self.base_url}/topics/", json=payload)
|
|
@@ -137,7 +143,7 @@ class ForumClient:
|
|
| 137 |
self, topic_id: str, participant_id: str, content: str
|
| 138 |
) -> dict | str:
|
| 139 |
payload = {
|
| 140 |
-
"participant_id": participant_id,
|
| 141 |
"content": content,
|
| 142 |
}
|
| 143 |
response = self.client.post(
|
|
|
|
| 5 |
import httpx
|
| 6 |
|
| 7 |
BASE_URL = "http://127.0.0.1:8000"
|
| 8 |
+
TABLE_PARTICIPANTS = "participants"
|
| 9 |
+
TABLE_TOPICS = "topics"
|
| 10 |
|
| 11 |
NEWLINE = "\n"
|
| 12 |
SEPARATOR_60 = "=" * 60
|
|
|
|
| 79 |
self.base_url = base_url
|
| 80 |
self.client = httpx.Client(timeout=30.0)
|
| 81 |
|
| 82 |
+
@staticmethod
|
| 83 |
+
def _link(table: str, value: str) -> dict:
|
| 84 |
+
return {"table": table, "value": value}
|
| 85 |
+
|
| 86 |
def get_participants(self) -> list:
|
| 87 |
response = self.client.get(f"{self.base_url}/participants/")
|
| 88 |
response.raise_for_status()
|
|
|
|
| 132 |
payload = {
|
| 133 |
"title": title,
|
| 134 |
"description": description,
|
| 135 |
+
"participants": [self._link(TABLE_PARTICIPANTS, p) for p in (participants or [])],
|
| 136 |
"messages": [],
|
| 137 |
}
|
| 138 |
response = self.client.post(f"{self.base_url}/topics/", json=payload)
|
|
|
|
| 143 |
self, topic_id: str, participant_id: str, content: str
|
| 144 |
) -> dict | str:
|
| 145 |
payload = {
|
| 146 |
+
"participant_id": self._link(TABLE_PARTICIPANTS, participant_id),
|
| 147 |
"content": content,
|
| 148 |
}
|
| 149 |
response = self.client.post(
|
client2.py
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from datetime import date, datetime
|
| 4 |
+
from enum import IntEnum
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, List, Optional, Callable
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
|
| 10 |
+
BASE_URL = "https://brestok-vika-server.hf.space"
|
| 11 |
+
DOWNLOADS_DIR = Path(__file__).parent / "downloads"
|
| 12 |
+
SEPARATOR = "=" * 70
|
| 13 |
+
PROMPT = "Select option: "
|
| 14 |
+
|
| 15 |
+
GENRES = [
|
| 16 |
+
"Fiction",
|
| 17 |
+
"Non-Fiction",
|
| 18 |
+
"Mystery",
|
| 19 |
+
"Sci-Fi",
|
| 20 |
+
"Fantasy",
|
| 21 |
+
"Biography",
|
| 22 |
+
"History",
|
| 23 |
+
"Romance",
|
| 24 |
+
"Thriller",
|
| 25 |
+
"Horror",
|
| 26 |
+
"Poetry",
|
| 27 |
+
"Drama",
|
| 28 |
+
"Comics",
|
| 29 |
+
"Other",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
BOOK_STATUSES = ["Available", "Borrowed"]
|
| 33 |
+
|
| 34 |
+
WORK_DAYS = [
|
| 35 |
+
"Monday",
|
| 36 |
+
"Tuesday",
|
| 37 |
+
"Wednesday",
|
| 38 |
+
"Thursday",
|
| 39 |
+
"Friday",
|
| 40 |
+
"Saturday",
|
| 41 |
+
"Sunday",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
NAME_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ\s\-']+$")
|
| 45 |
+
TITLE_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ0-9\s\-'.,!?:;\"()]+$")
|
| 46 |
+
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}$")
|
| 47 |
+
|
| 48 |
+
# Validation error messages
|
| 49 |
+
MSG_IS_REQUIRED = "is required."
|
| 50 |
+
MSG_AT_LEAST_2_CHARS = "must be at least 2 characters."
|
| 51 |
+
MSG_AT_MOST_50_CHARS = "must be at most 50 characters."
|
| 52 |
+
MSG_ONLY_LETTERS_SPACES_HYPHENS_APOSTROPHES = "must contain only letters, spaces, hyphens, and apostrophes."
|
| 53 |
+
MSG_AT_MOST_100_CHARS = "must be at most 100 characters."
|
| 54 |
+
MSG_CONTAINS_INVALID_CHARS = "contains invalid characters."
|
| 55 |
+
MSG_AUTHOR_REQUIRED = "Author is required."
|
| 56 |
+
MSG_AUTHOR_AT_LEAST_2_CHARS = "Author must be at least 2 characters."
|
| 57 |
+
MSG_AUTHOR_AT_MOST_100_CHARS = "Author must be at most 100 characters."
|
| 58 |
+
MSG_AUTHOR_ONLY_LETTERS = "Author must contain only letters, spaces, hyphens, and apostrophes."
|
| 59 |
+
MSG_PAGES_AT_LEAST_1 = "Pages must be at least 1."
|
| 60 |
+
MSG_PAGES_AT_MOST_10000 = "Pages must be at most 10000."
|
| 61 |
+
MSG_YEAR_AT_LEAST_1000 = "Year must be at least 1000."
|
| 62 |
+
MSG_YEAR_CANNOT_BE_GREATER = "Year cannot be greater than {current_year}."
|
| 63 |
+
MSG_GENRE_MUST_BE_ONE_OF = "Genre must be one of: {', '.join(GENRES)}"
|
| 64 |
+
MSG_STATUS_MUST_BE_ONE_OF = "Status must be one of: {', '.join(BOOK_STATUSES)}"
|
| 65 |
+
MSG_EXPERIENCE_NEGATIVE = "Experience cannot be negative."
|
| 66 |
+
MSG_EXPERIENCE_AT_MOST_60 = "Experience must be at most 60 years."
|
| 67 |
+
MSG_AT_LEAST_ONE_WORK_DAY = "At least one work day is required."
|
| 68 |
+
MSG_INVALID_WORK_DAYS = "Invalid work days: {', '.join(invalid)}. Valid options: {', '.join(WORK_DAYS)}"
|
| 69 |
+
MSG_MUST_BE_VALID_UUID = "must be a valid UUID format."
|
| 70 |
+
MSG_DATE_YYYY_MM_DD = "Date must be in YYYY-MM-DD format."
|
| 71 |
+
MSG_AT_LEAST_ONE_ITEM_REQUIRED = "At least one item is required."
|
| 72 |
+
MSG_ITEM_EMPTY = "Item #{i} is empty."
|
| 73 |
+
MSG_ITEM_INVALID_UUID = "Item #{i} ({id_val}) is not a valid UUID."
|
| 74 |
+
|
| 75 |
+
# Error and info messages
|
| 76 |
+
MSG_ERROR_ENTER_VALID_INTEGER = " Error: Enter a valid integer."
|
| 77 |
+
MSG_ERROR_INVALID_CHOICE = " Error: Invalid choice."
|
| 78 |
+
MSG_ERROR_INVALID_NUMBER = " Error: Invalid number {p}."
|
| 79 |
+
MSG_ERROR_INVALID_VALUE = " Error: Invalid value '{p}'."
|
| 80 |
+
MSG_ERROR_AT_LEAST_ONE_VALUE = " Error: At least one value is required."
|
| 81 |
+
MSG_ERROR_BOOK_NOT_FOUND = " Error: Book with ID {book_id} not found."
|
| 82 |
+
MSG_ERROR_BOOK_NOT_FOUND_VALIDATED = " Error: Book with ID {validated} not found."
|
| 83 |
+
MSG_ERROR_VISITOR_NOT_FOUND = " Error: Visitor with ID {validated} not found."
|
| 84 |
+
MSG_ERROR_WORKER_NOT_FOUND = " Error: Worker with ID {validated} not found."
|
| 85 |
+
MSG_NO_BOOKS_FOUND = "No books found."
|
| 86 |
+
MSG_NO_VISITORS_FOUND = "No visitors found."
|
| 87 |
+
MSG_NO_WORKERS_FOUND = "No workers found."
|
| 88 |
+
MSG_ERROR_PREFIX = "Error: "
|
| 89 |
+
MSG_INVALID_OPTION = "Invalid option."
|
| 90 |
+
MSG_GOODBYE = "Goodbye."
|
| 91 |
+
MSG_NO_FIELDS_TO_UPDATE = "No fields to update."
|
| 92 |
+
MSG_DELETED = "Deleted."
|
| 93 |
+
MSG_FILE_SAVED_TO = "File saved to: "
|
| 94 |
+
MSG_CURRENT_BOOKS = "\nCurrent books:"
|
| 95 |
+
MSG_HISTORY = "\nHistory:"
|
| 96 |
+
MSG_VISITOR_DELETED = "Visitor deleted."
|
| 97 |
+
MSG_WORKER_DELETED = "Worker deleted."
|
| 98 |
+
MSG_INTERRUPTED_GOODBYE = "\nInterrupted. Goodbye."
|
| 99 |
+
|
| 100 |
+
# Prompts and labels
|
| 101 |
+
MSG_USE_COMMA_OR_STAR = "Use comma to separate values or type * for all."
|
| 102 |
+
MSG_ENTER_NUMBERS_OR_NAMES = "Enter numbers or names: "
|
| 103 |
+
MSG_USE_COMMA_STAR_OR_EMPTY = "Use comma to separate values, * for all, or leave empty to skip."
|
| 104 |
+
MSG_ENTER_NUMBERS_OR_NAMES_EMPTY_SKIP = "Enter numbers or names (empty to skip): "
|
| 105 |
+
MSG_PICK_NUMBER = "Pick number: "
|
| 106 |
+
MSG_PICK_NUMBER_SKIP = "Pick number (0 to skip): "
|
| 107 |
+
MSG_BOOK_ID_PROMPT = "Book ID: "
|
| 108 |
+
MSG_BOOK_IDS_COMMA_SEPARATED = "Book IDs (comma separated): "
|
| 109 |
+
MSG_VISITOR_ID_PROMPT = "Visitor ID: "
|
| 110 |
+
MSG_WORKER_ID_PROMPT = "Worker ID: "
|
| 111 |
+
MSG_TITLE_PROMPT = "Title: "
|
| 112 |
+
MSG_AUTHOR_PROMPT = "Author: "
|
| 113 |
+
MSG_PAGES_PROMPT = "Pages: "
|
| 114 |
+
MSG_YEAR_PROMPT = "Year: "
|
| 115 |
+
MSG_GENRE_PROMPT = "Genre:"
|
| 116 |
+
MSG_STATUS_PROMPT = "Status:"
|
| 117 |
+
MSG_NAME_PROMPT = "Name: "
|
| 118 |
+
MSG_SURNAME_PROMPT = "Surname: "
|
| 119 |
+
MSG_EXPERIENCE_PROMPT = "Experience (years): "
|
| 120 |
+
MSG_WORK_DAYS_PROMPT = "Work days:"
|
| 121 |
+
MSG_SELECT_WORK_DAYS = "Select work days:"
|
| 122 |
+
MSG_TITLE_SKIP_PROMPT = "Title (empty to skip): "
|
| 123 |
+
MSG_AUTHOR_SKIP_PROMPT = "Author (empty to skip): "
|
| 124 |
+
MSG_PAGES_SKIP_PROMPT = "Pages (empty to skip): "
|
| 125 |
+
MSG_YEAR_SKIP_PROMPT = "Year (empty to skip): "
|
| 126 |
+
MSG_NAME_SKIP_PROMPT = "Name (empty to skip): "
|
| 127 |
+
MSG_SURNAME_SKIP_PROMPT = "Surname (empty to skip): "
|
| 128 |
+
MSG_EXPERIENCE_SKIP_PROMPT = "Experience (empty to skip): "
|
| 129 |
+
MSG_LEAVE_EMPTY_TO_SKIP = "Leave fields empty to skip them."
|
| 130 |
+
MSG_BORROW_DATE_PROMPT = "Borrow date (YYYY-MM-DD)"
|
| 131 |
+
MSG_RETURN_DATE_PROMPT = "Return date (YYYY-MM-DD)"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class ValidationError(Exception):
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def ensure_downloads_dir():
|
| 139 |
+
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def save_to_file(table: str, entity_id: str, content: str) -> str:
|
| 143 |
+
ensure_downloads_dir()
|
| 144 |
+
safe_id = entity_id.replace("/", "_").replace("\\", "_")
|
| 145 |
+
filename = f"{table}-{safe_id}.json"
|
| 146 |
+
filepath = DOWNLOADS_DIR / filename
|
| 147 |
+
if isinstance(content, (dict, list)):
|
| 148 |
+
text = json.dumps(content, ensure_ascii=False, indent=2)
|
| 149 |
+
else:
|
| 150 |
+
try:
|
| 151 |
+
parsed = json.loads(content)
|
| 152 |
+
text = json.dumps(parsed, ensure_ascii=False, indent=2)
|
| 153 |
+
except Exception:
|
| 154 |
+
text = content
|
| 155 |
+
filepath.write_text(text, encoding="utf-8")
|
| 156 |
+
return str(filepath)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def validate_name(value: str, field_name: str = "Name") -> str:
|
| 160 |
+
value = value.strip()
|
| 161 |
+
if not value:
|
| 162 |
+
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}")
|
| 163 |
+
if len(value) < 2:
|
| 164 |
+
raise ValidationError(f"{field_name} {MSG_AT_LEAST_2_CHARS}")
|
| 165 |
+
if len(value) > 50:
|
| 166 |
+
raise ValidationError(f"{field_name} {MSG_AT_MOST_50_CHARS}")
|
| 167 |
+
if not NAME_PATTERN.match(value):
|
| 168 |
+
raise ValidationError(f"{field_name} {MSG_ONLY_LETTERS_SPACES_HYPHENS_APOSTROPHES}")
|
| 169 |
+
return value
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def validate_title(value: str, field_name: str = "Title") -> str:
|
| 173 |
+
value = value.strip()
|
| 174 |
+
if not value:
|
| 175 |
+
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}")
|
| 176 |
+
if len(value) < 2:
|
| 177 |
+
raise ValidationError(f"{field_name} {MSG_AT_LEAST_2_CHARS}")
|
| 178 |
+
if len(value) > 100:
|
| 179 |
+
raise ValidationError(f"{field_name} {MSG_AT_MOST_100_CHARS}")
|
| 180 |
+
if not TITLE_PATTERN.match(value):
|
| 181 |
+
raise ValidationError(f"{field_name} {MSG_CONTAINS_INVALID_CHARS}")
|
| 182 |
+
return value
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def validate_author(value: str) -> str:
|
| 186 |
+
value = value.strip()
|
| 187 |
+
if not value:
|
| 188 |
+
raise ValidationError(MSG_AUTHOR_REQUIRED)
|
| 189 |
+
if len(value) < 2:
|
| 190 |
+
raise ValidationError(MSG_AUTHOR_AT_LEAST_2_CHARS)
|
| 191 |
+
if len(value) > 100:
|
| 192 |
+
raise ValidationError(MSG_AUTHOR_AT_MOST_100_CHARS)
|
| 193 |
+
if not NAME_PATTERN.match(value):
|
| 194 |
+
raise ValidationError(MSG_AUTHOR_ONLY_LETTERS)
|
| 195 |
+
return value
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def validate_pages(value: int) -> int:
|
| 199 |
+
if value < 1:
|
| 200 |
+
raise ValidationError(MSG_PAGES_AT_LEAST_1)
|
| 201 |
+
if value > 10000:
|
| 202 |
+
raise ValidationError(MSG_PAGES_AT_MOST_10000)
|
| 203 |
+
return value
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def validate_year(value: int) -> int:
|
| 207 |
+
current_year = date.today().year
|
| 208 |
+
if value < 1000:
|
| 209 |
+
raise ValidationError(MSG_YEAR_AT_LEAST_1000)
|
| 210 |
+
if value > current_year:
|
| 211 |
+
raise ValidationError(MSG_YEAR_CANNOT_BE_GREATER)
|
| 212 |
+
return value
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def validate_genre(value: str) -> str:
|
| 216 |
+
if value not in GENRES:
|
| 217 |
+
raise ValidationError(MSG_GENRE_MUST_BE_ONE_OF)
|
| 218 |
+
return value
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def validate_status(value: str) -> str:
|
| 222 |
+
if value not in BOOK_STATUSES:
|
| 223 |
+
raise ValidationError(MSG_STATUS_MUST_BE_ONE_OF)
|
| 224 |
+
return value
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def validate_experience(value: int) -> int:
|
| 228 |
+
if value < 0:
|
| 229 |
+
raise ValidationError(MSG_EXPERIENCE_NEGATIVE)
|
| 230 |
+
if value > 60:
|
| 231 |
+
raise ValidationError(MSG_EXPERIENCE_AT_MOST_60)
|
| 232 |
+
return value
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def validate_work_days(days: List[str]) -> List[str]:
|
| 236 |
+
if not days:
|
| 237 |
+
raise ValidationError(MSG_AT_LEAST_ONE_WORK_DAY)
|
| 238 |
+
invalid = [d for d in days if d not in WORK_DAYS]
|
| 239 |
+
if invalid:
|
| 240 |
+
raise ValidationError(MSG_INVALID_WORK_DAYS)
|
| 241 |
+
return days
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def validate_uuid(value: str, field_name: str = "ID") -> str:
|
| 245 |
+
value = value.strip()
|
| 246 |
+
if not value:
|
| 247 |
+
raise ValidationError(f"{field_name} {MSG_IS_REQUIRED}")
|
| 248 |
+
if not UUID_PATTERN.match(value):
|
| 249 |
+
raise ValidationError(f"{field_name} {MSG_MUST_BE_VALID_UUID}")
|
| 250 |
+
return value
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def validate_date(value: str) -> str:
|
| 254 |
+
value = value.strip()
|
| 255 |
+
if not value:
|
| 256 |
+
return date.today().isoformat()
|
| 257 |
+
try:
|
| 258 |
+
datetime.strptime(value, "%Y-%m-%d")
|
| 259 |
+
return value
|
| 260 |
+
except ValueError:
|
| 261 |
+
raise ValidationError(MSG_DATE_YYYY_MM_DD)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def validate_ids_list(ids: List[str]) -> List[str]:
|
| 265 |
+
if not ids:
|
| 266 |
+
raise ValidationError(MSG_AT_LEAST_ONE_ITEM_REQUIRED)
|
| 267 |
+
validated = []
|
| 268 |
+
for i, id_val in enumerate(ids, 1):
|
| 269 |
+
id_val = id_val.strip()
|
| 270 |
+
if not id_val:
|
| 271 |
+
raise ValidationError(MSG_ITEM_EMPTY)
|
| 272 |
+
if not UUID_PATTERN.match(id_val):
|
| 273 |
+
raise ValidationError(MSG_ITEM_INVALID_UUID)
|
| 274 |
+
validated.append(id_val)
|
| 275 |
+
return validated
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class LibraryClient:
|
| 279 |
+
def __init__(self, base_url: str = BASE_URL):
|
| 280 |
+
self.client = httpx.Client(base_url=base_url, timeout=30.0)
|
| 281 |
+
|
| 282 |
+
def close(self):
|
| 283 |
+
self.client.close()
|
| 284 |
+
|
| 285 |
+
def _handle_response(self, response: httpx.Response):
|
| 286 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 287 |
+
if response.status_code >= 400:
|
| 288 |
+
message = response.text
|
| 289 |
+
try:
|
| 290 |
+
payload = response.json()
|
| 291 |
+
if isinstance(payload, dict):
|
| 292 |
+
message = (
|
| 293 |
+
payload.get("error", {}).get("message")
|
| 294 |
+
or payload.get("message")
|
| 295 |
+
or str(payload)
|
| 296 |
+
)
|
| 297 |
+
except Exception:
|
| 298 |
+
message = response.text
|
| 299 |
+
raise RuntimeError(f"{response.status_code}: {message}")
|
| 300 |
+
if "application/json" in content_type:
|
| 301 |
+
payload = response.json()
|
| 302 |
+
if isinstance(payload, dict) and "successful" in payload:
|
| 303 |
+
if payload.get("successful"):
|
| 304 |
+
return payload.get("data")
|
| 305 |
+
error = payload.get("error") or {}
|
| 306 |
+
raise RuntimeError(error.get("message") or str(payload))
|
| 307 |
+
return payload
|
| 308 |
+
return response
|
| 309 |
+
|
| 310 |
+
def get(self, path: str, **kwargs):
|
| 311 |
+
response = self.client.get(path, **kwargs)
|
| 312 |
+
return self._handle_response(response)
|
| 313 |
+
|
| 314 |
+
def post(self, path: str, **kwargs):
|
| 315 |
+
response = self.client.post(path, **kwargs)
|
| 316 |
+
return self._handle_response(response)
|
| 317 |
+
|
| 318 |
+
def patch(self, path: str, **kwargs):
|
| 319 |
+
response = self.client.patch(path, **kwargs)
|
| 320 |
+
return self._handle_response(response)
|
| 321 |
+
|
| 322 |
+
def delete(self, path: str, **kwargs):
|
| 323 |
+
response = self.client.delete(path, **kwargs)
|
| 324 |
+
return self._handle_response(response)
|
| 325 |
+
|
| 326 |
+
def list_books(self):
|
| 327 |
+
return self.get("/books/all")
|
| 328 |
+
|
| 329 |
+
def get_book(self, book_id: str):
|
| 330 |
+
return self.get(f"/books/{book_id}")
|
| 331 |
+
|
| 332 |
+
def book_exists(self, book_id: str) -> bool:
|
| 333 |
+
try:
|
| 334 |
+
self.get_book(book_id)
|
| 335 |
+
return True
|
| 336 |
+
except RuntimeError:
|
| 337 |
+
return False
|
| 338 |
+
|
| 339 |
+
def create_book(
|
| 340 |
+
self,
|
| 341 |
+
title: str,
|
| 342 |
+
author: str,
|
| 343 |
+
pages: int,
|
| 344 |
+
year: int,
|
| 345 |
+
genre: str,
|
| 346 |
+
status: str = "Available",
|
| 347 |
+
):
|
| 348 |
+
payload = {
|
| 349 |
+
"title": title,
|
| 350 |
+
"author": author,
|
| 351 |
+
"pages": pages,
|
| 352 |
+
"year": year,
|
| 353 |
+
"genre": genre,
|
| 354 |
+
"status": status,
|
| 355 |
+
}
|
| 356 |
+
return self.post("/books/create", json=payload)
|
| 357 |
+
|
| 358 |
+
def update_book(
|
| 359 |
+
self,
|
| 360 |
+
book_id: str,
|
| 361 |
+
title: Optional[str] = None,
|
| 362 |
+
author: Optional[str] = None,
|
| 363 |
+
pages: Optional[int] = None,
|
| 364 |
+
year: Optional[int] = None,
|
| 365 |
+
genre: Optional[str] = None,
|
| 366 |
+
status: Optional[str] = None,
|
| 367 |
+
):
|
| 368 |
+
payload = {
|
| 369 |
+
k: v
|
| 370 |
+
for k, v in {
|
| 371 |
+
"title": title,
|
| 372 |
+
"author": author,
|
| 373 |
+
"pages": pages,
|
| 374 |
+
"year": year,
|
| 375 |
+
"genre": genre,
|
| 376 |
+
"status": status,
|
| 377 |
+
}.items()
|
| 378 |
+
if v not in (None, "")
|
| 379 |
+
}
|
| 380 |
+
return self.patch(f"/books/{book_id}", json=payload)
|
| 381 |
+
|
| 382 |
+
def delete_book(self, book_id: str):
|
| 383 |
+
return self.delete(f"/books/{book_id}")
|
| 384 |
+
|
| 385 |
+
def borrow_books(
|
| 386 |
+
self, book_ids: List[str], visitor_id: str, worker_id: str, borrow_date: str
|
| 387 |
+
):
|
| 388 |
+
payload = {
|
| 389 |
+
"bookIds": book_ids,
|
| 390 |
+
"visitorId": visitor_id,
|
| 391 |
+
"workerId": worker_id,
|
| 392 |
+
"borrowDate": borrow_date,
|
| 393 |
+
}
|
| 394 |
+
return self.post("/books/borrow", json=payload)
|
| 395 |
+
|
| 396 |
+
def return_books(
|
| 397 |
+
self, book_ids: List[str], visitor_id: str, worker_id: str, return_date: str
|
| 398 |
+
):
|
| 399 |
+
payload = {
|
| 400 |
+
"bookIds": book_ids,
|
| 401 |
+
"visitorId": visitor_id,
|
| 402 |
+
"workerId": worker_id,
|
| 403 |
+
"returnDate": return_date,
|
| 404 |
+
}
|
| 405 |
+
return self.post("/books/return", json=payload)
|
| 406 |
+
|
| 407 |
+
def download_book(self, book_id: str) -> str:
|
| 408 |
+
response = self.get(f"/books/{book_id}/download")
|
| 409 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 410 |
+
|
| 411 |
+
def list_visitors(self):
|
| 412 |
+
return self.get("/visitors/all")
|
| 413 |
+
|
| 414 |
+
def get_visitor(self, visitor_id: str):
|
| 415 |
+
return self.get(f"/visitors/{visitor_id}")
|
| 416 |
+
|
| 417 |
+
def visitor_exists(self, visitor_id: str) -> bool:
|
| 418 |
+
try:
|
| 419 |
+
self.get_visitor(visitor_id)
|
| 420 |
+
return True
|
| 421 |
+
except RuntimeError:
|
| 422 |
+
return False
|
| 423 |
+
|
| 424 |
+
def create_visitor(self, name: str, surname: str):
|
| 425 |
+
payload = {"name": name, "surname": surname}
|
| 426 |
+
return self.post("/visitors/create", json=payload)
|
| 427 |
+
|
| 428 |
+
def update_visitor(
|
| 429 |
+
self,
|
| 430 |
+
visitor_id: str,
|
| 431 |
+
name: Optional[str] = None,
|
| 432 |
+
surname: Optional[str] = None,
|
| 433 |
+
):
|
| 434 |
+
payload = {k: v for k, v in {"name": name, "surname": surname}.items() if v}
|
| 435 |
+
return self.patch(f"/visitors/{visitor_id}", json=payload)
|
| 436 |
+
|
| 437 |
+
def delete_visitor(self, visitor_id: str):
|
| 438 |
+
return self.delete(f"/visitors/delete/{visitor_id}")
|
| 439 |
+
|
| 440 |
+
def download_visitor(self, visitor_id: str) -> str:
|
| 441 |
+
response = self.get(f"/visitors/{visitor_id}/download")
|
| 442 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 443 |
+
|
| 444 |
+
def list_workers(self):
|
| 445 |
+
return self.get("/workers/all")
|
| 446 |
+
|
| 447 |
+
def get_worker(self, worker_id: str):
|
| 448 |
+
return self.get(f"/workers/{worker_id}")
|
| 449 |
+
|
| 450 |
+
def worker_exists(self, worker_id: str) -> bool:
|
| 451 |
+
try:
|
| 452 |
+
self.get_worker(worker_id)
|
| 453 |
+
return True
|
| 454 |
+
except RuntimeError:
|
| 455 |
+
return False
|
| 456 |
+
|
| 457 |
+
def create_worker(
|
| 458 |
+
self, name: str, surname: str, experience: int, work_days: List[str]
|
| 459 |
+
):
|
| 460 |
+
payload = {
|
| 461 |
+
"name": name,
|
| 462 |
+
"surname": surname,
|
| 463 |
+
"experience": experience,
|
| 464 |
+
"workDays": work_days,
|
| 465 |
+
}
|
| 466 |
+
return self.post("/workers/create", json=payload)
|
| 467 |
+
|
| 468 |
+
def update_worker(
|
| 469 |
+
self,
|
| 470 |
+
worker_id: str,
|
| 471 |
+
name: Optional[str] = None,
|
| 472 |
+
surname: Optional[str] = None,
|
| 473 |
+
experience: Optional[int] = None,
|
| 474 |
+
work_days: Optional[List[str]] = None,
|
| 475 |
+
):
|
| 476 |
+
payload = {
|
| 477 |
+
k: v
|
| 478 |
+
for k, v in {
|
| 479 |
+
"name": name,
|
| 480 |
+
"surname": surname,
|
| 481 |
+
"experience": experience,
|
| 482 |
+
"workDays": work_days,
|
| 483 |
+
}.items()
|
| 484 |
+
if v not in (None, "")
|
| 485 |
+
}
|
| 486 |
+
return self.patch(f"/workers/{worker_id}", json=payload)
|
| 487 |
+
|
| 488 |
+
def delete_worker(self, worker_id: str):
|
| 489 |
+
return self.delete(f"/workers/{worker_id}")
|
| 490 |
+
|
| 491 |
+
def workers_by_days(self, work_days: List[str]):
|
| 492 |
+
params = [("workDays", day) for day in work_days]
|
| 493 |
+
return self.get("/workers/by-work-days", params=params)
|
| 494 |
+
|
| 495 |
+
def download_worker(self, worker_id: str) -> str:
|
| 496 |
+
response = self.get(f"/workers/{worker_id}/download")
|
| 497 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
class MenuChoice(IntEnum):
|
| 501 |
+
LIST_BOOKS = 1
|
| 502 |
+
VIEW_BOOK = 2
|
| 503 |
+
CREATE_BOOK = 3
|
| 504 |
+
UPDATE_BOOK = 4
|
| 505 |
+
DELETE_BOOK = 5
|
| 506 |
+
BORROW_BOOKS = 6
|
| 507 |
+
RETURN_BOOKS = 7
|
| 508 |
+
DOWNLOAD_BOOK = 8
|
| 509 |
+
LIST_VISITORS = 9
|
| 510 |
+
VIEW_VISITOR = 10
|
| 511 |
+
CREATE_VISITOR = 11
|
| 512 |
+
UPDATE_VISITOR = 12
|
| 513 |
+
DELETE_VISITOR = 13
|
| 514 |
+
DOWNLOAD_VISITOR = 14
|
| 515 |
+
LIST_WORKERS = 15
|
| 516 |
+
VIEW_WORKER = 16
|
| 517 |
+
CREATE_WORKER = 17
|
| 518 |
+
UPDATE_WORKER = 18
|
| 519 |
+
DELETE_WORKER = 19
|
| 520 |
+
WORKERS_BY_DAYS = 20
|
| 521 |
+
DOWNLOAD_WORKER = 21
|
| 522 |
+
EXIT = 0
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
MENU_TEXT = {
|
| 526 |
+
MenuChoice.LIST_BOOKS: "List books",
|
| 527 |
+
MenuChoice.VIEW_BOOK: "Get book by ID",
|
| 528 |
+
MenuChoice.CREATE_BOOK: "Create book",
|
| 529 |
+
MenuChoice.UPDATE_BOOK: "Update book",
|
| 530 |
+
MenuChoice.DELETE_BOOK: "Delete book",
|
| 531 |
+
MenuChoice.BORROW_BOOKS: "Borrow books",
|
| 532 |
+
MenuChoice.RETURN_BOOKS: "Return books",
|
| 533 |
+
MenuChoice.DOWNLOAD_BOOK: "Download book as file",
|
| 534 |
+
MenuChoice.LIST_VISITORS: "List visitors",
|
| 535 |
+
MenuChoice.VIEW_VISITOR: "Get visitor by ID",
|
| 536 |
+
MenuChoice.CREATE_VISITOR: "Create visitor",
|
| 537 |
+
MenuChoice.UPDATE_VISITOR: "Update visitor",
|
| 538 |
+
MenuChoice.DELETE_VISITOR: "Delete visitor",
|
| 539 |
+
MenuChoice.DOWNLOAD_VISITOR: "Download visitor as file",
|
| 540 |
+
MenuChoice.LIST_WORKERS: "List workers",
|
| 541 |
+
MenuChoice.VIEW_WORKER: "Get worker by ID",
|
| 542 |
+
MenuChoice.CREATE_WORKER: "Create worker",
|
| 543 |
+
MenuChoice.UPDATE_WORKER: "Update worker",
|
| 544 |
+
MenuChoice.DELETE_WORKER: "Delete worker",
|
| 545 |
+
MenuChoice.WORKERS_BY_DAYS: "Find workers by work days",
|
| 546 |
+
MenuChoice.DOWNLOAD_WORKER: "Download worker as file",
|
| 547 |
+
MenuChoice.EXIT: "Exit",
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def prompt_with_validation(label: str, validator: Callable[[str], str]) -> str:
|
| 552 |
+
while True:
|
| 553 |
+
raw = input(label).strip()
|
| 554 |
+
try:
|
| 555 |
+
return validator(raw)
|
| 556 |
+
except ValidationError as e:
|
| 557 |
+
print(f" Error: {e}")
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def prompt_int_with_validation(label: str, validator: Callable[[int], int]) -> int:
|
| 561 |
+
while True:
|
| 562 |
+
raw = input(label).strip()
|
| 563 |
+
try:
|
| 564 |
+
value = int(raw)
|
| 565 |
+
return validator(value)
|
| 566 |
+
except ValueError:
|
| 567 |
+
print(MSG_ERROR_ENTER_VALID_INTEGER)
|
| 568 |
+
except ValidationError as e:
|
| 569 |
+
print(f" Error: {e}")
|
| 570 |
+
|
| 571 |
+
|
| 572 |
+
def prompt_optional_with_validation(label: str, validator: Callable[[str], str]) -> Optional[str]:
|
| 573 |
+
while True:
|
| 574 |
+
raw = input(label).strip()
|
| 575 |
+
if not raw:
|
| 576 |
+
return None
|
| 577 |
+
try:
|
| 578 |
+
return validator(raw)
|
| 579 |
+
except ValidationError as e:
|
| 580 |
+
print(f" Error: {e}")
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
def prompt_optional_int_with_validation(label: str, validator: Callable[[int], int]) -> Optional[int]:
|
| 584 |
+
while True:
|
| 585 |
+
raw = input(label).strip()
|
| 586 |
+
if not raw:
|
| 587 |
+
return None
|
| 588 |
+
try:
|
| 589 |
+
value = int(raw)
|
| 590 |
+
return validator(value)
|
| 591 |
+
except ValueError:
|
| 592 |
+
print(MSG_ERROR_ENTER_VALID_INTEGER)
|
| 593 |
+
except ValidationError as e:
|
| 594 |
+
print(f" Error: {e}")
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def prompt_choice(label: str, options: List[str]) -> str:
|
| 598 |
+
while True:
|
| 599 |
+
print(label)
|
| 600 |
+
for idx, option in enumerate(options, 1):
|
| 601 |
+
print(f" {idx}. {option}")
|
| 602 |
+
raw = input(MSG_PICK_NUMBER).strip()
|
| 603 |
+
try:
|
| 604 |
+
selected = int(raw)
|
| 605 |
+
if 1 <= selected <= len(options):
|
| 606 |
+
return options[selected - 1]
|
| 607 |
+
except ValueError:
|
| 608 |
+
pass
|
| 609 |
+
print(MSG_ERROR_INVALID_CHOICE)
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def prompt_optional_choice(label: str, options: List[str]) -> Optional[str]:
|
| 613 |
+
while True:
|
| 614 |
+
print(label)
|
| 615 |
+
print(" 0. Skip")
|
| 616 |
+
for idx, option in enumerate(options, 1):
|
| 617 |
+
print(f" {idx}. {option}")
|
| 618 |
+
raw = input(MSG_PICK_NUMBER_SKIP).strip()
|
| 619 |
+
try:
|
| 620 |
+
selected = int(raw)
|
| 621 |
+
if selected == 0:
|
| 622 |
+
return None
|
| 623 |
+
if 1 <= selected <= len(options):
|
| 624 |
+
return options[selected - 1]
|
| 625 |
+
except ValueError:
|
| 626 |
+
pass
|
| 627 |
+
print(MSG_ERROR_INVALID_CHOICE)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def prompt_multi_choice(label: str, options: List[str]) -> List[str]:
|
| 631 |
+
print(label)
|
| 632 |
+
print(MSG_USE_COMMA_OR_STAR)
|
| 633 |
+
for idx, option in enumerate(options, 1):
|
| 634 |
+
print(f" {idx}. {option}")
|
| 635 |
+
while True:
|
| 636 |
+
raw = input(MSG_ENTER_NUMBERS_OR_NAMES).strip()
|
| 637 |
+
if raw == "*":
|
| 638 |
+
return options[:]
|
| 639 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 640 |
+
result = []
|
| 641 |
+
valid = True
|
| 642 |
+
for p in parts:
|
| 643 |
+
if p in options:
|
| 644 |
+
result.append(p)
|
| 645 |
+
elif p.isdigit():
|
| 646 |
+
idx = int(p)
|
| 647 |
+
if 1 <= idx <= len(options):
|
| 648 |
+
result.append(options[idx - 1])
|
| 649 |
+
else:
|
| 650 |
+
print(MSG_ERROR_INVALID_NUMBER)
|
| 651 |
+
valid = False
|
| 652 |
+
break
|
| 653 |
+
else:
|
| 654 |
+
print(MSG_ERROR_INVALID_VALUE)
|
| 655 |
+
valid = False
|
| 656 |
+
break
|
| 657 |
+
if valid and result:
|
| 658 |
+
try:
|
| 659 |
+
return validate_work_days(result)
|
| 660 |
+
except ValidationError as e:
|
| 661 |
+
print(f" Error: {e}")
|
| 662 |
+
elif valid and not result:
|
| 663 |
+
print(MSG_ERROR_AT_LEAST_ONE_VALUE)
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def prompt_optional_multi_choice(label: str, options: List[str]) -> Optional[List[str]]:
|
| 667 |
+
print(label)
|
| 668 |
+
print(MSG_USE_COMMA_STAR_OR_EMPTY)
|
| 669 |
+
for idx, option in enumerate(options, 1):
|
| 670 |
+
print(f" {idx}. {option}")
|
| 671 |
+
while True:
|
| 672 |
+
raw = input(MSG_ENTER_NUMBERS_OR_NAMES_EMPTY_SKIP).strip()
|
| 673 |
+
if not raw:
|
| 674 |
+
return None
|
| 675 |
+
if raw == "*":
|
| 676 |
+
return options[:]
|
| 677 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 678 |
+
result = []
|
| 679 |
+
valid = True
|
| 680 |
+
for p in parts:
|
| 681 |
+
if p in options:
|
| 682 |
+
result.append(p)
|
| 683 |
+
elif p.isdigit():
|
| 684 |
+
idx = int(p)
|
| 685 |
+
if 1 <= idx <= len(options):
|
| 686 |
+
result.append(options[idx - 1])
|
| 687 |
+
else:
|
| 688 |
+
print(MSG_ERROR_INVALID_NUMBER)
|
| 689 |
+
valid = False
|
| 690 |
+
break
|
| 691 |
+
else:
|
| 692 |
+
print(MSG_ERROR_INVALID_VALUE)
|
| 693 |
+
valid = False
|
| 694 |
+
break
|
| 695 |
+
if valid and result:
|
| 696 |
+
try:
|
| 697 |
+
return validate_work_days(result)
|
| 698 |
+
except ValidationError as e:
|
| 699 |
+
print(f" Error: {e}")
|
| 700 |
+
elif valid and not result:
|
| 701 |
+
return None
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
def prompt_book_ids(label: str, client: LibraryClient) -> List[str]:
|
| 705 |
+
while True:
|
| 706 |
+
raw = input(label).strip()
|
| 707 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 708 |
+
try:
|
| 709 |
+
validated = validate_ids_list(parts)
|
| 710 |
+
for book_id in validated:
|
| 711 |
+
if not client.book_exists(book_id):
|
| 712 |
+
print(MSG_ERROR_BOOK_NOT_FOUND)
|
| 713 |
+
raise ValidationError("Book not found")
|
| 714 |
+
return validated
|
| 715 |
+
except ValidationError as e:
|
| 716 |
+
if "not found" not in str(e):
|
| 717 |
+
print(f" Error: {e}")
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
def prompt_book_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 721 |
+
while True:
|
| 722 |
+
raw = input(label).strip()
|
| 723 |
+
try:
|
| 724 |
+
validated = validate_uuid(raw, "Book ID")
|
| 725 |
+
if check_exists and not client.book_exists(validated):
|
| 726 |
+
print(MSG_ERROR_BOOK_NOT_FOUND_VALIDATED)
|
| 727 |
+
continue
|
| 728 |
+
return validated
|
| 729 |
+
except ValidationError as e:
|
| 730 |
+
print(f" Error: {e}")
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def prompt_visitor_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 734 |
+
while True:
|
| 735 |
+
raw = input(label).strip()
|
| 736 |
+
try:
|
| 737 |
+
validated = validate_uuid(raw, "Visitor ID")
|
| 738 |
+
if check_exists and not client.visitor_exists(validated):
|
| 739 |
+
print(MSG_ERROR_VISITOR_NOT_FOUND)
|
| 740 |
+
continue
|
| 741 |
+
return validated
|
| 742 |
+
except ValidationError as e:
|
| 743 |
+
print(f" Error: {e}")
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def prompt_worker_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 747 |
+
while True:
|
| 748 |
+
raw = input(label).strip()
|
| 749 |
+
try:
|
| 750 |
+
validated = validate_uuid(raw, "Worker ID")
|
| 751 |
+
if check_exists and not client.worker_exists(validated):
|
| 752 |
+
print(MSG_ERROR_WORKER_NOT_FOUND)
|
| 753 |
+
continue
|
| 754 |
+
return validated
|
| 755 |
+
except ValidationError as e:
|
| 756 |
+
print(f" Error: {e}")
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
def prompt_date(label: str) -> str:
|
| 760 |
+
today = date.today().isoformat()
|
| 761 |
+
while True:
|
| 762 |
+
raw = input(f"{label} [{today}]: ").strip()
|
| 763 |
+
try:
|
| 764 |
+
return validate_date(raw)
|
| 765 |
+
except ValidationError as e:
|
| 766 |
+
print(f" Error: {e}")
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def print_books(books: Any):
|
| 770 |
+
if not books:
|
| 771 |
+
print(MSG_NO_BOOKS_FOUND)
|
| 772 |
+
return
|
| 773 |
+
for book in books:
|
| 774 |
+
print(SEPARATOR)
|
| 775 |
+
print(f"ID: {book.get('id')}")
|
| 776 |
+
print(f"Title: {book.get('title')}")
|
| 777 |
+
print(f"Author: {book.get('author')}")
|
| 778 |
+
print(f"Pages: {book.get('pages')}")
|
| 779 |
+
print(f"Year: {book.get('year')}")
|
| 780 |
+
print(f"Genre: {book.get('genre')}")
|
| 781 |
+
print(f"Status: {book.get('status')}")
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def print_visitors(visitors: Any):
|
| 785 |
+
if not visitors:
|
| 786 |
+
print(MSG_NO_VISITORS_FOUND)
|
| 787 |
+
return
|
| 788 |
+
for visitor in visitors:
|
| 789 |
+
print(SEPARATOR)
|
| 790 |
+
print(f"ID: {visitor.get('id')}")
|
| 791 |
+
print(f"Name: {visitor.get('name')} {visitor.get('surname')}")
|
| 792 |
+
print(f"Registered: {visitor.get('registrationDate')}")
|
| 793 |
+
current = visitor.get("currentBooks") or []
|
| 794 |
+
history = visitor.get("history") or []
|
| 795 |
+
print(f"Current books: {len(current)}")
|
| 796 |
+
print(f"History: {len(history)}")
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def print_workers(workers: Any):
|
| 800 |
+
if not workers:
|
| 801 |
+
print(MSG_NO_WORKERS_FOUND)
|
| 802 |
+
return
|
| 803 |
+
for worker in workers:
|
| 804 |
+
print(SEPARATOR)
|
| 805 |
+
print(f"ID: {worker.get('id')}")
|
| 806 |
+
print(f"Name: {worker.get('name')} {worker.get('surname')}")
|
| 807 |
+
print(f"Experience: {worker.get('experience')} years")
|
| 808 |
+
print(f"Work days: {', '.join(worker.get('workDays', []))}")
|
| 809 |
+
issued = worker.get("issuedBooks") or []
|
| 810 |
+
print(f"Issued books: {len(issued)}")
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
def main_menu() -> Optional[MenuChoice]:
|
| 814 |
+
print("\n" + SEPARATOR)
|
| 815 |
+
for choice in MenuChoice:
|
| 816 |
+
print(f"{choice.value}. {MENU_TEXT[choice]}")
|
| 817 |
+
raw = input(PROMPT).strip()
|
| 818 |
+
try:
|
| 819 |
+
return MenuChoice(int(raw))
|
| 820 |
+
except Exception:
|
| 821 |
+
return None
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def handle_error(error: Exception):
|
| 825 |
+
print(f"{MSG_ERROR_PREFIX}{error}")
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def main():
|
| 829 |
+
client = LibraryClient()
|
| 830 |
+
try:
|
| 831 |
+
while True:
|
| 832 |
+
choice = main_menu()
|
| 833 |
+
if choice is None:
|
| 834 |
+
print(MSG_INVALID_OPTION)
|
| 835 |
+
continue
|
| 836 |
+
if choice == MenuChoice.EXIT:
|
| 837 |
+
print(MSG_GOODBYE)
|
| 838 |
+
break
|
| 839 |
+
try:
|
| 840 |
+
if choice == MenuChoice.LIST_BOOKS:
|
| 841 |
+
print_books(client.list_books())
|
| 842 |
+
|
| 843 |
+
elif choice == MenuChoice.VIEW_BOOK:
|
| 844 |
+
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client)
|
| 845 |
+
print_books([client.get_book(book_id)])
|
| 846 |
+
|
| 847 |
+
elif choice == MenuChoice.CREATE_BOOK:
|
| 848 |
+
title = prompt_with_validation(MSG_TITLE_PROMPT, validate_title)
|
| 849 |
+
author = prompt_with_validation(MSG_AUTHOR_PROMPT, validate_author)
|
| 850 |
+
pages = prompt_int_with_validation(MSG_PAGES_PROMPT, validate_pages)
|
| 851 |
+
year = prompt_int_with_validation(MSG_YEAR_PROMPT, validate_year)
|
| 852 |
+
genre = prompt_choice(MSG_GENRE_PROMPT, GENRES)
|
| 853 |
+
status = prompt_choice(MSG_STATUS_PROMPT, BOOK_STATUSES)
|
| 854 |
+
print_books([client.create_book(title, author, pages, year, genre, status)])
|
| 855 |
+
|
| 856 |
+
elif choice == MenuChoice.UPDATE_BOOK:
|
| 857 |
+
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client)
|
| 858 |
+
print(MSG_LEAVE_EMPTY_TO_SKIP)
|
| 859 |
+
title = prompt_optional_with_validation(MSG_TITLE_SKIP_PROMPT, validate_title)
|
| 860 |
+
author = prompt_optional_with_validation(MSG_AUTHOR_SKIP_PROMPT, validate_author)
|
| 861 |
+
pages = prompt_optional_int_with_validation(MSG_PAGES_SKIP_PROMPT, validate_pages)
|
| 862 |
+
year = prompt_optional_int_with_validation(MSG_YEAR_SKIP_PROMPT, validate_year)
|
| 863 |
+
genre = prompt_optional_choice(MSG_GENRE_PROMPT, GENRES)
|
| 864 |
+
status = prompt_optional_choice(MSG_STATUS_PROMPT, BOOK_STATUSES)
|
| 865 |
+
if not any([title, author, pages, year, genre, status]):
|
| 866 |
+
print(MSG_NO_FIELDS_TO_UPDATE)
|
| 867 |
+
continue
|
| 868 |
+
updated = client.update_book(book_id, title, author, pages, year, genre, status)
|
| 869 |
+
print_books([updated])
|
| 870 |
+
|
| 871 |
+
elif choice == MenuChoice.DELETE_BOOK:
|
| 872 |
+
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client)
|
| 873 |
+
result = client.delete_book(book_id)
|
| 874 |
+
print(result.get("message") if isinstance(result, dict) else MSG_DELETED)
|
| 875 |
+
|
| 876 |
+
elif choice == MenuChoice.BORROW_BOOKS:
|
| 877 |
+
book_ids = prompt_book_ids(MSG_BOOK_IDS_COMMA_SEPARATED, client)
|
| 878 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 879 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 880 |
+
borrow_date = prompt_date(MSG_BORROW_DATE_PROMPT)
|
| 881 |
+
result = client.borrow_books(book_ids, visitor_id, worker_id, borrow_date)
|
| 882 |
+
print(result.get("message") if isinstance(result, dict) else result)
|
| 883 |
+
|
| 884 |
+
elif choice == MenuChoice.RETURN_BOOKS:
|
| 885 |
+
book_ids = prompt_book_ids(MSG_BOOK_IDS_COMMA_SEPARATED, client)
|
| 886 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 887 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 888 |
+
return_date = prompt_date(MSG_RETURN_DATE_PROMPT)
|
| 889 |
+
result = client.return_books(book_ids, visitor_id, worker_id, return_date)
|
| 890 |
+
print(result.get("message") if isinstance(result, dict) else result)
|
| 891 |
+
|
| 892 |
+
elif choice == MenuChoice.DOWNLOAD_BOOK:
|
| 893 |
+
book_id = prompt_book_id(MSG_BOOK_ID_PROMPT, client)
|
| 894 |
+
content = client.download_book(book_id)
|
| 895 |
+
filepath = save_to_file("books", book_id, content)
|
| 896 |
+
print(f"{MSG_FILE_SAVED_TO}{filepath}")
|
| 897 |
+
|
| 898 |
+
elif choice == MenuChoice.LIST_VISITORS:
|
| 899 |
+
print_visitors(client.list_visitors())
|
| 900 |
+
|
| 901 |
+
elif choice == MenuChoice.VIEW_VISITOR:
|
| 902 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 903 |
+
visitor = client.get_visitor(visitor_id)
|
| 904 |
+
print_visitors([visitor])
|
| 905 |
+
current = visitor.get("currentBooks") or []
|
| 906 |
+
history = visitor.get("history") or []
|
| 907 |
+
if current:
|
| 908 |
+
print(MSG_CURRENT_BOOKS)
|
| 909 |
+
print_books(current)
|
| 910 |
+
if history:
|
| 911 |
+
print(MSG_HISTORY)
|
| 912 |
+
print_books(history)
|
| 913 |
+
|
| 914 |
+
elif choice == MenuChoice.CREATE_VISITOR:
|
| 915 |
+
name = prompt_with_validation(MSG_NAME_PROMPT, lambda v: validate_name(v, "Name"))
|
| 916 |
+
surname = prompt_with_validation(MSG_SURNAME_PROMPT, lambda v: validate_name(v, "Surname"))
|
| 917 |
+
print_visitors([client.create_visitor(name, surname)])
|
| 918 |
+
|
| 919 |
+
elif choice == MenuChoice.UPDATE_VISITOR:
|
| 920 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 921 |
+
print(MSG_LEAVE_EMPTY_TO_SKIP)
|
| 922 |
+
name = prompt_optional_with_validation(MSG_NAME_PROMPT + " (empty to skip): ",
|
| 923 |
+
lambda v: validate_name(v, "Name"))
|
| 924 |
+
surname = prompt_optional_with_validation(MSG_SURNAME_PROMPT + " (empty to skip): ",
|
| 925 |
+
lambda v: validate_name(v, "Surname"))
|
| 926 |
+
if not name and not surname:
|
| 927 |
+
print(MSG_NO_FIELDS_TO_UPDATE)
|
| 928 |
+
continue
|
| 929 |
+
visitor = client.update_visitor(visitor_id, name, surname)
|
| 930 |
+
print_visitors([visitor])
|
| 931 |
+
|
| 932 |
+
elif choice == MenuChoice.DELETE_VISITOR:
|
| 933 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 934 |
+
client.delete_visitor(visitor_id)
|
| 935 |
+
print(MSG_VISITOR_DELETED)
|
| 936 |
+
|
| 937 |
+
elif choice == MenuChoice.DOWNLOAD_VISITOR:
|
| 938 |
+
visitor_id = prompt_visitor_id(MSG_VISITOR_ID_PROMPT, client)
|
| 939 |
+
content = client.download_visitor(visitor_id)
|
| 940 |
+
filepath = save_to_file("visitors", visitor_id, content)
|
| 941 |
+
print(f"{MSG_FILE_SAVED_TO}{filepath}")
|
| 942 |
+
|
| 943 |
+
elif choice == MenuChoice.LIST_WORKERS:
|
| 944 |
+
print_workers(client.list_workers())
|
| 945 |
+
|
| 946 |
+
elif choice == MenuChoice.VIEW_WORKER:
|
| 947 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 948 |
+
worker = client.get_worker(worker_id)
|
| 949 |
+
print_workers([worker])
|
| 950 |
+
issued = worker.get("issuedBooks") or []
|
| 951 |
+
if issued:
|
| 952 |
+
print("\nIssued books:")
|
| 953 |
+
print_books(issued)
|
| 954 |
+
|
| 955 |
+
elif choice == MenuChoice.CREATE_WORKER:
|
| 956 |
+
name = prompt_with_validation(MSG_NAME_PROMPT, lambda v: validate_name(v, "Name"))
|
| 957 |
+
surname = prompt_with_validation(MSG_SURNAME_PROMPT, lambda v: validate_name(v, "Surname"))
|
| 958 |
+
experience = prompt_int_with_validation(MSG_EXPERIENCE_PROMPT, validate_experience)
|
| 959 |
+
work_days = prompt_multi_choice(MSG_WORK_DAYS_PROMPT, WORK_DAYS)
|
| 960 |
+
print_workers([client.create_worker(name, surname, experience, work_days)])
|
| 961 |
+
|
| 962 |
+
elif choice == MenuChoice.UPDATE_WORKER:
|
| 963 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 964 |
+
print(MSG_LEAVE_EMPTY_TO_SKIP)
|
| 965 |
+
name = prompt_optional_with_validation(MSG_NAME_PROMPT + " (empty to skip): ",
|
| 966 |
+
lambda v: validate_name(v, "Name"))
|
| 967 |
+
surname = prompt_optional_with_validation(MSG_SURNAME_PROMPT + " (empty to skip): ",
|
| 968 |
+
lambda v: validate_name(v, "Surname"))
|
| 969 |
+
experience = prompt_optional_int_with_validation(MSG_EXPERIENCE_PROMPT + " (empty to skip): ",
|
| 970 |
+
validate_experience)
|
| 971 |
+
work_days = prompt_optional_multi_choice(MSG_WORK_DAYS_PROMPT, WORK_DAYS)
|
| 972 |
+
if not any([name, surname, experience is not None, work_days]):
|
| 973 |
+
print(MSG_NO_FIELDS_TO_UPDATE)
|
| 974 |
+
continue
|
| 975 |
+
worker = client.update_worker(worker_id, name, surname, experience, work_days)
|
| 976 |
+
print_workers([worker])
|
| 977 |
+
|
| 978 |
+
elif choice == MenuChoice.DELETE_WORKER:
|
| 979 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 980 |
+
client.delete_worker(worker_id)
|
| 981 |
+
print(MSG_WORKER_DELETED)
|
| 982 |
+
|
| 983 |
+
elif choice == MenuChoice.WORKERS_BY_DAYS:
|
| 984 |
+
work_days = prompt_multi_choice(MSG_SELECT_WORK_DAYS, WORK_DAYS)
|
| 985 |
+
print_workers(client.workers_by_days(work_days))
|
| 986 |
+
|
| 987 |
+
elif choice == MenuChoice.DOWNLOAD_WORKER:
|
| 988 |
+
worker_id = prompt_worker_id(MSG_WORKER_ID_PROMPT, client)
|
| 989 |
+
content = client.download_worker(worker_id)
|
| 990 |
+
filepath = save_to_file("workers", worker_id, content)
|
| 991 |
+
print(f"{MSG_FILE_SAVED_TO}{filepath}")
|
| 992 |
+
|
| 993 |
+
else:
|
| 994 |
+
print(MSG_INVALID_OPTION)
|
| 995 |
+
|
| 996 |
+
except (RuntimeError, httpx.HTTPError, ValueError) as error:
|
| 997 |
+
handle_error(error)
|
| 998 |
+
except KeyboardInterrupt:
|
| 999 |
+
print(MSG_INTERRUPTED_GOODBYE)
|
| 1000 |
+
finally:
|
| 1001 |
+
client.close()
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
if __name__ == "__main__":
|
| 1005 |
+
main()
|
data/messages.txt
CHANGED
|
@@ -1 +1,2 @@
|
|
| 1 |
-
|
|
|
|
|
|
| 1 |
+
47514c4b5247514c4a5247514c4752475150505247514b4f524951484747524b514b4c524b51484748524a5150505247514b4c5247514c495247514c4d524b5148474752484751504f5247514b505247514c4c5248514b4c524a514b5052484d514b50524b514b4f5248495148474852484f514b4f52484d5148494b5247514b4e52475148484d52475148484852475148484952475148474c524b5148484c52494b514c4a5247514847495247514c4e5247514847475247514c485249514b50524c514b4c524751504e52475148474852484d514c49524847514c49524a48515050524a4d514c4e524a4f514c48524848514b4c524a514b4f524849514c4c524a49514c49524c515050524849514b50524a4951504f52475148494b52484c5148494b52494b51484849524a4e5148484b52494c5148474c524b5148474c52494e51504e52475148484752494c5148484c52494b514b505248514c4c524951484748524b514c49524a49514b4c524a50514b50524a4d514c495249514c4d524a4e514b4c524a4e515050524951504e52484751484748524751504f524a4a514b5052484851484749524848514c4d524c514847485248505148494b524b4c514c475249514b4c52484c514c47524847514b505249514f4b52484c514b505247514c4f524e4c514c4f524848514c495247514b4d524d4e514b4f524a49514c4d5248495148494b5247514f505247514f4e5247514f49524751484949524a4e514c47524751484947524751484750524a4e514848475247514e4f52475148474e525048514f4e52475148474f52475148484a524751504752494f514d4d52475148484f524b514e48524847495148494952484748514f495248474a515047524c4f514d4d5248474d51504e525049514e4752484747515050524a5148484952504e51504f524a5148474f52484747514f505247514f4f5247514e4e52475148474a524847485150475248484b514c475248474b5148474e524b514c475247514d505248494851504752504e514f4952504b514f50525049514f505247514d48
|
| 2 |
+
48364d4c3748364d4f37483651503748364d4e3748364c50374836494848374836515137483649484a3748364c4d374b364d4c374836494849374e364c4d3748364d4a374836514f374949364d4f375136514f37494d364d483751364d4f3749493651503748364d48374a36515137494c364d4e3749364d4a374d3649484a374836494a4c3748364c4f37483649494e37483649494937483649494a37483649484d374f3649494d374a4e364d493750364d4d374c364c50374d365150374949364c4d374b364d4e374d364d4b3751364d4a374a483651513748364d4b37495036494849374949364d4a3751365150374c49364d4b37494c36494849374d364d4d37494c364d48374a48364d4a374f36494a4c3748364c51374a4d364c4f374a5136514f37483649494c374a4f3649484d374f3649484d374d4b36494948374a4f3649494d374a4e364d48374a4836494849374a36494848374a364c503748364d4d3751364d4937494b364d4f374e4b364c4d37494b364d4b3749364948493751364d4e374949364d4b374c49364c4d374c50365150374c5036494848374c49364d4c374d49364d49374a483649484a374a4d364d48374d364d48374f49364c51374a48364c4d374d49364d4c374836504c374a48364d493748364d50374d49364d4b37504c364d4b374e4b364c4e374e4b364d4e374c49364d4d37505036494a4c37483651483748364d49374836504d3748364e49
|
data/participants.txt
CHANGED
|
@@ -1,7 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
41dc9c33-fc1f-4f43-b640-4d7804ed8bfc|adf ihu|asdljfsadfh|asdf|2025-11-26T07:35:24.443778|9.4
|
| 4 |
-
0cb6d70f-fd72-477f-863f-33b3f6932f90|Vika|Dryha|vikshalka|2025-12-02T22:13:16.858855|4.5
|
| 5 |
-
b3692e4e-b9f2-4566-a123-af9cb9f2c9d0|фффф|ииииии|аааааа|2025-12-08T11:51:20.063434|0.0
|
| 6 |
-
68c33585-b8b5-4567-b2b7-ff3fce1525af|asdf|asdf|asdf|2025-12-08T11:57:53.675948|3.0
|
| 7 |
-
578dbd0e-c71f-42f6-83c1-7b506d468ff3|sadifsdaf|asdf|fasdfdsaf|2025-12-08T12:15:14.793116|3.4
|
|
|
|
| 1 |
+
47514b505247514c4b5247514c4c5247514c4a52475148474852475150505247514c495247514c4e5247514b4c524a514c495248514b4f5250514c49524b514c4d524751504e525051504e524d514c4a52484b514b4c524c51504f5247514847475248514c49524751484749524e514c4d5247514b4f524c514c4c52485148494b5247514e4e52484b5148474e52475148484c52475148474c52475148475052475148494b5247514f4a52475148474b524751484948524a4751504e52475148484752475148484852475148484e52494f5148474e5249505148494b524751504f52475148484b524c5148484c52475148484d524a4e5148474e524a48514c4752494a514c47524b514b4c5248514c475250514b50524b514f4b524848514c4f5247514c475248514c4f524e514b505247514b4d524c4a514c4e524f514c4c5249514c4c524a48514c4a524c4d514b4f
|
| 2 |
+
48364d483749364948493748364d4f374836494848374b364c503748364d4d3748364c4d3748364d493748364d4a374b364d4d374f364d4a3748364d4b3748364d4c374836494849374f364d4e37494c364d4b37494a364c4d374836514f3749365150374950364d48374c364d4b37494b364c513750364d4837483649484a374836494a4c3748364948513749503649484f37483649494d37483649484d374a4e36494a4c37483649494e37494c3649494d374b4936494a4c374a4c3649494f374c3649484c3749503649494d374a4c3649484d374a4d364d483748364c503749364d4b374f364c513749364c4d3748364c5137494b36504c3749364d493748364d50374c4b364d4b374c4e364d49374c4b364c4e3750364d4e374b364d4837494836494a4c3749364c4e3750363349
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
data/topics.txt
CHANGED
|
@@ -1,3 +1,2 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
523f5745-ebde-4556-8e38-6a7ed95baf08|Maksim|sadfsadf|2025-12-08T11:58:03.296799||a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^bHVpYXNoZmRhc2l1bGhmbHNhZA==;68c33585-b8b5-4567-b2b7-ff3fce1525af^dWFkaHNmaXVoc2FkIGl1Zmhhc2RpZmhkdXNhb2g=
|
|
|
|
| 1 |
+
47514c4a5247514847495247514c4e5247514847475247514c485248514b505247514b4f5247514b4c524751504e5247514847485247514c4c5247514c49524f514c495249515050524e514b4c524a51484748524c514c49524f514c47524e514c4d524848514c4e524849514b4f52475150505247514c4d5247514b50524a51504f52475148494b5247514e495248475148474f52475148474f5247514848485247514a49524751484850524a475148484b5249505148474752494d5148484852475148474c524b5148484c5250514a49524a4d5148484852505148484c524b5148474952475148474d524a485148474c524b4751484747524a4d51484749524b4951504e52475148484c524a475148474c524b48514a49524b495148484c525051484747524b4f5148474d524951504e524b4e5148474752494d514c47524e514c475248514b4c52494b514c47524f514b505248514f4b52494b514b4f5247514c4f5247514c47524d4a514c4f524e514c495247514b4d524e514b4f52494a514b4f524848514c4d52494d5148494b
|
| 2 |
+
48364d4937483649484a3748364d4d3748364d4e3748364c50374d3651503748364948493748364c4d3748365150374c364c503748364d4b3750364d4a3748364d483748365151374949364c4d3748364d4f374f364948493748364d4a3750365150374949364d4b374836514f374f364c50374b36514f37494b364d48374950365151374836494a4c3748364f4a374f36494850374836494850374836494949374a4e3649494d374a493649484837483649484d37483649484e374b4b3649494d374a4936494949374a4e364d48374d364d4837494d364c5137494b364c4d3748364c513748364d4c374836504c37494b364d493748364d50374c49364d4b374c4d364d4a374c49364c4e374950364d4a374b364d4e3749364c51374a4e36494a4c
|
|
|
unified_client.py
ADDED
|
@@ -0,0 +1,1205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from datetime import date, datetime
|
| 4 |
+
from enum import IntEnum
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, List, Optional, Callable
|
| 7 |
+
from uuid import UUID
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
|
| 11 |
+
FORUM_BASE_URL = "https://brestok-lab4.hf.space"
|
| 12 |
+
LIBRARY_BASE_URL = "https://brestok-vika-server.hf.space"
|
| 13 |
+
DOWNLOADS_DIR = Path(__file__).parent / "downloads"
|
| 14 |
+
|
| 15 |
+
SEPARATOR = "=" * 70
|
| 16 |
+
SEPARATOR_60 = "=" * 60
|
| 17 |
+
SEPARATOR_90 = "-" * 90
|
| 18 |
+
DASH_LINE_60 = "-" * 60
|
| 19 |
+
PROMPT = "Select option: "
|
| 20 |
+
|
| 21 |
+
TABLE_PARTICIPANTS = "participants"
|
| 22 |
+
TABLE_TOPICS = "topics"
|
| 23 |
+
|
| 24 |
+
GENRES = [
|
| 25 |
+
"Fiction", "Non-Fiction", "Mystery", "Sci-Fi", "Fantasy", "Biography",
|
| 26 |
+
"History", "Romance", "Thriller", "Horror", "Poetry", "Drama", "Comics", "Other",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
BOOK_STATUSES = ["Available", "Borrowed"]
|
| 30 |
+
|
| 31 |
+
WORK_DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
| 32 |
+
|
| 33 |
+
NAME_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ\s\-']+$")
|
| 34 |
+
TITLE_PATTERN = re.compile(r"^[A-Za-zА-Яа-яЁёІіЇїЄєҐґ0-9\s\-'.,!?:;\"()]+$")
|
| 35 |
+
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}$")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ValidationError(Exception):
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def ensure_downloads_dir():
|
| 43 |
+
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def save_to_file(table: str, entity_id: str, content: str) -> str:
|
| 47 |
+
ensure_downloads_dir()
|
| 48 |
+
safe_id = entity_id.replace("/", "_").replace("\\", "_")
|
| 49 |
+
filename = f"{table}-{safe_id}.json"
|
| 50 |
+
filepath = DOWNLOADS_DIR / filename
|
| 51 |
+
if isinstance(content, (dict, list)):
|
| 52 |
+
text = json.dumps(content, ensure_ascii=False, indent=2)
|
| 53 |
+
else:
|
| 54 |
+
try:
|
| 55 |
+
parsed = json.loads(content)
|
| 56 |
+
text = json.dumps(parsed, ensure_ascii=False, indent=2)
|
| 57 |
+
except Exception:
|
| 58 |
+
text = content
|
| 59 |
+
filepath.write_text(text, encoding="utf-8")
|
| 60 |
+
return str(filepath)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def validate_name(value: str, field_name: str = "Name") -> str:
|
| 64 |
+
value = value.strip()
|
| 65 |
+
if not value:
|
| 66 |
+
raise ValidationError(f"{field_name} is required.")
|
| 67 |
+
if len(value) < 2:
|
| 68 |
+
raise ValidationError(f"{field_name} must be at least 2 characters.")
|
| 69 |
+
if len(value) > 50:
|
| 70 |
+
raise ValidationError(f"{field_name} must be at most 50 characters.")
|
| 71 |
+
if not NAME_PATTERN.match(value):
|
| 72 |
+
raise ValidationError(f"{field_name} must contain only letters, spaces, hyphens, and apostrophes.")
|
| 73 |
+
return value
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def validate_title(value: str, field_name: str = "Title") -> str:
|
| 77 |
+
value = value.strip()
|
| 78 |
+
if not value:
|
| 79 |
+
raise ValidationError(f"{field_name} is required.")
|
| 80 |
+
if len(value) < 2:
|
| 81 |
+
raise ValidationError(f"{field_name} must be at least 2 characters.")
|
| 82 |
+
if len(value) > 100:
|
| 83 |
+
raise ValidationError(f"{field_name} must be at most 100 characters.")
|
| 84 |
+
if not TITLE_PATTERN.match(value):
|
| 85 |
+
raise ValidationError(f"{field_name} contains invalid characters.")
|
| 86 |
+
return value
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def validate_author(value: str) -> str:
|
| 90 |
+
value = value.strip()
|
| 91 |
+
if not value:
|
| 92 |
+
raise ValidationError("Author is required.")
|
| 93 |
+
if len(value) < 2:
|
| 94 |
+
raise ValidationError("Author must be at least 2 characters.")
|
| 95 |
+
if len(value) > 100:
|
| 96 |
+
raise ValidationError("Author must be at most 100 characters.")
|
| 97 |
+
if not NAME_PATTERN.match(value):
|
| 98 |
+
raise ValidationError("Author must contain only letters, spaces, hyphens, and apostrophes.")
|
| 99 |
+
return value
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def validate_pages(value: int) -> int:
|
| 103 |
+
if value < 1:
|
| 104 |
+
raise ValidationError("Pages must be at least 1.")
|
| 105 |
+
if value > 10000:
|
| 106 |
+
raise ValidationError("Pages must be at most 10000.")
|
| 107 |
+
return value
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def validate_year(value: int) -> int:
|
| 111 |
+
current_year = date.today().year
|
| 112 |
+
if value < 1000:
|
| 113 |
+
raise ValidationError("Year must be at least 1000.")
|
| 114 |
+
if value > current_year:
|
| 115 |
+
raise ValidationError(f"Year cannot be greater than {current_year}.")
|
| 116 |
+
return value
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def validate_genre(value: str) -> str:
|
| 120 |
+
if value not in GENRES:
|
| 121 |
+
raise ValidationError(f"Genre must be one of: {', '.join(GENRES)}")
|
| 122 |
+
return value
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def validate_status(value: str) -> str:
|
| 126 |
+
if value not in BOOK_STATUSES:
|
| 127 |
+
raise ValidationError(f"Status must be one of: {', '.join(BOOK_STATUSES)}")
|
| 128 |
+
return value
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def validate_experience(value: int) -> int:
|
| 132 |
+
if value < 0:
|
| 133 |
+
raise ValidationError("Experience cannot be negative.")
|
| 134 |
+
if value > 60:
|
| 135 |
+
raise ValidationError("Experience must be at most 60 years.")
|
| 136 |
+
return value
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def validate_work_days(days: List[str]) -> List[str]:
|
| 140 |
+
if not days:
|
| 141 |
+
raise ValidationError("At least one work day is required.")
|
| 142 |
+
invalid = [d for d in days if d not in WORK_DAYS]
|
| 143 |
+
if invalid:
|
| 144 |
+
raise ValidationError(f"Invalid work days: {', '.join(invalid)}. Valid options: {', '.join(WORK_DAYS)}")
|
| 145 |
+
return days
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def validate_uuid(value: str, field_name: str = "ID") -> str:
|
| 149 |
+
value = value.strip()
|
| 150 |
+
if not value:
|
| 151 |
+
raise ValidationError(f"{field_name} is required.")
|
| 152 |
+
if not UUID_PATTERN.match(value):
|
| 153 |
+
raise ValidationError(f"{field_name} must be a valid UUID format.")
|
| 154 |
+
return value
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def validate_date(value: str) -> str:
|
| 158 |
+
value = value.strip()
|
| 159 |
+
if not value:
|
| 160 |
+
return date.today().isoformat()
|
| 161 |
+
try:
|
| 162 |
+
datetime.strptime(value, "%Y-%m-%d")
|
| 163 |
+
return value
|
| 164 |
+
except ValueError:
|
| 165 |
+
raise ValidationError("Date must be in YYYY-MM-DD format.")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def validate_ids_list(ids: List[str]) -> List[str]:
|
| 169 |
+
if not ids:
|
| 170 |
+
raise ValidationError("At least one item is required.")
|
| 171 |
+
validated = []
|
| 172 |
+
for i, id_val in enumerate(ids, 1):
|
| 173 |
+
id_val = id_val.strip()
|
| 174 |
+
if not id_val:
|
| 175 |
+
raise ValidationError(f"Item #{i} is empty.")
|
| 176 |
+
if not UUID_PATTERN.match(id_val):
|
| 177 |
+
raise ValidationError(f"Item #{i} ({id_val}) is not a valid UUID.")
|
| 178 |
+
validated.append(id_val)
|
| 179 |
+
return validated
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def validate_activity_rating(value: float) -> float:
|
| 183 |
+
if value < 0.1 or value > 5.0:
|
| 184 |
+
raise ValidationError("Activity rating must be between 0.1 and 5.0.")
|
| 185 |
+
return value
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class ForumClient:
|
| 189 |
+
def __init__(self, base_url: str = FORUM_BASE_URL):
|
| 190 |
+
self.base_url = base_url
|
| 191 |
+
self.client = httpx.Client(timeout=30.0)
|
| 192 |
+
|
| 193 |
+
@staticmethod
|
| 194 |
+
def _link(table: str, value: str) -> dict:
|
| 195 |
+
return {"table": table, "value": value}
|
| 196 |
+
|
| 197 |
+
def get_participants(self) -> list:
|
| 198 |
+
response = self.client.get(f"{self.base_url}/participants/")
|
| 199 |
+
response.raise_for_status()
|
| 200 |
+
return response.json()
|
| 201 |
+
|
| 202 |
+
def get_participant(self, participant_id: str) -> dict:
|
| 203 |
+
response = self.client.get(f"{self.base_url}/participants/{participant_id}")
|
| 204 |
+
response.raise_for_status()
|
| 205 |
+
return response.json()
|
| 206 |
+
|
| 207 |
+
def create_participant(self, first_name: str, last_name: str, nickname: str, activity_rating: float) -> dict:
|
| 208 |
+
payload = {
|
| 209 |
+
"first_name": first_name,
|
| 210 |
+
"last_name": last_name,
|
| 211 |
+
"nickname": nickname,
|
| 212 |
+
"activity_rating": activity_rating,
|
| 213 |
+
}
|
| 214 |
+
response = self.client.post(f"{self.base_url}/participants/", json=payload)
|
| 215 |
+
response.raise_for_status()
|
| 216 |
+
return response.json()
|
| 217 |
+
|
| 218 |
+
def get_topics(self) -> list:
|
| 219 |
+
response = self.client.get(f"{self.base_url}/topics/")
|
| 220 |
+
response.raise_for_status()
|
| 221 |
+
return response.json()
|
| 222 |
+
|
| 223 |
+
def get_topic(self, topic_id: str) -> dict:
|
| 224 |
+
response = self.client.get(f"{self.base_url}/topics/{topic_id}")
|
| 225 |
+
response.raise_for_status()
|
| 226 |
+
return response.json()
|
| 227 |
+
|
| 228 |
+
def get_messages(self) -> list:
|
| 229 |
+
response = self.client.get(f"{self.base_url}/messages/")
|
| 230 |
+
response.raise_for_status()
|
| 231 |
+
return response.json()
|
| 232 |
+
|
| 233 |
+
def create_topic(self, title: str, description: str, participants: list = None) -> dict:
|
| 234 |
+
payload = {
|
| 235 |
+
"title": title,
|
| 236 |
+
"description": description,
|
| 237 |
+
"participants": [self._link(TABLE_PARTICIPANTS, p) for p in (participants or [])],
|
| 238 |
+
"messages": [],
|
| 239 |
+
}
|
| 240 |
+
response = self.client.post(f"{self.base_url}/topics/", json=payload)
|
| 241 |
+
response.raise_for_status()
|
| 242 |
+
return response.json()
|
| 243 |
+
|
| 244 |
+
def publish_message(self, topic_id: str, participant_id: str, content: str) -> dict | str:
|
| 245 |
+
payload = {
|
| 246 |
+
"participant_id": self._link(TABLE_PARTICIPANTS, participant_id),
|
| 247 |
+
"content": content,
|
| 248 |
+
}
|
| 249 |
+
response = self.client.post(f"{self.base_url}/topics/{topic_id}/messages", json=payload)
|
| 250 |
+
if response.status_code == 400:
|
| 251 |
+
error_detail = response.json().get("detail", "Unknown error")
|
| 252 |
+
return f"ERROR: {error_detail}"
|
| 253 |
+
response.raise_for_status()
|
| 254 |
+
return response.json()
|
| 255 |
+
|
| 256 |
+
def download_participant(self, participant_id: str) -> dict:
|
| 257 |
+
response = self.client.get(f"{self.base_url}/participants/{participant_id}/download")
|
| 258 |
+
response.raise_for_status()
|
| 259 |
+
return response.json()
|
| 260 |
+
|
| 261 |
+
def download_topic(self, topic_id: str) -> dict:
|
| 262 |
+
response = self.client.get(f"{self.base_url}/topics/{topic_id}/download")
|
| 263 |
+
response.raise_for_status()
|
| 264 |
+
return response.json()
|
| 265 |
+
|
| 266 |
+
def download_message(self, message_id: str) -> dict:
|
| 267 |
+
response = self.client.get(f"{self.base_url}/messages/{message_id}/download")
|
| 268 |
+
response.raise_for_status()
|
| 269 |
+
return response.json()
|
| 270 |
+
|
| 271 |
+
def close(self):
|
| 272 |
+
self.client.close()
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class LibraryClient:
|
| 276 |
+
def __init__(self, base_url: str = LIBRARY_BASE_URL):
|
| 277 |
+
self.client = httpx.Client(base_url=base_url, timeout=30.0)
|
| 278 |
+
|
| 279 |
+
def close(self):
|
| 280 |
+
self.client.close()
|
| 281 |
+
|
| 282 |
+
def _handle_response(self, response: httpx.Response):
|
| 283 |
+
content_type = response.headers.get("content-type", "").lower()
|
| 284 |
+
if response.status_code >= 400:
|
| 285 |
+
message = response.text
|
| 286 |
+
try:
|
| 287 |
+
payload = response.json()
|
| 288 |
+
if isinstance(payload, dict):
|
| 289 |
+
message = payload.get("error", {}).get("message") or payload.get("message") or str(payload)
|
| 290 |
+
except Exception:
|
| 291 |
+
message = response.text
|
| 292 |
+
raise RuntimeError(f"{response.status_code}: {message}")
|
| 293 |
+
if "application/json" in content_type:
|
| 294 |
+
payload = response.json()
|
| 295 |
+
if isinstance(payload, dict) and "successful" in payload:
|
| 296 |
+
if payload.get("successful"):
|
| 297 |
+
return payload.get("data")
|
| 298 |
+
error = payload.get("error") or {}
|
| 299 |
+
raise RuntimeError(error.get("message") or str(payload))
|
| 300 |
+
return payload
|
| 301 |
+
return response
|
| 302 |
+
|
| 303 |
+
def get(self, path: str, **kwargs):
|
| 304 |
+
response = self.client.get(path, **kwargs)
|
| 305 |
+
return self._handle_response(response)
|
| 306 |
+
|
| 307 |
+
def post(self, path: str, **kwargs):
|
| 308 |
+
response = self.client.post(path, **kwargs)
|
| 309 |
+
return self._handle_response(response)
|
| 310 |
+
|
| 311 |
+
def patch(self, path: str, **kwargs):
|
| 312 |
+
response = self.client.patch(path, **kwargs)
|
| 313 |
+
return self._handle_response(response)
|
| 314 |
+
|
| 315 |
+
def delete(self, path: str, **kwargs):
|
| 316 |
+
response = self.client.delete(path, **kwargs)
|
| 317 |
+
return self._handle_response(response)
|
| 318 |
+
|
| 319 |
+
def list_books(self):
|
| 320 |
+
return self.get("/books/all")
|
| 321 |
+
|
| 322 |
+
def get_book(self, book_id: str):
|
| 323 |
+
return self.get(f"/books/{book_id}")
|
| 324 |
+
|
| 325 |
+
def book_exists(self, book_id: str) -> bool:
|
| 326 |
+
try:
|
| 327 |
+
self.get_book(book_id)
|
| 328 |
+
return True
|
| 329 |
+
except RuntimeError:
|
| 330 |
+
return False
|
| 331 |
+
|
| 332 |
+
def create_book(self, title: str, author: str, pages: int, year: int, genre: str, status: str = "Available"):
|
| 333 |
+
payload = {"title": title, "author": author, "pages": pages, "year": year, "genre": genre, "status": status}
|
| 334 |
+
return self.post("/books/create", json=payload)
|
| 335 |
+
|
| 336 |
+
def update_book(self, book_id: str, title: Optional[str] = None, author: Optional[str] = None,
|
| 337 |
+
pages: Optional[int] = None, year: Optional[int] = None, genre: Optional[str] = None,
|
| 338 |
+
status: Optional[str] = None):
|
| 339 |
+
payload = {k: v for k, v in {"title": title, "author": author, "pages": pages, "year": year,
|
| 340 |
+
"genre": genre, "status": status}.items() if v not in (None, "")}
|
| 341 |
+
return self.patch(f"/books/{book_id}", json=payload)
|
| 342 |
+
|
| 343 |
+
def delete_book(self, book_id: str):
|
| 344 |
+
return self.delete(f"/books/{book_id}")
|
| 345 |
+
|
| 346 |
+
def borrow_books(self, book_ids: List[str], visitor_id: str, worker_id: str, borrow_date: str):
|
| 347 |
+
payload = {"bookIds": book_ids, "visitorId": visitor_id, "workerId": worker_id, "borrowDate": borrow_date}
|
| 348 |
+
return self.post("/books/borrow", json=payload)
|
| 349 |
+
|
| 350 |
+
def return_books(self, book_ids: List[str], visitor_id: str, worker_id: str, return_date: str):
|
| 351 |
+
payload = {"bookIds": book_ids, "visitorId": visitor_id, "workerId": worker_id, "returnDate": return_date}
|
| 352 |
+
return self.post("/books/return", json=payload)
|
| 353 |
+
|
| 354 |
+
def download_book(self, book_id: str) -> str:
|
| 355 |
+
response = self.get(f"/books/{book_id}/download")
|
| 356 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 357 |
+
|
| 358 |
+
def list_visitors(self):
|
| 359 |
+
return self.get("/visitors/all")
|
| 360 |
+
|
| 361 |
+
def get_visitor(self, visitor_id: str):
|
| 362 |
+
return self.get(f"/visitors/{visitor_id}")
|
| 363 |
+
|
| 364 |
+
def visitor_exists(self, visitor_id: str) -> bool:
|
| 365 |
+
try:
|
| 366 |
+
self.get_visitor(visitor_id)
|
| 367 |
+
return True
|
| 368 |
+
except RuntimeError:
|
| 369 |
+
return False
|
| 370 |
+
|
| 371 |
+
def create_visitor(self, name: str, surname: str):
|
| 372 |
+
payload = {"name": name, "surname": surname}
|
| 373 |
+
return self.post("/visitors/create", json=payload)
|
| 374 |
+
|
| 375 |
+
def update_visitor(self, visitor_id: str, name: Optional[str] = None, surname: Optional[str] = None):
|
| 376 |
+
payload = {k: v for k, v in {"name": name, "surname": surname}.items() if v}
|
| 377 |
+
return self.patch(f"/visitors/{visitor_id}", json=payload)
|
| 378 |
+
|
| 379 |
+
def delete_visitor(self, visitor_id: str):
|
| 380 |
+
return self.delete(f"/visitors/delete/{visitor_id}")
|
| 381 |
+
|
| 382 |
+
def download_visitor(self, visitor_id: str) -> str:
|
| 383 |
+
response = self.get(f"/visitors/{visitor_id}/download")
|
| 384 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 385 |
+
|
| 386 |
+
def list_workers(self):
|
| 387 |
+
return self.get("/workers/all")
|
| 388 |
+
|
| 389 |
+
def get_worker(self, worker_id: str):
|
| 390 |
+
return self.get(f"/workers/{worker_id}")
|
| 391 |
+
|
| 392 |
+
def worker_exists(self, worker_id: str) -> bool:
|
| 393 |
+
try:
|
| 394 |
+
self.get_worker(worker_id)
|
| 395 |
+
return True
|
| 396 |
+
except RuntimeError:
|
| 397 |
+
return False
|
| 398 |
+
|
| 399 |
+
def create_worker(self, name: str, surname: str, experience: int, work_days: List[str]):
|
| 400 |
+
payload = {"name": name, "surname": surname, "experience": experience, "workDays": work_days}
|
| 401 |
+
return self.post("/workers/create", json=payload)
|
| 402 |
+
|
| 403 |
+
def update_worker(self, worker_id: str, name: Optional[str] = None, surname: Optional[str] = None,
|
| 404 |
+
experience: Optional[int] = None, work_days: Optional[List[str]] = None):
|
| 405 |
+
payload = {k: v for k, v in {"name": name, "surname": surname, "experience": experience,
|
| 406 |
+
"workDays": work_days}.items() if v not in (None, "")}
|
| 407 |
+
return self.patch(f"/workers/{worker_id}", json=payload)
|
| 408 |
+
|
| 409 |
+
def delete_worker(self, worker_id: str):
|
| 410 |
+
return self.delete(f"/workers/{worker_id}")
|
| 411 |
+
|
| 412 |
+
def workers_by_days(self, work_days: List[str]):
|
| 413 |
+
params = [("workDays", day) for day in work_days]
|
| 414 |
+
return self.get("/workers/by-work-days", params=params)
|
| 415 |
+
|
| 416 |
+
def download_worker(self, worker_id: str) -> str:
|
| 417 |
+
response = self.get(f"/workers/{worker_id}/download")
|
| 418 |
+
return response.text if isinstance(response, httpx.Response) else str(response)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
class ForumMenuChoice(IntEnum):
|
| 422 |
+
LIST_PARTICIPANTS = 1
|
| 423 |
+
GET_PARTICIPANT = 2
|
| 424 |
+
CREATE_PARTICIPANT = 3
|
| 425 |
+
DOWNLOAD_PARTICIPANT = 4
|
| 426 |
+
LIST_TOPICS = 5
|
| 427 |
+
GET_TOPIC = 6
|
| 428 |
+
CREATE_TOPIC = 7
|
| 429 |
+
DOWNLOAD_TOPIC = 8
|
| 430 |
+
PUBLISH_MESSAGE = 9
|
| 431 |
+
LIST_MESSAGES = 10
|
| 432 |
+
DOWNLOAD_MESSAGE = 11
|
| 433 |
+
BACK = 0
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
FORUM_MENU_TEXT = {
|
| 437 |
+
ForumMenuChoice.LIST_PARTICIPANTS: "List all participants",
|
| 438 |
+
ForumMenuChoice.GET_PARTICIPANT: "Get participant by ID",
|
| 439 |
+
ForumMenuChoice.CREATE_PARTICIPANT: "Create new participant",
|
| 440 |
+
ForumMenuChoice.DOWNLOAD_PARTICIPANT: "Download participant as file",
|
| 441 |
+
ForumMenuChoice.LIST_TOPICS: "List all topics",
|
| 442 |
+
ForumMenuChoice.GET_TOPIC: "Get topic by ID",
|
| 443 |
+
ForumMenuChoice.CREATE_TOPIC: "Create new topic",
|
| 444 |
+
ForumMenuChoice.DOWNLOAD_TOPIC: "Download topic as file",
|
| 445 |
+
ForumMenuChoice.PUBLISH_MESSAGE: "Publish message to topic",
|
| 446 |
+
ForumMenuChoice.LIST_MESSAGES: "List all messages",
|
| 447 |
+
ForumMenuChoice.DOWNLOAD_MESSAGE: "Download message as file",
|
| 448 |
+
ForumMenuChoice.BACK: "Back to server selection",
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
class LibraryMenuChoice(IntEnum):
|
| 453 |
+
LIST_BOOKS = 1
|
| 454 |
+
VIEW_BOOK = 2
|
| 455 |
+
CREATE_BOOK = 3
|
| 456 |
+
UPDATE_BOOK = 4
|
| 457 |
+
DELETE_BOOK = 5
|
| 458 |
+
BORROW_BOOKS = 6
|
| 459 |
+
RETURN_BOOKS = 7
|
| 460 |
+
DOWNLOAD_BOOK = 8
|
| 461 |
+
LIST_VISITORS = 9
|
| 462 |
+
VIEW_VISITOR = 10
|
| 463 |
+
CREATE_VISITOR = 11
|
| 464 |
+
UPDATE_VISITOR = 12
|
| 465 |
+
DELETE_VISITOR = 13
|
| 466 |
+
DOWNLOAD_VISITOR = 14
|
| 467 |
+
LIST_WORKERS = 15
|
| 468 |
+
VIEW_WORKER = 16
|
| 469 |
+
CREATE_WORKER = 17
|
| 470 |
+
UPDATE_WORKER = 18
|
| 471 |
+
DELETE_WORKER = 19
|
| 472 |
+
WORKERS_BY_DAYS = 20
|
| 473 |
+
DOWNLOAD_WORKER = 21
|
| 474 |
+
BACK = 0
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
LIBRARY_MENU_TEXT = {
|
| 478 |
+
LibraryMenuChoice.LIST_BOOKS: "List books",
|
| 479 |
+
LibraryMenuChoice.VIEW_BOOK: "Get book by ID",
|
| 480 |
+
LibraryMenuChoice.CREATE_BOOK: "Create book",
|
| 481 |
+
LibraryMenuChoice.UPDATE_BOOK: "Update book",
|
| 482 |
+
LibraryMenuChoice.DELETE_BOOK: "Delete book",
|
| 483 |
+
LibraryMenuChoice.BORROW_BOOKS: "Borrow books",
|
| 484 |
+
LibraryMenuChoice.RETURN_BOOKS: "Return books",
|
| 485 |
+
LibraryMenuChoice.DOWNLOAD_BOOK: "Download book as file",
|
| 486 |
+
LibraryMenuChoice.LIST_VISITORS: "List visitors",
|
| 487 |
+
LibraryMenuChoice.VIEW_VISITOR: "Get visitor by ID",
|
| 488 |
+
LibraryMenuChoice.CREATE_VISITOR: "Create visitor",
|
| 489 |
+
LibraryMenuChoice.UPDATE_VISITOR: "Update visitor",
|
| 490 |
+
LibraryMenuChoice.DELETE_VISITOR: "Delete visitor",
|
| 491 |
+
LibraryMenuChoice.DOWNLOAD_VISITOR: "Download visitor as file",
|
| 492 |
+
LibraryMenuChoice.LIST_WORKERS: "List workers",
|
| 493 |
+
LibraryMenuChoice.VIEW_WORKER: "Get worker by ID",
|
| 494 |
+
LibraryMenuChoice.CREATE_WORKER: "Create worker",
|
| 495 |
+
LibraryMenuChoice.UPDATE_WORKER: "Update worker",
|
| 496 |
+
LibraryMenuChoice.DELETE_WORKER: "Delete worker",
|
| 497 |
+
LibraryMenuChoice.WORKERS_BY_DAYS: "Find workers by work days",
|
| 498 |
+
LibraryMenuChoice.DOWNLOAD_WORKER: "Download worker as file",
|
| 499 |
+
LibraryMenuChoice.BACK: "Back to server selection",
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def prompt_with_validation(label: str, validator: Callable[[str], str]) -> str:
|
| 504 |
+
while True:
|
| 505 |
+
raw = input(label).strip()
|
| 506 |
+
try:
|
| 507 |
+
return validator(raw)
|
| 508 |
+
except ValidationError as e:
|
| 509 |
+
print(f" Error: {e}")
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def prompt_int_with_validation(label: str, validator: Callable[[int], int]) -> int:
|
| 513 |
+
while True:
|
| 514 |
+
raw = input(label).strip()
|
| 515 |
+
try:
|
| 516 |
+
value = int(raw)
|
| 517 |
+
return validator(value)
|
| 518 |
+
except ValueError:
|
| 519 |
+
print(" Error: Enter a valid integer.")
|
| 520 |
+
except ValidationError as e:
|
| 521 |
+
print(f" Error: {e}")
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def prompt_float_with_validation(label: str, validator: Callable[[float], float]) -> float:
|
| 525 |
+
while True:
|
| 526 |
+
raw = input(label).strip()
|
| 527 |
+
try:
|
| 528 |
+
value = float(raw)
|
| 529 |
+
return validator(value)
|
| 530 |
+
except ValueError:
|
| 531 |
+
print(" Error: Enter a valid number.")
|
| 532 |
+
except ValidationError as e:
|
| 533 |
+
print(f" Error: {e}")
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def prompt_optional_with_validation(label: str, validator: Callable[[str], str]) -> Optional[str]:
|
| 537 |
+
while True:
|
| 538 |
+
raw = input(label).strip()
|
| 539 |
+
if not raw:
|
| 540 |
+
return None
|
| 541 |
+
try:
|
| 542 |
+
return validator(raw)
|
| 543 |
+
except ValidationError as e:
|
| 544 |
+
print(f" Error: {e}")
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def prompt_optional_int_with_validation(label: str, validator: Callable[[int], int]) -> Optional[int]:
|
| 548 |
+
while True:
|
| 549 |
+
raw = input(label).strip()
|
| 550 |
+
if not raw:
|
| 551 |
+
return None
|
| 552 |
+
try:
|
| 553 |
+
value = int(raw)
|
| 554 |
+
return validator(value)
|
| 555 |
+
except ValueError:
|
| 556 |
+
print(" Error: Enter a valid integer.")
|
| 557 |
+
except ValidationError as e:
|
| 558 |
+
print(f" Error: {e}")
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def prompt_choice(label: str, options: List[str]) -> str:
|
| 562 |
+
while True:
|
| 563 |
+
print(label)
|
| 564 |
+
for idx, option in enumerate(options, 1):
|
| 565 |
+
print(f" {idx}. {option}")
|
| 566 |
+
raw = input("Pick number: ").strip()
|
| 567 |
+
try:
|
| 568 |
+
selected = int(raw)
|
| 569 |
+
if 1 <= selected <= len(options):
|
| 570 |
+
return options[selected - 1]
|
| 571 |
+
except ValueError:
|
| 572 |
+
pass
|
| 573 |
+
print(" Error: Invalid choice.")
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def prompt_optional_choice(label: str, options: List[str]) -> Optional[str]:
|
| 577 |
+
while True:
|
| 578 |
+
print(label)
|
| 579 |
+
print(" 0. Skip")
|
| 580 |
+
for idx, option in enumerate(options, 1):
|
| 581 |
+
print(f" {idx}. {option}")
|
| 582 |
+
raw = input("Pick number (0 to skip): ").strip()
|
| 583 |
+
try:
|
| 584 |
+
selected = int(raw)
|
| 585 |
+
if selected == 0:
|
| 586 |
+
return None
|
| 587 |
+
if 1 <= selected <= len(options):
|
| 588 |
+
return options[selected - 1]
|
| 589 |
+
except ValueError:
|
| 590 |
+
pass
|
| 591 |
+
print(" Error: Invalid choice.")
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def prompt_multi_choice(label: str, options: List[str]) -> List[str]:
|
| 595 |
+
print(label)
|
| 596 |
+
print("Use comma to separate values or type * for all.")
|
| 597 |
+
for idx, option in enumerate(options, 1):
|
| 598 |
+
print(f" {idx}. {option}")
|
| 599 |
+
while True:
|
| 600 |
+
raw = input("Enter numbers or names: ").strip()
|
| 601 |
+
if raw == "*":
|
| 602 |
+
return options[:]
|
| 603 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 604 |
+
result = []
|
| 605 |
+
valid = True
|
| 606 |
+
for p in parts:
|
| 607 |
+
if p in options:
|
| 608 |
+
result.append(p)
|
| 609 |
+
elif p.isdigit():
|
| 610 |
+
idx = int(p)
|
| 611 |
+
if 1 <= idx <= len(options):
|
| 612 |
+
result.append(options[idx - 1])
|
| 613 |
+
else:
|
| 614 |
+
print(f" Error: Invalid number {p}.")
|
| 615 |
+
valid = False
|
| 616 |
+
break
|
| 617 |
+
else:
|
| 618 |
+
print(f" Error: Invalid value '{p}'.")
|
| 619 |
+
valid = False
|
| 620 |
+
break
|
| 621 |
+
if valid and result:
|
| 622 |
+
try:
|
| 623 |
+
return validate_work_days(result)
|
| 624 |
+
except ValidationError as e:
|
| 625 |
+
print(f" Error: {e}")
|
| 626 |
+
elif valid and not result:
|
| 627 |
+
print(" Error: At least one value is required.")
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def prompt_optional_multi_choice(label: str, options: List[str]) -> Optional[List[str]]:
|
| 631 |
+
print(label)
|
| 632 |
+
print("Use comma to separate values, * for all, or leave empty to skip.")
|
| 633 |
+
for idx, option in enumerate(options, 1):
|
| 634 |
+
print(f" {idx}. {option}")
|
| 635 |
+
while True:
|
| 636 |
+
raw = input("Enter numbers or names (empty to skip): ").strip()
|
| 637 |
+
if not raw:
|
| 638 |
+
return None
|
| 639 |
+
if raw == "*":
|
| 640 |
+
return options[:]
|
| 641 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 642 |
+
result = []
|
| 643 |
+
valid = True
|
| 644 |
+
for p in parts:
|
| 645 |
+
if p in options:
|
| 646 |
+
result.append(p)
|
| 647 |
+
elif p.isdigit():
|
| 648 |
+
idx = int(p)
|
| 649 |
+
if 1 <= idx <= len(options):
|
| 650 |
+
result.append(options[idx - 1])
|
| 651 |
+
else:
|
| 652 |
+
print(f" Error: Invalid number {p}.")
|
| 653 |
+
valid = False
|
| 654 |
+
break
|
| 655 |
+
else:
|
| 656 |
+
print(f" Error: Invalid value '{p}'.")
|
| 657 |
+
valid = False
|
| 658 |
+
break
|
| 659 |
+
if valid and result:
|
| 660 |
+
try:
|
| 661 |
+
return validate_work_days(result)
|
| 662 |
+
except ValidationError as e:
|
| 663 |
+
print(f" Error: {e}")
|
| 664 |
+
elif valid and not result:
|
| 665 |
+
return None
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
def prompt_book_ids(label: str, client: LibraryClient) -> List[str]:
|
| 669 |
+
while True:
|
| 670 |
+
raw = input(label).strip()
|
| 671 |
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
| 672 |
+
try:
|
| 673 |
+
validated = validate_ids_list(parts)
|
| 674 |
+
for book_id in validated:
|
| 675 |
+
if not client.book_exists(book_id):
|
| 676 |
+
print(f" Error: Book with ID {book_id} not found.")
|
| 677 |
+
raise ValidationError("Book not found")
|
| 678 |
+
return validated
|
| 679 |
+
except ValidationError as e:
|
| 680 |
+
if "not found" not in str(e):
|
| 681 |
+
print(f" Error: {e}")
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def prompt_book_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 685 |
+
while True:
|
| 686 |
+
raw = input(label).strip()
|
| 687 |
+
try:
|
| 688 |
+
validated = validate_uuid(raw, "Book ID")
|
| 689 |
+
if check_exists and not client.book_exists(validated):
|
| 690 |
+
print(f" Error: Book with ID {validated} not found.")
|
| 691 |
+
continue
|
| 692 |
+
return validated
|
| 693 |
+
except ValidationError as e:
|
| 694 |
+
print(f" Error: {e}")
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def prompt_visitor_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 698 |
+
while True:
|
| 699 |
+
raw = input(label).strip()
|
| 700 |
+
try:
|
| 701 |
+
validated = validate_uuid(raw, "Visitor ID")
|
| 702 |
+
if check_exists and not client.visitor_exists(validated):
|
| 703 |
+
print(f" Error: Visitor with ID {validated} not found.")
|
| 704 |
+
continue
|
| 705 |
+
return validated
|
| 706 |
+
except ValidationError as e:
|
| 707 |
+
print(f" Error: {e}")
|
| 708 |
+
|
| 709 |
+
|
| 710 |
+
def prompt_worker_id(label: str, client: LibraryClient, check_exists: bool = True) -> str:
|
| 711 |
+
while True:
|
| 712 |
+
raw = input(label).strip()
|
| 713 |
+
try:
|
| 714 |
+
validated = validate_uuid(raw, "Worker ID")
|
| 715 |
+
if check_exists and not client.worker_exists(validated):
|
| 716 |
+
print(f" Error: Worker with ID {validated} not found.")
|
| 717 |
+
continue
|
| 718 |
+
return validated
|
| 719 |
+
except ValidationError as e:
|
| 720 |
+
print(f" Error: {e}")
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
def prompt_date(label: str) -> str:
|
| 724 |
+
today = date.today().isoformat()
|
| 725 |
+
while True:
|
| 726 |
+
raw = input(f"{label} [{today}]: ").strip()
|
| 727 |
+
try:
|
| 728 |
+
return validate_date(raw)
|
| 729 |
+
except ValidationError as e:
|
| 730 |
+
print(f" Error: {e}")
|
| 731 |
+
|
| 732 |
+
|
| 733 |
+
def print_books(books: Any):
|
| 734 |
+
if not books:
|
| 735 |
+
print("No books found.")
|
| 736 |
+
return
|
| 737 |
+
for book in books:
|
| 738 |
+
print(SEPARATOR)
|
| 739 |
+
print(f"ID: {book.get('id')}")
|
| 740 |
+
print(f"Title: {book.get('title')}")
|
| 741 |
+
print(f"Author: {book.get('author')}")
|
| 742 |
+
print(f"Pages: {book.get('pages')}")
|
| 743 |
+
print(f"Year: {book.get('year')}")
|
| 744 |
+
print(f"Genre: {book.get('genre')}")
|
| 745 |
+
print(f"Status: {book.get('status')}")
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
def print_visitors(visitors: Any):
|
| 749 |
+
if not visitors:
|
| 750 |
+
print("No visitors found.")
|
| 751 |
+
return
|
| 752 |
+
for visitor in visitors:
|
| 753 |
+
print(SEPARATOR)
|
| 754 |
+
print(f"ID: {visitor.get('id')}")
|
| 755 |
+
print(f"Name: {visitor.get('name')} {visitor.get('surname')}")
|
| 756 |
+
print(f"Registered: {visitor.get('registrationDate')}")
|
| 757 |
+
current = visitor.get("currentBooks") or []
|
| 758 |
+
history = visitor.get("history") or []
|
| 759 |
+
print(f"Current books: {len(current)}")
|
| 760 |
+
print(f"History: {len(history)}")
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def print_workers(workers: Any):
|
| 764 |
+
if not workers:
|
| 765 |
+
print("No workers found.")
|
| 766 |
+
return
|
| 767 |
+
for worker in workers:
|
| 768 |
+
print(SEPARATOR)
|
| 769 |
+
print(f"ID: {worker.get('id')}")
|
| 770 |
+
print(f"Name: {worker.get('name')} {worker.get('surname')}")
|
| 771 |
+
print(f"Experience: {worker.get('experience')} years")
|
| 772 |
+
print(f"Work days: {', '.join(worker.get('workDays', []))}")
|
| 773 |
+
issued = worker.get("issuedBooks") or []
|
| 774 |
+
print(f"Issued books: {len(issued)}")
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def print_participants(participants: list):
|
| 778 |
+
if not participants:
|
| 779 |
+
print("No participants found.")
|
| 780 |
+
return
|
| 781 |
+
header = f"{'ID':<40} {'Name':<25} {'Nickname':<15} {'Rating':<10}"
|
| 782 |
+
print(header)
|
| 783 |
+
print(SEPARATOR_90)
|
| 784 |
+
for p in participants:
|
| 785 |
+
full_name = f"{p['first_name']} {p['last_name']}"
|
| 786 |
+
print(f"{p['id']:<40} {full_name:<25} {p['nickname']:<15} {p['activity_rating']:<10}")
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def print_topics(topics: list):
|
| 790 |
+
if not topics:
|
| 791 |
+
print("No topics found.")
|
| 792 |
+
return
|
| 793 |
+
for topic in topics:
|
| 794 |
+
print(f"\nTopic: {topic['title']}")
|
| 795 |
+
print(f" ID: {topic['id']}")
|
| 796 |
+
print(f" Description: {topic['description']}")
|
| 797 |
+
print(f" Created: {topic['created_at']}")
|
| 798 |
+
print(f" Messages ({len(topic['messages'])}):")
|
| 799 |
+
for msg in topic["messages"]:
|
| 800 |
+
print(f" {msg.get('order_in_topic', 0)}. [{msg['participant_name']}]: {msg['content']}")
|
| 801 |
+
|
| 802 |
+
|
| 803 |
+
def print_topic_detail(topic: dict):
|
| 804 |
+
print(f"\nTopic: {topic['title']}")
|
| 805 |
+
print(f" ID: {topic['id']}")
|
| 806 |
+
print(f" Description: {topic['description']}")
|
| 807 |
+
print(f" Created: {topic['created_at']}")
|
| 808 |
+
print(f" Participants: {len(topic['participants'])}")
|
| 809 |
+
print(f"\n Messages ({len(topic['messages'])}):")
|
| 810 |
+
if not topic["messages"]:
|
| 811 |
+
print(" No messages yet.")
|
| 812 |
+
for msg in topic["messages"]:
|
| 813 |
+
print(f" {msg.get('order_in_topic', 0)}. [{msg['participant_name']}]: {msg['content']}")
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def print_messages(messages: list):
|
| 817 |
+
if not messages:
|
| 818 |
+
print(" No messages yet.")
|
| 819 |
+
return
|
| 820 |
+
for msg in messages:
|
| 821 |
+
print(f"{msg['topic_title']} | #{msg['order_in_topic']} [{msg['participant_name']}]: {msg['content']}")
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def server_selection_menu() -> Optional[int]:
|
| 825 |
+
print("\n" + SEPARATOR)
|
| 826 |
+
print(" UNIFIED CLIENT - SERVER SELECTION")
|
| 827 |
+
print(SEPARATOR)
|
| 828 |
+
print("1. Forum Server (Topics, Participants, Messages)")
|
| 829 |
+
print("2. Library Server (Books, Visitors, Workers)")
|
| 830 |
+
print("0. Exit")
|
| 831 |
+
print(DASH_LINE_60)
|
| 832 |
+
raw = input(PROMPT).strip()
|
| 833 |
+
try:
|
| 834 |
+
choice = int(raw)
|
| 835 |
+
if choice in (0, 1, 2):
|
| 836 |
+
return choice
|
| 837 |
+
except ValueError:
|
| 838 |
+
pass
|
| 839 |
+
return None
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def forum_menu() -> Optional[ForumMenuChoice]:
|
| 843 |
+
print("\n" + SEPARATOR_60)
|
| 844 |
+
print(" FORUM CLIENT - MAIN MENU")
|
| 845 |
+
print(SEPARATOR_60)
|
| 846 |
+
for choice in ForumMenuChoice:
|
| 847 |
+
print(f"{choice.value}. {FORUM_MENU_TEXT[choice]}")
|
| 848 |
+
print(DASH_LINE_60)
|
| 849 |
+
raw = input(PROMPT).strip()
|
| 850 |
+
try:
|
| 851 |
+
return ForumMenuChoice(int(raw))
|
| 852 |
+
except Exception:
|
| 853 |
+
return None
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def library_menu() -> Optional[LibraryMenuChoice]:
|
| 857 |
+
print("\n" + SEPARATOR)
|
| 858 |
+
for choice in LibraryMenuChoice:
|
| 859 |
+
print(f"{choice.value}. {LIBRARY_MENU_TEXT[choice]}")
|
| 860 |
+
raw = input(PROMPT).strip()
|
| 861 |
+
try:
|
| 862 |
+
return LibraryMenuChoice(int(raw))
|
| 863 |
+
except Exception:
|
| 864 |
+
return None
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
def run_forum_client():
|
| 868 |
+
client = ForumClient()
|
| 869 |
+
try:
|
| 870 |
+
while True:
|
| 871 |
+
choice = forum_menu()
|
| 872 |
+
if choice is None:
|
| 873 |
+
print("Invalid option. Please try again.")
|
| 874 |
+
continue
|
| 875 |
+
if choice == ForumMenuChoice.BACK:
|
| 876 |
+
break
|
| 877 |
+
|
| 878 |
+
try:
|
| 879 |
+
if choice == ForumMenuChoice.LIST_PARTICIPANTS:
|
| 880 |
+
print(SEPARATOR_60)
|
| 881 |
+
print("ALL PARTICIPANTS:")
|
| 882 |
+
print_participants(client.get_participants())
|
| 883 |
+
|
| 884 |
+
elif choice == ForumMenuChoice.GET_PARTICIPANT:
|
| 885 |
+
print(SEPARATOR_60)
|
| 886 |
+
participant_id = input("Enter participant ID: ").strip()
|
| 887 |
+
try:
|
| 888 |
+
UUID(participant_id)
|
| 889 |
+
participant = client.get_participant(participant_id)
|
| 890 |
+
print(f"\nParticipant: {participant['first_name']} {participant['last_name']}")
|
| 891 |
+
print(f" ID: {participant['id']}")
|
| 892 |
+
print(f" Nickname: {participant['nickname']}")
|
| 893 |
+
print(f" Rating: {participant['activity_rating']}")
|
| 894 |
+
print(f" Registered: {participant['registered_at']}")
|
| 895 |
+
except ValueError:
|
| 896 |
+
print("Invalid UUID format.")
|
| 897 |
+
|
| 898 |
+
elif choice == ForumMenuChoice.CREATE_PARTICIPANT:
|
| 899 |
+
print(SEPARATOR_60)
|
| 900 |
+
print("CREATE NEW PARTICIPANT:")
|
| 901 |
+
first_name = prompt_with_validation(" First name: ",
|
| 902 |
+
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
|
| 903 |
+
ValidationError("First name must contain only letters and '-'.")))
|
| 904 |
+
last_name = prompt_with_validation(" Last name: ",
|
| 905 |
+
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
|
| 906 |
+
ValidationError("Last name must contain only letters and '-'.")))
|
| 907 |
+
nickname = prompt_with_validation(" Nickname: ",
|
| 908 |
+
lambda v: v if re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", v) else (_ for _ in ()).throw(
|
| 909 |
+
ValidationError("Nickname must contain only letters and '-'.")))
|
| 910 |
+
activity_rating = prompt_float_with_validation(" Activity rating: ", validate_activity_rating)
|
| 911 |
+
participant = client.create_participant(first_name, last_name, nickname, activity_rating)
|
| 912 |
+
print("\nParticipant created successfully!")
|
| 913 |
+
print(f" ID: {participant['id']}")
|
| 914 |
+
|
| 915 |
+
elif choice == ForumMenuChoice.LIST_TOPICS:
|
| 916 |
+
print(SEPARATOR_60)
|
| 917 |
+
print("ALL TOPICS:")
|
| 918 |
+
print_topics(client.get_topics())
|
| 919 |
+
|
| 920 |
+
elif choice == ForumMenuChoice.GET_TOPIC:
|
| 921 |
+
print(SEPARATOR_60)
|
| 922 |
+
topic_id = input("Enter topic ID: ").strip()
|
| 923 |
+
try:
|
| 924 |
+
UUID(topic_id)
|
| 925 |
+
topic = client.get_topic(topic_id)
|
| 926 |
+
print_topic_detail(topic)
|
| 927 |
+
except ValueError:
|
| 928 |
+
print("Invalid UUID format.")
|
| 929 |
+
|
| 930 |
+
elif choice == ForumMenuChoice.CREATE_TOPIC:
|
| 931 |
+
print(SEPARATOR_60)
|
| 932 |
+
print("CREATE NEW TOPIC:")
|
| 933 |
+
title = input(" Title: ").strip()
|
| 934 |
+
description = input(" Description: ").strip()
|
| 935 |
+
topic = client.create_topic(title, description)
|
| 936 |
+
print("\nTopic created successfully!")
|
| 937 |
+
print(f" ID: {topic['id']}")
|
| 938 |
+
|
| 939 |
+
elif choice == ForumMenuChoice.PUBLISH_MESSAGE:
|
| 940 |
+
print(SEPARATOR_60)
|
| 941 |
+
print("PUBLISH MESSAGE TO TOPIC:")
|
| 942 |
+
while True:
|
| 943 |
+
topic_id = input(" Topic ID: ").strip()
|
| 944 |
+
if not topic_id:
|
| 945 |
+
print("Topic ID is required.")
|
| 946 |
+
continue
|
| 947 |
+
try:
|
| 948 |
+
client.get_topic(topic_id)
|
| 949 |
+
break
|
| 950 |
+
except httpx.HTTPStatusError as e:
|
| 951 |
+
if e.response.status_code in (404, 422):
|
| 952 |
+
print("Topic not found.")
|
| 953 |
+
else:
|
| 954 |
+
print(f"Error: {e.response.status_code} - {e.response.text}")
|
| 955 |
+
|
| 956 |
+
while True:
|
| 957 |
+
participant_id = input(" Participant ID: ").strip()
|
| 958 |
+
if not participant_id:
|
| 959 |
+
print("Participant ID is required.")
|
| 960 |
+
continue
|
| 961 |
+
try:
|
| 962 |
+
client.get_participant(participant_id)
|
| 963 |
+
break
|
| 964 |
+
except httpx.HTTPStatusError as e:
|
| 965 |
+
if e.response.status_code in (404, 422):
|
| 966 |
+
print("Participant not found.")
|
| 967 |
+
else:
|
| 968 |
+
print(f"Error: {e.response.status_code} - {e.response.text}")
|
| 969 |
+
|
| 970 |
+
content = input(" Message content: ").strip()
|
| 971 |
+
result = client.publish_message(topic_id, participant_id, content)
|
| 972 |
+
if isinstance(result, str):
|
| 973 |
+
print(f"\n{result}")
|
| 974 |
+
else:
|
| 975 |
+
print("\nMessage published successfully!")
|
| 976 |
+
print(f"Topic now has {len(result['messages'])} message(s).")
|
| 977 |
+
|
| 978 |
+
elif choice == ForumMenuChoice.LIST_MESSAGES:
|
| 979 |
+
print(SEPARATOR_60)
|
| 980 |
+
print("ALL MESSAGES:")
|
| 981 |
+
print_messages(client.get_messages())
|
| 982 |
+
|
| 983 |
+
elif choice == ForumMenuChoice.DOWNLOAD_PARTICIPANT:
|
| 984 |
+
print(SEPARATOR_60)
|
| 985 |
+
participant_id = input("Enter participant ID: ").strip()
|
| 986 |
+
try:
|
| 987 |
+
UUID(participant_id)
|
| 988 |
+
content = client.download_participant(participant_id)
|
| 989 |
+
filepath = save_to_file("participants", participant_id, content)
|
| 990 |
+
print(f"File saved to: {filepath}")
|
| 991 |
+
except ValueError:
|
| 992 |
+
print("Invalid UUID format.")
|
| 993 |
+
|
| 994 |
+
elif choice == ForumMenuChoice.DOWNLOAD_TOPIC:
|
| 995 |
+
print(SEPARATOR_60)
|
| 996 |
+
topic_id = input("Enter topic ID: ").strip()
|
| 997 |
+
try:
|
| 998 |
+
UUID(topic_id)
|
| 999 |
+
content = client.download_topic(topic_id)
|
| 1000 |
+
filepath = save_to_file("topics", topic_id, content)
|
| 1001 |
+
print(f"File saved to: {filepath}")
|
| 1002 |
+
except ValueError:
|
| 1003 |
+
print("Invalid UUID format.")
|
| 1004 |
+
|
| 1005 |
+
elif choice == ForumMenuChoice.DOWNLOAD_MESSAGE:
|
| 1006 |
+
print(SEPARATOR_60)
|
| 1007 |
+
message_id = input("Enter message ID: ").strip()
|
| 1008 |
+
try:
|
| 1009 |
+
UUID(message_id)
|
| 1010 |
+
content = client.download_message(message_id)
|
| 1011 |
+
filepath = save_to_file("messages", message_id, content)
|
| 1012 |
+
print(f"File saved to: {filepath}")
|
| 1013 |
+
except ValueError:
|
| 1014 |
+
print("Invalid UUID format.")
|
| 1015 |
+
|
| 1016 |
+
except httpx.HTTPStatusError as e:
|
| 1017 |
+
print(f"Error: {e.response.status_code} - {e.response.text}")
|
| 1018 |
+
finally:
|
| 1019 |
+
client.close()
|
| 1020 |
+
|
| 1021 |
+
|
| 1022 |
+
def run_library_client():
|
| 1023 |
+
client = LibraryClient()
|
| 1024 |
+
try:
|
| 1025 |
+
while True:
|
| 1026 |
+
choice = library_menu()
|
| 1027 |
+
if choice is None:
|
| 1028 |
+
print("Invalid option.")
|
| 1029 |
+
continue
|
| 1030 |
+
if choice == LibraryMenuChoice.BACK:
|
| 1031 |
+
break
|
| 1032 |
+
|
| 1033 |
+
try:
|
| 1034 |
+
if choice == LibraryMenuChoice.LIST_BOOKS:
|
| 1035 |
+
print_books(client.list_books())
|
| 1036 |
+
|
| 1037 |
+
elif choice == LibraryMenuChoice.VIEW_BOOK:
|
| 1038 |
+
book_id = prompt_book_id("Book ID: ", client)
|
| 1039 |
+
print_books([client.get_book(book_id)])
|
| 1040 |
+
|
| 1041 |
+
elif choice == LibraryMenuChoice.CREATE_BOOK:
|
| 1042 |
+
title = prompt_with_validation("Title: ", validate_title)
|
| 1043 |
+
author = prompt_with_validation("Author: ", validate_author)
|
| 1044 |
+
pages = prompt_int_with_validation("Pages: ", validate_pages)
|
| 1045 |
+
year = prompt_int_with_validation("Year: ", validate_year)
|
| 1046 |
+
genre = prompt_choice("Genre:", GENRES)
|
| 1047 |
+
status = prompt_choice("Status:", BOOK_STATUSES)
|
| 1048 |
+
print_books([client.create_book(title, author, pages, year, genre, status)])
|
| 1049 |
+
|
| 1050 |
+
elif choice == LibraryMenuChoice.UPDATE_BOOK:
|
| 1051 |
+
book_id = prompt_book_id("Book ID: ", client)
|
| 1052 |
+
print("Leave fields empty to skip them.")
|
| 1053 |
+
title = prompt_optional_with_validation("Title (empty to skip): ", validate_title)
|
| 1054 |
+
author = prompt_optional_with_validation("Author (empty to skip): ", validate_author)
|
| 1055 |
+
pages = prompt_optional_int_with_validation("Pages (empty to skip): ", validate_pages)
|
| 1056 |
+
year = prompt_optional_int_with_validation("Year (empty to skip): ", validate_year)
|
| 1057 |
+
genre = prompt_optional_choice("Genre:", GENRES)
|
| 1058 |
+
status = prompt_optional_choice("Status:", BOOK_STATUSES)
|
| 1059 |
+
if not any([title, author, pages, year, genre, status]):
|
| 1060 |
+
print("No fields to update.")
|
| 1061 |
+
continue
|
| 1062 |
+
print_books([client.update_book(book_id, title, author, pages, year, genre, status)])
|
| 1063 |
+
|
| 1064 |
+
elif choice == LibraryMenuChoice.DELETE_BOOK:
|
| 1065 |
+
book_id = prompt_book_id("Book ID: ", client)
|
| 1066 |
+
result = client.delete_book(book_id)
|
| 1067 |
+
print(result.get("message") if isinstance(result, dict) else "Deleted.")
|
| 1068 |
+
|
| 1069 |
+
elif choice == LibraryMenuChoice.BORROW_BOOKS:
|
| 1070 |
+
book_ids = prompt_book_ids("Book IDs (comma separated): ", client)
|
| 1071 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1072 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1073 |
+
borrow_date = prompt_date("Borrow date (YYYY-MM-DD)")
|
| 1074 |
+
result = client.borrow_books(book_ids, visitor_id, worker_id, borrow_date)
|
| 1075 |
+
print(result.get("message") if isinstance(result, dict) else result)
|
| 1076 |
+
|
| 1077 |
+
elif choice == LibraryMenuChoice.RETURN_BOOKS:
|
| 1078 |
+
book_ids = prompt_book_ids("Book IDs (comma separated): ", client)
|
| 1079 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1080 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1081 |
+
return_date = prompt_date("Return date (YYYY-MM-DD)")
|
| 1082 |
+
result = client.return_books(book_ids, visitor_id, worker_id, return_date)
|
| 1083 |
+
print(result.get("message") if isinstance(result, dict) else result)
|
| 1084 |
+
|
| 1085 |
+
elif choice == LibraryMenuChoice.DOWNLOAD_BOOK:
|
| 1086 |
+
book_id = prompt_book_id("Book ID: ", client)
|
| 1087 |
+
content = client.download_book(book_id)
|
| 1088 |
+
filepath = save_to_file("books", book_id, content)
|
| 1089 |
+
print(f"File saved to: {filepath}")
|
| 1090 |
+
|
| 1091 |
+
elif choice == LibraryMenuChoice.LIST_VISITORS:
|
| 1092 |
+
print_visitors(client.list_visitors())
|
| 1093 |
+
|
| 1094 |
+
elif choice == LibraryMenuChoice.VIEW_VISITOR:
|
| 1095 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1096 |
+
visitor = client.get_visitor(visitor_id)
|
| 1097 |
+
print_visitors([visitor])
|
| 1098 |
+
current = visitor.get("currentBooks") or []
|
| 1099 |
+
history = visitor.get("history") or []
|
| 1100 |
+
if current:
|
| 1101 |
+
print("\nCurrent books:")
|
| 1102 |
+
print_books(current)
|
| 1103 |
+
if history:
|
| 1104 |
+
print("\nHistory:")
|
| 1105 |
+
print_books(history)
|
| 1106 |
+
|
| 1107 |
+
elif choice == LibraryMenuChoice.CREATE_VISITOR:
|
| 1108 |
+
name = prompt_with_validation("Name: ", lambda v: validate_name(v, "Name"))
|
| 1109 |
+
surname = prompt_with_validation("Surname: ", lambda v: validate_name(v, "Surname"))
|
| 1110 |
+
print_visitors([client.create_visitor(name, surname)])
|
| 1111 |
+
|
| 1112 |
+
elif choice == LibraryMenuChoice.UPDATE_VISITOR:
|
| 1113 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1114 |
+
print("Leave fields empty to skip them.")
|
| 1115 |
+
name = prompt_optional_with_validation("Name (empty to skip): ", lambda v: validate_name(v, "Name"))
|
| 1116 |
+
surname = prompt_optional_with_validation("Surname (empty to skip): ", lambda v: validate_name(v, "Surname"))
|
| 1117 |
+
if not name and not surname:
|
| 1118 |
+
print("No fields to update.")
|
| 1119 |
+
continue
|
| 1120 |
+
print_visitors([client.update_visitor(visitor_id, name, surname)])
|
| 1121 |
+
|
| 1122 |
+
elif choice == LibraryMenuChoice.DELETE_VISITOR:
|
| 1123 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1124 |
+
client.delete_visitor(visitor_id)
|
| 1125 |
+
print("Visitor deleted.")
|
| 1126 |
+
|
| 1127 |
+
elif choice == LibraryMenuChoice.DOWNLOAD_VISITOR:
|
| 1128 |
+
visitor_id = prompt_visitor_id("Visitor ID: ", client)
|
| 1129 |
+
content = client.download_visitor(visitor_id)
|
| 1130 |
+
filepath = save_to_file("visitors", visitor_id, content)
|
| 1131 |
+
print(f"File saved to: {filepath}")
|
| 1132 |
+
|
| 1133 |
+
elif choice == LibraryMenuChoice.LIST_WORKERS:
|
| 1134 |
+
print_workers(client.list_workers())
|
| 1135 |
+
|
| 1136 |
+
elif choice == LibraryMenuChoice.VIEW_WORKER:
|
| 1137 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1138 |
+
worker = client.get_worker(worker_id)
|
| 1139 |
+
print_workers([worker])
|
| 1140 |
+
issued = worker.get("issuedBooks") or []
|
| 1141 |
+
if issued:
|
| 1142 |
+
print("\nIssued books:")
|
| 1143 |
+
print_books(issued)
|
| 1144 |
+
|
| 1145 |
+
elif choice == LibraryMenuChoice.CREATE_WORKER:
|
| 1146 |
+
name = prompt_with_validation("Name: ", lambda v: validate_name(v, "Name"))
|
| 1147 |
+
surname = prompt_with_validation("Surname: ", lambda v: validate_name(v, "Surname"))
|
| 1148 |
+
experience = prompt_int_with_validation("Experience (years): ", validate_experience)
|
| 1149 |
+
work_days = prompt_multi_choice("Work days:", WORK_DAYS)
|
| 1150 |
+
print_workers([client.create_worker(name, surname, experience, work_days)])
|
| 1151 |
+
|
| 1152 |
+
elif choice == LibraryMenuChoice.UPDATE_WORKER:
|
| 1153 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1154 |
+
print("Leave fields empty to skip them.")
|
| 1155 |
+
name = prompt_optional_with_validation("Name (empty to skip): ", lambda v: validate_name(v, "Name"))
|
| 1156 |
+
surname = prompt_optional_with_validation("Surname (empty to skip): ", lambda v: validate_name(v, "Surname"))
|
| 1157 |
+
experience = prompt_optional_int_with_validation("Experience (empty to skip): ", validate_experience)
|
| 1158 |
+
work_days = prompt_optional_multi_choice("Work days:", WORK_DAYS)
|
| 1159 |
+
if not any([name, surname, experience is not None, work_days]):
|
| 1160 |
+
print("No fields to update.")
|
| 1161 |
+
continue
|
| 1162 |
+
print_workers([client.update_worker(worker_id, name, surname, experience, work_days)])
|
| 1163 |
+
|
| 1164 |
+
elif choice == LibraryMenuChoice.DELETE_WORKER:
|
| 1165 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1166 |
+
client.delete_worker(worker_id)
|
| 1167 |
+
print("Worker deleted.")
|
| 1168 |
+
|
| 1169 |
+
elif choice == LibraryMenuChoice.WORKERS_BY_DAYS:
|
| 1170 |
+
work_days = prompt_multi_choice("Select work days:", WORK_DAYS)
|
| 1171 |
+
print_workers(client.workers_by_days(work_days))
|
| 1172 |
+
|
| 1173 |
+
elif choice == LibraryMenuChoice.DOWNLOAD_WORKER:
|
| 1174 |
+
worker_id = prompt_worker_id("Worker ID: ", client)
|
| 1175 |
+
content = client.download_worker(worker_id)
|
| 1176 |
+
filepath = save_to_file("workers", worker_id, content)
|
| 1177 |
+
print(f"File saved to: {filepath}")
|
| 1178 |
+
|
| 1179 |
+
except (RuntimeError, httpx.HTTPError, ValueError) as error:
|
| 1180 |
+
print(f"Error: {error}")
|
| 1181 |
+
finally:
|
| 1182 |
+
client.close()
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
def main():
|
| 1186 |
+
try:
|
| 1187 |
+
while True:
|
| 1188 |
+
server_choice = server_selection_menu()
|
| 1189 |
+
if server_choice is None:
|
| 1190 |
+
print("Invalid option. Please try again.")
|
| 1191 |
+
continue
|
| 1192 |
+
if server_choice == 0:
|
| 1193 |
+
print("Goodbye!")
|
| 1194 |
+
break
|
| 1195 |
+
elif server_choice == 1:
|
| 1196 |
+
run_forum_client()
|
| 1197 |
+
elif server_choice == 2:
|
| 1198 |
+
run_library_client()
|
| 1199 |
+
except KeyboardInterrupt:
|
| 1200 |
+
print("\nInterrupted. Goodbye!")
|
| 1201 |
+
|
| 1202 |
+
|
| 1203 |
+
if __name__ == "__main__":
|
| 1204 |
+
main()
|
| 1205 |
+
|