brestok commited on
Commit
a08f988
·
1 Parent(s): 69f2337
.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, default_table=TABLE_TOPICS),
53
  order_in_topic=int(order_in_topic),
54
- participant_id=Link.from_raw(participant_raw, default_table=TABLE_PARTICIPANTS),
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: Link
12
- participant_id: Link
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(FileManager(MESSAGES_FILE), FileManager(TOPICS_FILE), FileManager(PARTICIPANTS_FILE))
 
 
25
  participant_service = ParticipantService(FileManager(PARTICIPANTS_FILE))
26
  topic_file_manager = FileManager(TOPICS_FILE)
27
 
28
 
29
- def _resolve_participant_name(participant_id: Link, participants: List[Participant]) -> str:
30
- for participant in participants:
31
- if participant.id == participant_id.value:
32
- return f"{participant.first_name} {participant.last_name}".strip()
33
- return UNKNOWN_PARTICIPANT_NAME
34
-
35
-
36
- def _resolve_topic_title(topic_id: Link, topics: List[Topic]) -> str:
37
- for topic in topics:
38
- if topic.id == topic_id.value:
39
- return topic.title
40
- return UNKNOWN_TOPIC_TITLE
 
 
 
 
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(message: Message, participants: List[Participant], topics: List[Topic]) -> MessageResponse:
 
 
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=_resolve_participant_name(message.participant_id, participants),
64
- topic_title=_resolve_topic_title(message.topic_id, topics),
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 = Link(table=TABLE_TOPICS, value=topic_id)
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[Link]:
39
  if not raw:
40
  return []
41
- links: List[Link] = []
42
  for value in raw.split(cls.INNER_SEPARATOR):
43
  if not value:
44
  continue
45
- links.append(Link.from_raw(value, default_table=TABLE_PARTICIPANTS))
46
  return links
47
 
48
  @classmethod
@@ -59,14 +59,14 @@ class TopicLineParser:
59
  continue
60
  messages.append(
61
  TopicMessage(
62
- participant_id=Link.from_raw(participant_raw, default_table=TABLE_PARTICIPANTS),
63
  content=cls._decode_message(encoded_content),
64
  )
65
  )
66
  return messages
67
 
68
  @classmethod
69
- def _serialize_participants(cls, participants: List[Link]) -> str:
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 Link
16
 
17
 
18
  class TopicMessageBase(BaseModel):
19
- participant_id: Link
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[Link] = Field(default_factory=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[Link] = Field(default_factory=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: Link
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=Link(table=TABLE_TOPICS, value=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=Link(table=TABLE_TOPICS, value=resolved_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 = Link(table=TABLE_TOPICS, value=topic_id)
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(Link(table=TABLE_TOPICS, value=topic.id))
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 = Link(table=TABLE_TOPICS, value=topic_id)
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
- return [line.rstrip(NEWLINE) for line in file if line.strip()]
 
 
 
 
 
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, default_table: str | None = None) -> "Link":
27
  text = raw.strip()
28
  if not text:
29
  raise ValueError("Cannot parse empty link value")
30
  normalized = text.lstrip("/")
31
- if "/" in normalized:
32
- table, value = normalized.split("/", 1)
33
- else:
34
- if default_table is None:
35
- raise ValueError("Link table is missing")
36
- table, value = default_table, normalized
37
- return cls(table=table, value=UUID(value))
 
 
 
 
 
 
 
 
 
 
 
 
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
- 1da2f311-7fbb-4528-8710-c169bb8855d0|523f5745-ebde-4556-8e38-6a7ed95baf08|1|0cb6d70f-fd72-477f-863f-33b3f6932f90|2025-12-09T15:30:54.854152|SGVsbG8gd29ybGQ=
 
 
1
+ 47514c4b5247514c4a5247514c4752475150505247514b4f524951484747524b514b4c524b51484748524a5150505247514b4c5247514c495247514c4d524b5148474752484751504f5247514b505247514c4c5248514b4c524a514b5052484d514b50524b514b4f5248495148474852484f514b4f52484d5148494b5247514b4e52475148484d52475148484852475148484952475148474c524b5148484c52494b514c4a5247514847495247514c4e5247514847475247514c485249514b50524c514b4c524751504e52475148474852484d514c49524847514c49524a48515050524a4d514c4e524a4f514c48524848514b4c524a514b4f524849514c4c524a49514c49524c515050524849514b50524a4951504f52475148494b52484c5148494b52494b51484849524a4e5148484b52494c5148474c524b5148474c52494e51504e52475148484752494c5148484c52494b514b505248514c4c524951484748524b514c49524a49514b4c524a50514b50524a4d514c495249514c4d524a4e514b4c524a4e515050524951504e52484751484748524751504f524a4a514b5052484851484749524848514c4d524c514847485248505148494b524b4c514c475249514b4c52484c514c47524847514b505249514f4b52484c514b505247514c4f524e4c514c4f524848514c495247514b4d524d4e514b4f524a49514c4d5248495148494b5247514f505247514f4e5247514f49524751484949524a4e514c47524751484947524751484750524a4e514848475247514e4f52475148474e525048514f4e52475148474f52475148484a524751504752494f514d4d52475148484f524b514e48524847495148494952484748514f495248474a515047524c4f514d4d5248474d51504e525049514e4752484747515050524a5148484952504e51504f524a5148474f52484747514f505247514f4f5247514e4e52475148474a524847485150475248484b514c475248474b5148474e524b514c475247514d505248494851504752504e514f4952504b514f50525049514f505247514d48
2
+ 48364d4c3748364d4f37483651503748364d4e3748364c50374836494848374836515137483649484a3748364c4d374b364d4c374836494849374e364c4d3748364d4a374836514f374949364d4f375136514f37494d364d483751364d4f3749493651503748364d48374a36515137494c364d4e3749364d4a374d3649484a374836494a4c3748364c4f37483649494e37483649494937483649494a37483649484d374f3649494d374a4e364d493750364d4d374c364c50374d365150374949364c4d374b364d4e374d364d4b3751364d4a374a483651513748364d4b37495036494849374949364d4a3751365150374c49364d4b37494c36494849374d364d4d37494c364d48374a48364d4a374f36494a4c3748364c51374a4d364c4f374a5136514f37483649494c374a4f3649484d374f3649484d374d4b36494948374a4f3649494d374a4e364d48374a4836494849374a36494848374a364c503748364d4d3751364d4937494b364d4f374e4b364c4d37494b364d4b3749364948493751364d4e374949364d4b374c49364c4d374c50365150374c5036494848374c49364d4c374d49364d49374a483649484a374a4d364d48374d364d48374f49364c51374a48364c4d374d49364d4c374836504c374a48364d493748364d50374d49364d4b37504c364d4b374e4b364c4e374e4b364d4e374c49364d4d37505036494a4c37483651483748364d49374836504d3748364e49
data/participants.txt CHANGED
@@ -1,7 +1,2 @@
1
- a4a2a13c-7aa3-4735-91c3-58a8c206a8ff|Maksim|Sh|string|2025-11-26T06:05:38.678099|5.0
2
- b7e14c6d-9048-4a7b-9420-48c0136ed913|DSIUfdh ihu|asdljfh|fasidjfosdiaj|2025-11-26T06:13:11.928647|10.0
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
- 3d67d49b-d2ad-4796-84ce-5765e636f71e|Hello|poaiujdfposaj poadsjjfdsaoifjop adsijfidosaj|2025-11-26T06:13:42.369699|a4a2a13c-7aa3-4735-91c3-58a8c206a8ff,b7e14c6d-9048-4a7b-9420-48c0136ed913|b7e14c6d-9048-4a7b-9420-48c0136ed913^SGk=;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^SGVsbG8=;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^VGhpcyBpcyBhIG5vcm1hbCB0ZXN0IG1lc3NhZ2U=;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^SGVsbG8gZnJvbSBjbGllbnQh;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^bHNsZGZob2lkc2F1aCBmb2lkc2F1aCBmb2lhc2R1aGZvaWQgc2F1aGZk
2
- 5e497fa8-4983-497b-a2ef-e214181b1fc8|Hello|poaiujdfposaj poadsjjfdsaoifjop adsijfidosaj|2025-11-26T07:36:42.494103|a4a2a13c-7aa3-4735-91c3-58a8c206a8ff,b7e14c6d-9048-4a7b-9420-48c0136ed913|b7e14c6d-9048-4a7b-9420-48c0136ed913^SGk=;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^SGVsbG8=;41dc9c33-fc1f-4f43-b640-4d7804ed8bfc^TXkgbmFtZSBpcyBNYWtzaW0=;a4a2a13c-7aa3-4735-91c3-58a8c206a8ff^SGkgSGkgSGk=
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
+