brestok commited on
Commit
69f2337
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
.idea/.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
6
+ # Datasource local storage ignored files
7
+ /dataSources/
8
+ /dataSources.local.xml
.idea/inspectionProfiles/Project_Default.xml ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <profile version="1.0">
3
+ <option name="myName" value="Project Default" />
4
+ <inspection_tool class="DuplicatedCode" enabled="true" level="WEAK WARNING" enabled_by_default="true">
5
+ <Languages>
6
+ <language minSize="74" name="Python" />
7
+ </Languages>
8
+ </inspection_tool>
9
+ <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
10
+ <inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
11
+ <option name="ignoredPackages">
12
+ <value>
13
+ <list size="143">
14
+ <item index="0" class="java.lang.String" itemvalue="motor" />
15
+ <item index="1" class="java.lang.String" itemvalue="rsa" />
16
+ <item index="2" class="java.lang.String" itemvalue="pybit" />
17
+ <item index="3" class="java.lang.String" itemvalue="PyYAML" />
18
+ <item index="4" class="java.lang.String" itemvalue="cffi" />
19
+ <item index="5" class="java.lang.String" itemvalue="marshmallow" />
20
+ <item index="6" class="java.lang.String" itemvalue="pyasn1" />
21
+ <item index="7" class="java.lang.String" itemvalue="requests" />
22
+ <item index="8" class="java.lang.String" itemvalue="exceptiongroup" />
23
+ <item index="9" class="java.lang.String" itemvalue="starlette" />
24
+ <item index="10" class="java.lang.String" itemvalue="certifi" />
25
+ <item index="11" class="java.lang.String" itemvalue="anyio" />
26
+ <item index="12" class="java.lang.String" itemvalue="urllib3" />
27
+ <item index="13" class="java.lang.String" itemvalue="uvicorn" />
28
+ <item index="14" class="java.lang.String" itemvalue="python-jose" />
29
+ <item index="15" class="java.lang.String" itemvalue="uvloop" />
30
+ <item index="16" class="java.lang.String" itemvalue="passlib" />
31
+ <item index="17" class="java.lang.String" itemvalue="websockets" />
32
+ <item index="18" class="java.lang.String" itemvalue="annotated-types" />
33
+ <item index="19" class="java.lang.String" itemvalue="watchfiles" />
34
+ <item index="20" class="java.lang.String" itemvalue="dnspython" />
35
+ <item index="21" class="java.lang.String" itemvalue="pydantic" />
36
+ <item index="22" class="java.lang.String" itemvalue="pymongo" />
37
+ <item index="23" class="java.lang.String" itemvalue="ecdsa" />
38
+ <item index="24" class="java.lang.String" itemvalue="packaging" />
39
+ <item index="25" class="java.lang.String" itemvalue="starkbank-ecdsa" />
40
+ <item index="26" class="java.lang.String" itemvalue="pydash" />
41
+ <item index="27" class="java.lang.String" itemvalue="bcrypt" />
42
+ <item index="28" class="java.lang.String" itemvalue="fastapi" />
43
+ <item index="29" class="java.lang.String" itemvalue="pydantic_core" />
44
+ <item index="30" class="java.lang.String" itemvalue="python-http-client" />
45
+ <item index="31" class="java.lang.String" itemvalue="email_validator" />
46
+ <item index="32" class="java.lang.String" itemvalue="typing_extensions" />
47
+ <item index="33" class="java.lang.String" itemvalue="pycryptodome" />
48
+ <item index="34" class="java.lang.String" itemvalue="pyee" />
49
+ <item index="35" class="java.lang.String" itemvalue="azure-identity" />
50
+ <item index="36" class="java.lang.String" itemvalue="greenlet" />
51
+ <item index="37" class="java.lang.String" itemvalue="playwright" />
52
+ <item index="38" class="java.lang.String" itemvalue="pyppeteer" />
53
+ <item index="39" class="java.lang.String" itemvalue="SQLAlchemy" />
54
+ <item index="40" class="java.lang.String" itemvalue="pyarrow" />
55
+ <item index="41" class="java.lang.String" itemvalue="protobuf" />
56
+ <item index="42" class="java.lang.String" itemvalue="tornado" />
57
+ <item index="43" class="java.lang.String" itemvalue="solders" />
58
+ <item index="44" class="java.lang.String" itemvalue="rich" />
59
+ <item index="45" class="java.lang.String" itemvalue="numpy" />
60
+ <item index="46" class="java.lang.String" itemvalue="Jinja2" />
61
+ <item index="47" class="java.lang.String" itemvalue="DateTime" />
62
+ <item index="48" class="java.lang.String" itemvalue="attrs" />
63
+ <item index="49" class="java.lang.String" itemvalue="altair" />
64
+ <item index="50" class="java.lang.String" itemvalue="pandas" />
65
+ <item index="51" class="java.lang.String" itemvalue="Pygments" />
66
+ <item index="52" class="java.lang.String" itemvalue="pip" />
67
+ <item index="53" class="java.lang.String" itemvalue="pillow" />
68
+ <item index="54" class="java.lang.String" itemvalue="httpx" />
69
+ <item index="55" class="java.lang.String" itemvalue="boto3" />
70
+ <item index="56" class="java.lang.String" itemvalue="six" />
71
+ <item index="57" class="java.lang.String" itemvalue="botocore" />
72
+ <item index="58" class="java.lang.String" itemvalue="huggingface-hub" />
73
+ <item index="59" class="java.lang.String" itemvalue="nvidia-cuda-cupti-cu12" />
74
+ <item index="60" class="java.lang.String" itemvalue="nvidia-cufft-cu12" />
75
+ <item index="61" class="java.lang.String" itemvalue="python-dateutil" />
76
+ <item index="62" class="java.lang.String" itemvalue="python-dotenv" />
77
+ <item index="63" class="java.lang.String" itemvalue="MarkupSafe" />
78
+ <item index="64" class="java.lang.String" itemvalue="pycparser" />
79
+ <item index="65" class="java.lang.String" itemvalue="frozenlist" />
80
+ <item index="66" class="java.lang.String" itemvalue="fsspec" />
81
+ <item index="67" class="java.lang.String" itemvalue="nvidia-cusolver-cu12" />
82
+ <item index="68" class="java.lang.String" itemvalue="nvidia-curand-cu12" />
83
+ <item index="69" class="java.lang.String" itemvalue="filelock" />
84
+ <item index="70" class="java.lang.String" itemvalue="safetensors" />
85
+ <item index="71" class="java.lang.String" itemvalue="sentencepiece" />
86
+ <item index="72" class="java.lang.String" itemvalue="multiprocess" />
87
+ <item index="73" class="java.lang.String" itemvalue="pyarrow-hotfix" />
88
+ <item index="74" class="java.lang.String" itemvalue="nvidia-cuda-runtime-cu12" />
89
+ <item index="75" class="java.lang.String" itemvalue="sympy" />
90
+ <item index="76" class="java.lang.String" itemvalue="xxhash" />
91
+ <item index="77" class="java.lang.String" itemvalue="beautifulsoup4" />
92
+ <item index="78" class="java.lang.String" itemvalue="tokenizers" />
93
+ <item index="79" class="java.lang.String" itemvalue="nvidia-cuda-nvrtc-cu12" />
94
+ <item index="80" class="java.lang.String" itemvalue="transformers" />
95
+ <item index="81" class="java.lang.String" itemvalue="triton" />
96
+ <item index="82" class="java.lang.String" itemvalue="cryptography" />
97
+ <item index="83" class="java.lang.String" itemvalue="openai" />
98
+ <item index="84" class="java.lang.String" itemvalue="nvidia-cublas-cu12" />
99
+ <item index="85" class="java.lang.String" itemvalue="regex" />
100
+ <item index="86" class="java.lang.String" itemvalue="nvidia-nvtx-cu12" />
101
+ <item index="87" class="java.lang.String" itemvalue="PyMySQL" />
102
+ <item index="88" class="java.lang.String" itemvalue="Mako" />
103
+ <item index="89" class="java.lang.String" itemvalue="evaluate" />
104
+ <item index="90" class="java.lang.String" itemvalue="httpcore" />
105
+ <item index="91" class="java.lang.String" itemvalue="idna" />
106
+ <item index="92" class="java.lang.String" itemvalue="environs" />
107
+ <item index="93" class="java.lang.String" itemvalue="networkx" />
108
+ <item index="94" class="java.lang.String" itemvalue="nvidia-nvjitlink-cu12" />
109
+ <item index="95" class="java.lang.String" itemvalue="nvidia-cusparse-cu12" />
110
+ <item index="96" class="java.lang.String" itemvalue="datasets" />
111
+ <item index="97" class="java.lang.String" itemvalue="nvidia-nccl-cu12" />
112
+ <item index="98" class="java.lang.String" itemvalue="sniffio" />
113
+ <item index="99" class="java.lang.String" itemvalue="aiomysql" />
114
+ <item index="100" class="java.lang.String" itemvalue="sqladmin" />
115
+ <item index="101" class="java.lang.String" itemvalue="itsdangerous" />
116
+ <item index="102" class="java.lang.String" itemvalue="faiss-gpu" />
117
+ <item index="103" class="java.lang.String" itemvalue="aiocron" />
118
+ <item index="104" class="java.lang.String" itemvalue="tzdata" />
119
+ <item index="105" class="java.lang.String" itemvalue="dill" />
120
+ <item index="106" class="java.lang.String" itemvalue="nvidia-cudnn-cu12" />
121
+ <item index="107" class="java.lang.String" itemvalue="torch" />
122
+ <item index="108" class="java.lang.String" itemvalue="et-xmlfile" />
123
+ <item index="109" class="java.lang.String" itemvalue="python-multipart" />
124
+ <item index="110" class="java.lang.String" itemvalue="tqdm" />
125
+ <item index="111" class="java.lang.String" itemvalue="aiohttp" />
126
+ <item index="112" class="java.lang.String" itemvalue="multidict" />
127
+ <item index="113" class="java.lang.String" itemvalue="responses" />
128
+ <item index="114" class="java.lang.String" itemvalue="pytz" />
129
+ <item index="115" class="java.lang.String" itemvalue="openpyxl" />
130
+ <item index="116" class="java.lang.String" itemvalue="tomli" />
131
+ <item index="117" class="java.lang.String" itemvalue="asyncio" />
132
+ <item index="118" class="java.lang.String" itemvalue="colorama" />
133
+ <item index="119" class="java.lang.String" itemvalue="zope.interface" />
134
+ <item index="120" class="java.lang.String" itemvalue="setuptools" />
135
+ <item index="121" class="java.lang.String" itemvalue="click" />
136
+ <item index="122" class="java.lang.String" itemvalue="vellum-ai" />
137
+ <item index="123" class="java.lang.String" itemvalue="Pillow" />
138
+ <item index="124" class="java.lang.String" itemvalue="langchain-community" />
139
+ <item index="125" class="java.lang.String" itemvalue="langchain-openai" />
140
+ <item index="126" class="java.lang.String" itemvalue="langchain" />
141
+ <item index="127" class="java.lang.String" itemvalue="langchain-core" />
142
+ <item index="128" class="java.lang.String" itemvalue="langchain-text-splitters" />
143
+ <item index="129" class="java.lang.String" itemvalue="langsmith" />
144
+ <item index="130" class="java.lang.String" itemvalue="selenium" />
145
+ <item index="131" class="java.lang.String" itemvalue="selectolax" />
146
+ <item index="132" class="java.lang.String" itemvalue="apscheduler" />
147
+ <item index="133" class="java.lang.String" itemvalue="fake-useragent" />
148
+ <item index="134" class="java.lang.String" itemvalue="aiofiles" />
149
+ <item index="135" class="java.lang.String" itemvalue="lxml" />
150
+ <item index="136" class="java.lang.String" itemvalue="html2text" />
151
+ <item index="137" class="java.lang.String" itemvalue="linkedin_api" />
152
+ <item index="138" class="java.lang.String" itemvalue="pydantic-settings" />
153
+ <item index="139" class="java.lang.String" itemvalue="langchain-mongodb" />
154
+ <item index="140" class="java.lang.String" itemvalue="jiter" />
155
+ <item index="141" class="java.lang.String" itemvalue="langchainhub" />
156
+ <item index="142" class="java.lang.String" itemvalue="langchain-experimental" />
157
+ </list>
158
+ </value>
159
+ </option>
160
+ </inspection_tool>
161
+ <inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
162
+ <option name="ignoredErrors">
163
+ <list>
164
+ <option value="N802" />
165
+ </list>
166
+ </option>
167
+ </inspection_tool>
168
+ </profile>
169
+ </component>
.idea/inspectionProfiles/profiles_settings.xml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </component>
.idea/lab4.iml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="PYTHON_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
6
+ </content>
7
+ <orderEntry type="inheritedJdk" />
8
+ <orderEntry type="sourceFolder" forTests="false" />
9
+ </component>
10
+ </module>
.idea/misc.xml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="Black">
4
+ <option name="sdkName" value="Python 3.12 (lab4)" />
5
+ </component>
6
+ <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (lab4)" project-jdk-type="Python SDK" />
7
+ </project>
.idea/modules.xml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/lab4.iml" filepath="$PROJECT_DIR$/.idea/lab4.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
.vscode/launch.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Python Debugger: FastAPI",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "module": "uvicorn",
9
+ "args": [
10
+ "main:app",
11
+ "--reload"
12
+ ],
13
+ "jinja": true,
14
+ "preLaunchTask": "kill_old_fastapi_process"
15
+ }
16
+ ]
17
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files.exclude": {
3
+ "**/__pycache__": true,
4
+ "**/.git": true,
5
+ "**/.DS_Store": true,
6
+ "**/.idea": true,
7
+ "**/.github": true,
8
+ "**/.venv": true,
9
+ "**/.history": true
10
+ },
11
+ "search.exclude": {
12
+ "**/.github": true,
13
+ "**/.vscode": true,
14
+ "**/.DS_Store": true,
15
+ "**/.idea": true,
16
+ "**/.git": true,
17
+ "**/.venv": true,
18
+ "**/.history": true
19
+ },
20
+ }
.vscode/tasks.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "label": "kill_old_fastapi_process",
6
+ "type": "shell",
7
+ "command": "lsof -ti :8000 | xargs kill -9",
8
+ "problemMatcher": [],
9
+ "group": "build",
10
+ "presentation": {
11
+ "reveal": "never"
12
+ }
13
+ }
14
+ ]
15
+ }
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12.7
2
+
3
+ RUN apt-get update && apt-get install -y libgl1 && rm -rf /var/lib/apt/lists/*
4
+
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV PATH="/home/user/.local/bin:$PATH"
8
+
9
+ WORKDIR /app
10
+
11
+ COPY --chown=user ./requirements.txt requirements.txt
12
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
13
+
14
+ COPY --chown=user . /app
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: lab4
3
+ emoji: 🐢
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
app/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+
3
+ from app.api.messages.views import router as messages_router
4
+ from app.api.participants.views import router as participants_router
5
+ from app.api.topics.views import router as topics_router
6
+ from app.core.config import (
7
+ APP_TITLE,
8
+ PARTICIPANTS_ROUTE_PREFIX,
9
+ PARTICIPANTS_TAG,
10
+ MESSAGES_ROUTE_PREFIX,
11
+ MESSAGES_TAG,
12
+ TOPICS_ROUTE_PREFIX,
13
+ TOPICS_TAG,
14
+ )
15
+
16
+
17
+ def create_app() -> FastAPI:
18
+ app = FastAPI(title=APP_TITLE)
19
+ app.include_router(participants_router, prefix=PARTICIPANTS_ROUTE_PREFIX, tags=[PARTICIPANTS_TAG])
20
+ app.include_router(messages_router, prefix=MESSAGES_ROUTE_PREFIX, tags=[MESSAGES_TAG])
21
+ app.include_router(topics_router, prefix=TOPICS_ROUTE_PREFIX, tags=[TOPICS_TAG])
22
+ return app
23
+
24
+
25
+ __all__ = ["create_app"]
26
+
app/api/messages/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __all__ = []
app/api/messages/parser.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ 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,
9
+ MESSAGE_EXPECTED_PARTS,
10
+ MESSAGE_PARSE_EMPTY_ERROR,
11
+ MESSAGE_PARSE_INVALID_ERROR,
12
+ MESSAGE_SEPARATOR,
13
+ TABLE_PARTICIPANTS,
14
+ TABLE_TOPICS,
15
+ )
16
+ from app.core.link import Link
17
+
18
+
19
+ class MessageLineParser:
20
+ SEPARATOR = MESSAGE_SEPARATOR
21
+ EXPECTED_PARTS = MESSAGE_EXPECTED_PARTS
22
+
23
+ @staticmethod
24
+ def _decode_content(encoded: str) -> str:
25
+ return base64.b64decode(encoded.encode(ENCODING_ASCII)).decode(ENCODING_UTF8)
26
+
27
+ @staticmethod
28
+ def _encode_content(value: str) -> str:
29
+ return base64.b64encode(value.encode(ENCODING_UTF8)).decode(ENCODING_ASCII)
30
+
31
+ @classmethod
32
+ def parse(cls, line: str) -> Message:
33
+ raw = line.strip()
34
+ if not raw:
35
+ raise ValueError(MESSAGE_PARSE_EMPTY_ERROR)
36
+
37
+ parts = raw.split(cls.SEPARATOR)
38
+ if len(parts) != cls.EXPECTED_PARTS:
39
+ raise ValueError(MESSAGE_PARSE_INVALID_ERROR)
40
+
41
+ (
42
+ message_id,
43
+ topic_raw,
44
+ order_in_topic,
45
+ participant_raw,
46
+ created_at_value,
47
+ encoded_content,
48
+ ) = parts
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
+ )
58
+
59
+ @classmethod
60
+ def serialize(cls, message: Message) -> str:
61
+ return cls.SEPARATOR.join(
62
+ [
63
+ str(message.id),
64
+ message.topic_id.as_path(),
65
+ str(message.order_in_topic),
66
+ message.participant_id.as_path(),
67
+ message.created_at.isoformat(),
68
+ cls._encode_content(message.content),
69
+ ]
70
+ )
71
+
app/api/messages/schemas.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
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
+
16
+ class MessageCreate(MessageBase):
17
+ pass
18
+
19
+
20
+ class Message(MessageBase):
21
+ id: UUID
22
+ order_in_topic: int
23
+ created_at: datetime
24
+
25
+
26
+ class MessageResponse(Message):
27
+ participant_name: str
28
+ topic_title: str
app/api/messages/services.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
3
+ from uuid import UUID, uuid4
4
+
5
+ from fastapi import HTTPException, status
6
+
7
+ from app.api.messages.parser import MessageLineParser
8
+ from app.api.messages.schemas import Message, MessageCreate
9
+ from app.api.participants.parser import ParticipantLineParser
10
+ from app.api.participants.schemas import Participant
11
+ from app.api.topics.schemas import Topic
12
+ 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,
19
+ )
20
+ from app.core.file_manager import FileManager
21
+ from app.core.link import Link
22
+
23
+
24
+ class MessageService:
25
+ def __init__(
26
+ self,
27
+ file_manager: FileManager,
28
+ topic_file_manager: FileManager,
29
+ participant_file_manager: FileManager,
30
+ ) -> None:
31
+ self.file_manager = file_manager
32
+ self.topic_file_manager = topic_file_manager
33
+ self.participant_file_manager = participant_file_manager
34
+
35
+ def _check_forbidden_words(self, content: str) -> Optional[str]:
36
+ content_lower = content.lower()
37
+ for word in FORBIDDEN_WORDS:
38
+ if word in content_lower:
39
+ return word
40
+ return None
41
+
42
+ def _load_topics(self) -> List[Topic]:
43
+ from app.api.topics.parser import TopicLineParser
44
+
45
+ topics: List[Topic] = []
46
+ for line in self.topic_file_manager.read_lines():
47
+ try:
48
+ topics.append(TopicLineParser.parse(line))
49
+ except ValueError:
50
+ continue
51
+ return topics
52
+
53
+ def _load_participants(self) -> List[Participant]:
54
+ participants: List[Participant] = []
55
+ for line in self.participant_file_manager.read_lines():
56
+ try:
57
+ participants.append(ParticipantLineParser.parse(line))
58
+ except ValueError:
59
+ continue
60
+ return participants
61
+
62
+ def _get_topic_by_id(self, topic_id: UUID) -> Topic:
63
+ for topic in self._load_topics():
64
+ if topic.id == topic_id:
65
+ return topic
66
+ raise HTTPException(
67
+ status_code=status.HTTP_404_NOT_FOUND,
68
+ detail=TOPIC_NOT_FOUND_MESSAGE,
69
+ )
70
+
71
+ def _get_participant_by_id(self, participant_id: UUID) -> Participant:
72
+ for participant in self._load_participants():
73
+ if participant.id == participant_id:
74
+ return participant
75
+ raise HTTPException(
76
+ status_code=status.HTTP_404_NOT_FOUND,
77
+ detail=PARTICIPANT_NOT_FOUND_MESSAGE,
78
+ )
79
+
80
+ def _resolve_id(self, link: Link, expected_table: str) -> UUID:
81
+ return link.resolve({expected_table: lambda value: value})
82
+
83
+ def get_topic(self, topic_link: Link) -> Topic:
84
+ topic_id = self._resolve_id(topic_link, TABLE_TOPICS)
85
+ return self._get_topic_by_id(topic_id)
86
+
87
+ def get_participant(self, participant_link: Link) -> Participant:
88
+ participant_id = self._resolve_id(participant_link, TABLE_PARTICIPANTS)
89
+ return self._get_participant_by_id(participant_id)
90
+
91
+ def list_messages(self) -> List[Message]:
92
+ messages: List[Message] = []
93
+ for line in self.file_manager.read_lines():
94
+ try:
95
+ messages.append(MessageLineParser.parse(line))
96
+ except ValueError:
97
+ continue
98
+ return messages
99
+
100
+ def list_messages_by_topic(self, topic_id: Link) -> List[Message]:
101
+ topic_value = self._resolve_id(topic_id, TABLE_TOPICS)
102
+ messages = [message for message in self.list_messages() if message.topic_id.value == topic_value]
103
+ return sorted(messages, key=lambda message: message.order_in_topic)
104
+
105
+ def create_message(self, payload: MessageCreate) -> Message:
106
+ forbidden_word = self._check_forbidden_words(payload.content)
107
+ if forbidden_word:
108
+ raise HTTPException(
109
+ status_code=status.HTTP_400_BAD_REQUEST,
110
+ detail=f"{FORBIDDEN_WORDS_MESSAGE}: '{forbidden_word}'",
111
+ )
112
+
113
+ self.get_topic(payload.topic_id)
114
+ self.get_participant(payload.participant_id)
115
+
116
+ next_order = len(self.list_messages_by_topic(payload.topic_id)) + 1
117
+ message = Message(
118
+ id=uuid4(),
119
+ topic_id=payload.topic_id,
120
+ participant_id=payload.participant_id,
121
+ content=payload.content,
122
+ order_in_topic=next_order,
123
+ created_at=datetime.utcnow(),
124
+ )
125
+ serialized = MessageLineParser.serialize(message)
126
+ self.file_manager.append_line(serialized)
127
+ return message
128
+
app/api/messages/views.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from app.api.messages.schemas import Message, MessageResponse
7
+ from app.api.messages.services import MessageService
8
+ from app.api.participants.schemas import Participant
9
+ from app.api.participants.services import ParticipantService
10
+ 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]:
44
+ from app.api.topics.parser import TopicLineParser
45
+
46
+ topics: List[Topic] = []
47
+ for line in topic_file_manager.read_lines():
48
+ try:
49
+ topics.append(TopicLineParser.parse(line))
50
+ except ValueError:
51
+ continue
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,
59
+ order_in_topic=message.order_in_topic,
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
+
68
+ @router.get("/", response_model=List[MessageResponse])
69
+ async def list_messages() -> List[MessageResponse]:
70
+ messages = message_service.list_messages()
71
+ participants = participant_service.list_participants()
72
+ topics = _list_topics()
73
+ messages.sort(key=lambda message: (message.topic_id.value, message.order_in_topic))
74
+ return [_message_to_response(message, participants, topics) for message in messages]
75
+
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
+
app/api/participants/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __all__ = []
app/api/participants/parser.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from app.api.participants.schemas import Participant
5
+ from app.core.config import (
6
+ PARTICIPANT_EXPECTED_PARTS,
7
+ PARTICIPANT_PARSE_EMPTY_ERROR,
8
+ PARTICIPANT_PARSE_INVALID_ERROR,
9
+ PARTICIPANT_SEPARATOR,
10
+ )
11
+
12
+
13
+ class ParticipantLineParser:
14
+ SEPARATOR = PARTICIPANT_SEPARATOR
15
+ EXPECTED_PARTS = PARTICIPANT_EXPECTED_PARTS
16
+
17
+ @classmethod
18
+ def parse(cls, line: str) -> Participant:
19
+ raw = line.strip()
20
+ if not raw:
21
+ raise ValueError(PARTICIPANT_PARSE_EMPTY_ERROR)
22
+
23
+ parts = raw.split(cls.SEPARATOR)
24
+ if len(parts) != cls.EXPECTED_PARTS:
25
+ raise ValueError(PARTICIPANT_PARSE_INVALID_ERROR)
26
+
27
+ participant_id, first_name, last_name, nickname, registered_at, activity_rating = parts
28
+ return Participant(
29
+ id=UUID(participant_id),
30
+ first_name=first_name,
31
+ last_name=last_name,
32
+ nickname=nickname,
33
+ registered_at=datetime.fromisoformat(registered_at),
34
+ activity_rating=float(activity_rating),
35
+ )
36
+
37
+ @classmethod
38
+ def serialize(cls, participant: Participant) -> str:
39
+ return cls.SEPARATOR.join(
40
+ [
41
+ str(participant.id),
42
+ participant.first_name,
43
+ participant.last_name,
44
+ participant.nickname,
45
+ participant.registered_at.isoformat(),
46
+ f"{participant.activity_rating}",
47
+ ]
48
+ )
49
+
50
+
51
+
app/api/participants/schemas.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from uuid import UUID
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from app.core.config import NAME_MAX_LENGTH, NAME_MIN_LENGTH, RATING_MIN_VALUE
7
+
8
+
9
+ class ParticipantBase(BaseModel):
10
+ first_name: str = Field(min_length=NAME_MIN_LENGTH, max_length=NAME_MAX_LENGTH)
11
+ last_name: str = Field(min_length=NAME_MIN_LENGTH, max_length=NAME_MAX_LENGTH)
12
+ nickname: str = Field(min_length=NAME_MIN_LENGTH, max_length=NAME_MAX_LENGTH)
13
+ activity_rating: float = Field(ge=RATING_MIN_VALUE)
14
+
15
+
16
+ class ParticipantCreate(ParticipantBase):
17
+ pass
18
+
19
+
20
+ class Participant(ParticipantBase):
21
+ id: UUID
22
+ registered_at: datetime
23
+
24
+
25
+
app/api/participants/services.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List
3
+ from uuid import UUID, uuid4
4
+
5
+ from fastapi import HTTPException, status
6
+
7
+ from app.api.participants.parser import ParticipantLineParser
8
+ from app.api.participants.schemas import Participant, ParticipantCreate
9
+ from app.core.config import PARTICIPANT_NOT_FOUND_MESSAGE
10
+ from app.core.file_manager import FileManager
11
+
12
+
13
+ class ParticipantService:
14
+ def __init__(self, file_manager: FileManager) -> None:
15
+ self.file_manager = file_manager
16
+
17
+ def list_participants(self) -> List[Participant]:
18
+ participants: List[Participant] = []
19
+ for line in self.file_manager.read_lines():
20
+ try:
21
+ participants.append(ParticipantLineParser.parse(line))
22
+ except ValueError:
23
+ continue
24
+ return participants
25
+
26
+ def get_participant(self, participant_id: UUID) -> Participant:
27
+ for participant in self.list_participants():
28
+ if participant.id == participant_id:
29
+ return participant
30
+ raise HTTPException(
31
+ status_code=status.HTTP_404_NOT_FOUND,
32
+ detail=PARTICIPANT_NOT_FOUND_MESSAGE,
33
+ )
34
+
35
+ def create_participant(self, payload: ParticipantCreate) -> Participant:
36
+ participant = Participant(
37
+ id=uuid4(),
38
+ first_name=payload.first_name,
39
+ last_name=payload.last_name,
40
+ nickname=payload.nickname,
41
+ registered_at=datetime.utcnow(),
42
+ activity_rating=payload.activity_rating,
43
+ )
44
+ serialized = ParticipantLineParser.serialize(participant)
45
+ self.file_manager.append_line(serialized)
46
+ return participant
47
+
48
+
49
+
app/api/participants/views.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from app.api.participants.schemas import Participant, ParticipantCreate
7
+ from app.api.participants.services import ParticipantService
8
+ from app.core.config import HTTP_STATUS_CREATED, PARTICIPANTS_FILE
9
+ from app.core.file_manager import FileManager
10
+
11
+
12
+ router = APIRouter()
13
+ participant_service = ParticipantService(FileManager(PARTICIPANTS_FILE))
14
+
15
+
16
+ @router.get("/", response_model=List[Participant])
17
+ async def list_participants() -> List[Participant]:
18
+ return participant_service.list_participants()
19
+
20
+
21
+ @router.get("/{participant_id}", response_model=Participant)
22
+ async def get_participant(participant_id: UUID) -> Participant:
23
+ return participant_service.get_participant(participant_id)
24
+
25
+
26
+ @router.post("/", response_model=Participant, status_code=HTTP_STATUS_CREATED)
27
+ async def create_participant(payload: ParticipantCreate) -> Participant:
28
+ return participant_service.create_participant(payload)
29
+
30
+
31
+
app/api/topics/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __all__ = []
app/api/topics/parser.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from datetime import datetime
3
+ from typing import List
4
+ from uuid import UUID
5
+
6
+ from app.api.topics.schemas import Topic, TopicMessage
7
+ from app.core.config import (
8
+ ENCODING_ASCII,
9
+ ENCODING_UTF8,
10
+ TABLE_PARTICIPANTS,
11
+ TOPIC_EXPECTED_PARTS,
12
+ TOPIC_INNER_SEPARATOR,
13
+ TOPIC_LIST_SEPARATOR,
14
+ TOPIC_MESSAGE_PART_SEPARATOR,
15
+ TOPIC_PARSE_EMPTY_ERROR,
16
+ TOPIC_PARSE_INVALID_ERROR,
17
+ TOPIC_SEPARATOR,
18
+ )
19
+ from app.core.link import Link
20
+
21
+
22
+ class TopicLineParser:
23
+ SEPARATOR = TOPIC_SEPARATOR
24
+ EXPECTED_PARTS = TOPIC_EXPECTED_PARTS
25
+ LIST_SEPARATOR = TOPIC_LIST_SEPARATOR
26
+ INNER_SEPARATOR = TOPIC_INNER_SEPARATOR
27
+ MESSAGE_PART_SEPARATOR = TOPIC_MESSAGE_PART_SEPARATOR
28
+
29
+ @staticmethod
30
+ def _decode_message(encoded: str) -> str:
31
+ return base64.b64decode(encoded.encode(ENCODING_ASCII)).decode(ENCODING_UTF8)
32
+
33
+ @staticmethod
34
+ def _encode_message(value: str) -> str:
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
49
+ def _parse_messages(cls, raw: str) -> List[TopicMessage]:
50
+ if not raw:
51
+ return []
52
+ messages: List[TopicMessage] = []
53
+ for chunk in raw.split(cls.LIST_SEPARATOR):
54
+ if not chunk:
55
+ continue
56
+ try:
57
+ participant_raw, encoded_content = chunk.split(cls.MESSAGE_PART_SEPARATOR, 1)
58
+ except ValueError:
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
73
+ def _serialize_messages(cls, messages: List[TopicMessage]) -> str:
74
+ return cls.LIST_SEPARATOR.join(
75
+ cls.MESSAGE_PART_SEPARATOR.join(
76
+ [message.participant_id.as_path(), cls._encode_message(message.content)]
77
+ )
78
+ for message in messages
79
+ )
80
+
81
+ @classmethod
82
+ def parse(cls, line: str) -> Topic:
83
+ raw = line.strip()
84
+ if not raw:
85
+ raise ValueError(TOPIC_PARSE_EMPTY_ERROR)
86
+
87
+ parts = raw.split(cls.SEPARATOR)
88
+ if len(parts) != cls.EXPECTED_PARTS:
89
+ raise ValueError(TOPIC_PARSE_INVALID_ERROR)
90
+
91
+ (
92
+ topic_id,
93
+ title,
94
+ description,
95
+ created_at_value,
96
+ participants_raw,
97
+ messages_raw,
98
+ ) = parts
99
+
100
+ participants = cls._parse_participants(participants_raw)
101
+ messages = cls._parse_messages(messages_raw)
102
+
103
+ return Topic(
104
+ id=UUID(topic_id),
105
+ title=title,
106
+ description=description,
107
+ created_at=datetime.fromisoformat(created_at_value),
108
+ participants=participants,
109
+ messages=messages,
110
+ )
111
+
112
+ @classmethod
113
+ def serialize(cls, topic: Topic) -> str:
114
+ return cls.SEPARATOR.join(
115
+ [
116
+ str(topic.id),
117
+ topic.title,
118
+ topic.description,
119
+ topic.created_at.isoformat(),
120
+ cls._serialize_participants(topic.participants),
121
+ cls._serialize_messages(topic.messages),
122
+ ]
123
+ )
124
+
125
+
126
+
app/api/topics/schemas.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List
3
+ from uuid import UUID
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from app.core.config import (
8
+ TOPIC_DESCRIPTION_MAX_LENGTH,
9
+ TOPIC_DESCRIPTION_MIN_LENGTH,
10
+ TOPIC_MESSAGE_MAX_LENGTH,
11
+ TOPIC_MESSAGE_MIN_LENGTH,
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
+
23
+
24
+ class TopicMessageCreate(TopicMessageBase):
25
+ pass
26
+
27
+
28
+ class TopicMessage(TopicMessageBase):
29
+ pass
30
+
31
+
32
+ class TopicMessageResponse(TopicMessageBase):
33
+ participant_name: str
34
+
35
+
36
+ class TopicBase(BaseModel):
37
+ title: str = Field(min_length=TOPIC_TITLE_MIN_LENGTH, max_length=TOPIC_TITLE_MAX_LENGTH)
38
+ description: str = Field(
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):
46
+ messages: List[TopicMessageCreate] = Field(default_factory=list)
47
+
48
+
49
+ class Topic(BaseModel):
50
+ id: UUID
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
+
58
+ class TopicResponse(TopicBase):
59
+ id: UUID
60
+ created_at: datetime
61
+ messages: List[TopicMessageResponse] = Field(default_factory=list)
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)
app/api/topics/services.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Optional
3
+ from uuid import UUID, uuid4
4
+
5
+ from fastapi import HTTPException, status
6
+
7
+ from app.api.messages.schemas import MessageCreate
8
+ from app.api.messages.services import MessageService
9
+ from app.api.topics.parser import TopicLineParser
10
+ from app.api.topics.schemas import AddMessageRequest, Topic, TopicCreate, TopicMessage
11
+ from app.core.config import (
12
+ FORBIDDEN_WORDS,
13
+ FORBIDDEN_WORDS_MESSAGE,
14
+ TABLE_TOPICS,
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:
22
+ def __init__(self, file_manager: FileManager, message_service: MessageService | None = None) -> None:
23
+ self.file_manager = file_manager
24
+ self.message_service = message_service
25
+
26
+ @staticmethod
27
+ def _resolve_topic_id(value: UUID | Link) -> UUID:
28
+ if isinstance(value, UUID):
29
+ return value
30
+ return value.resolve({TABLE_TOPICS: lambda link_value: link_value})
31
+
32
+ def list_topics(self) -> List[Topic]:
33
+ topics: List[Topic] = []
34
+ for line in self.file_manager.read_lines():
35
+ try:
36
+ topics.append(TopicLineParser.parse(line))
37
+ except ValueError:
38
+ continue
39
+ return topics
40
+
41
+ def get_topic(self, topic_id: UUID | Link) -> Topic:
42
+ resolved_id = self._resolve_topic_id(topic_id)
43
+ for topic in self.list_topics():
44
+ if topic.id == resolved_id:
45
+ return topic
46
+ raise HTTPException(
47
+ status_code=status.HTTP_404_NOT_FOUND,
48
+ detail=TOPIC_NOT_FOUND_MESSAGE,
49
+ )
50
+
51
+ def create_topic(self, payload: TopicCreate) -> Topic:
52
+ topic_messages = (
53
+ []
54
+ if self.message_service
55
+ else [
56
+ TopicMessage(
57
+ participant_id=message.participant_id,
58
+ content=message.content,
59
+ )
60
+ for message in payload.messages
61
+ ]
62
+ )
63
+ topic = Topic(
64
+ id=uuid4(),
65
+ title=payload.title,
66
+ description=payload.description,
67
+ created_at=datetime.utcnow(),
68
+ participants=payload.participants,
69
+ messages=topic_messages,
70
+ )
71
+ serialized = TopicLineParser.serialize(topic)
72
+ self.file_manager.append_line(serialized)
73
+ if self.message_service:
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
+ )
81
+ )
82
+ return topic
83
+
84
+ @staticmethod
85
+ def check_forbidden_words(content: str) -> Optional[str]:
86
+ content_lower = content.lower()
87
+ for word in FORBIDDEN_WORDS:
88
+ if word in content_lower:
89
+ return word
90
+ return None
91
+
92
+ def add_message(self, topic_id: UUID | Link, payload: AddMessageRequest) -> Topic:
93
+ forbidden_word = self.check_forbidden_words(payload.content)
94
+ if forbidden_word:
95
+ raise HTTPException(
96
+ status_code=status.HTTP_400_BAD_REQUEST,
97
+ detail=f"{FORBIDDEN_WORDS_MESSAGE}: '{forbidden_word}'",
98
+ )
99
+
100
+ resolved_id = self._resolve_topic_id(topic_id)
101
+
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
+ )
110
+ return self.get_topic(resolved_id)
111
+
112
+ topics = self.list_topics()
113
+ topic_index = None
114
+ for i, topic in enumerate(topics):
115
+ if topic.id == resolved_id:
116
+ topic_index = i
117
+ break
118
+
119
+ if topic_index is None:
120
+ raise HTTPException(
121
+ status_code=status.HTTP_404_NOT_FOUND,
122
+ detail=TOPIC_NOT_FOUND_MESSAGE,
123
+ )
124
+
125
+ new_message = TopicMessage(
126
+ participant_id=payload.participant_id,
127
+ content=payload.content,
128
+ )
129
+ topics[topic_index].messages.append(new_message)
130
+
131
+ lines = [TopicLineParser.serialize(t) for t in topics]
132
+ self.file_manager.write_lines(lines)
133
+
134
+ return topics[topic_index]
app/api/topics/views.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from uuid import UUID
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from app.api.messages.schemas import Message
7
+ from app.api.messages.services import MessageService
8
+ from app.api.participants.schemas import Participant
9
+ from app.api.participants.services import ParticipantService
10
+ from app.api.topics.schemas import (
11
+ AddMessageRequest,
12
+ Topic,
13
+ TopicCreate,
14
+ TopicMessageResponse,
15
+ TopicResponse,
16
+ )
17
+ from app.api.topics.services import TopicService
18
+ 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()
31
+ message_service = MessageService(FileManager(MESSAGES_FILE), FileManager(TOPICS_FILE), FileManager(PARTICIPANTS_FILE))
32
+ topic_service = TopicService(FileManager(TOPICS_FILE), message_service=message_service)
33
+ participant_service = ParticipantService(FileManager(PARTICIPANTS_FILE))
34
+
35
+
36
+ def _resolve_participant_name(participant_id: Link, participants: List[Participant]) -> str:
37
+ for participant in participants:
38
+ if participant.id == participant_id.value:
39
+ return f"{participant.first_name} {participant.last_name}".strip()
40
+ return UNKNOWN_PARTICIPANT_NAME
41
+
42
+
43
+ def _topic_to_response(topic: Topic, participants: List[Participant], messages: List[Message]) -> TopicResponse:
44
+ message_responses = []
45
+ for message in sorted(messages, key=lambda item: item.order_in_topic):
46
+ message_responses.append(
47
+ TopicMessageResponse(
48
+ participant_id=message.participant_id,
49
+ content=message.content,
50
+ participant_name=_resolve_participant_name(message.participant_id, participants),
51
+ order_in_topic=message.order_in_topic,
52
+ )
53
+ )
54
+ return TopicResponse(
55
+ id=topic.id,
56
+ title=topic.title,
57
+ description=topic.description,
58
+ created_at=topic.created_at,
59
+ participants=topic.participants,
60
+ messages=message_responses,
61
+ )
62
+
63
+
64
+ @router.get("/", response_model=List[TopicResponse])
65
+ async def list_topics() -> List[TopicResponse]:
66
+ topics = topic_service.list_topics()
67
+ participants = participant_service.list_participants()
68
+ messages = message_service.list_messages()
69
+ messages_by_topic: dict[UUID, List[Message]] = {}
70
+ for message in messages:
71
+ messages_by_topic.setdefault(message.topic_id.value, []).append(message)
72
+ return [
73
+ _topic_to_response(topic, participants, messages_by_topic.get(topic.id, []))
74
+ for topic in topics
75
+ ]
76
+
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)
84
+ return _topic_to_response(topic, participants, topic_messages)
85
+
86
+
87
+ @router.post("/", response_model=TopicResponse, status_code=HTTP_STATUS_CREATED)
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)
app/core/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+
2
+
3
+
4
+
app/core/config.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ BASE_DIR = Path(__file__).resolve().parent.parent.parent
4
+ DATA_DIR = BASE_DIR / "data"
5
+ TABLE_PARTICIPANTS = "participants"
6
+ TABLE_TOPICS = "topics"
7
+ TABLE_MESSAGES = "messages"
8
+
9
+ PARTICIPANTS_FILE = DATA_DIR / "participants.txt"
10
+ TOPICS_FILE = DATA_DIR / "topics.txt"
11
+ MESSAGES_FILE = DATA_DIR / "messages.txt"
12
+
13
+ PARTICIPANT_SEPARATOR = "|"
14
+ PARTICIPANT_EXPECTED_PARTS = 6
15
+
16
+ TOPIC_SEPARATOR = "|"
17
+ TOPIC_EXPECTED_PARTS = 6
18
+ TOPIC_LIST_SEPARATOR = ";"
19
+ TOPIC_INNER_SEPARATOR = ","
20
+ TOPIC_MESSAGE_PART_SEPARATOR = "^"
21
+
22
+ MESSAGE_SEPARATOR = "|"
23
+ MESSAGE_EXPECTED_PARTS = 6
24
+
25
+ ENCODING_UTF8 = "utf-8"
26
+ ENCODING_ASCII = "ascii"
27
+ NEWLINE = "\n"
28
+
29
+ NAME_MIN_LENGTH = 1
30
+ NAME_MAX_LENGTH = 100
31
+ RATING_MIN_VALUE = 0
32
+
33
+ TOPIC_TITLE_MIN_LENGTH = 1
34
+ TOPIC_TITLE_MAX_LENGTH = 200
35
+ TOPIC_DESCRIPTION_MIN_LENGTH = 1
36
+ TOPIC_DESCRIPTION_MAX_LENGTH = 1000
37
+ TOPIC_MESSAGE_MIN_LENGTH = 1
38
+ TOPIC_MESSAGE_MAX_LENGTH = 2000
39
+
40
+ PARTICIPANT_PARSE_EMPTY_ERROR = "Cannot parse empty participant line"
41
+ PARTICIPANT_PARSE_INVALID_ERROR = "Invalid participant line format"
42
+ TOPIC_PARSE_EMPTY_ERROR = "Cannot parse empty topic line"
43
+ TOPIC_PARSE_INVALID_ERROR = "Invalid topic line format"
44
+ MESSAGE_PARSE_EMPTY_ERROR = "Cannot parse empty message line"
45
+ MESSAGE_PARSE_INVALID_ERROR = "Invalid message line format"
46
+
47
+ PARTICIPANT_NOT_FOUND_MESSAGE = "Participant not found"
48
+ TOPIC_NOT_FOUND_MESSAGE = "Topic not found"
49
+ MESSAGE_NOT_FOUND_MESSAGE = "Message not found"
50
+ UNKNOWN_PARTICIPANT_NAME = "Unknown participant"
51
+ UNKNOWN_TOPIC_TITLE = "Unknown topic"
52
+
53
+ APP_TITLE = "Forum Storage API"
54
+ PARTICIPANTS_ROUTE_PREFIX = "/participants"
55
+ TOPICS_ROUTE_PREFIX = "/topics"
56
+ MESSAGES_ROUTE_PREFIX = "/messages"
57
+ PARTICIPANTS_TAG = "participants"
58
+ TOPICS_TAG = "topics"
59
+ MESSAGES_TAG = "messages"
60
+
61
+ HTTP_STATUS_CREATED = 201
62
+
63
+ FORBIDDEN_WORDS = ["spam", "hate", "violence", "abuse", "illegal"]
64
+ FORBIDDEN_WORDS_MESSAGE = "Message contains forbidden words"
65
+
66
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
67
+ PARTICIPANTS_FILE.touch(exist_ok=True)
68
+ TOPICS_FILE.touch(exist_ok=True)
69
+ MESSAGES_FILE.touch(exist_ok=True)
70
+
71
+
app/core/file_manager.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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
+
app/core/link.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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):
9
+ table: str
10
+ value: UUID
11
+
12
+ def resolve(self, resolvers: dict[str, Callable[[UUID], Any]]) -> Any:
13
+ resolver = resolvers.get(self.table)
14
+ if resolver is None:
15
+ raise HTTPException(
16
+ status_code=status.HTTP_400_BAD_REQUEST,
17
+ detail=f"Resolver for table '{self.table}' is not registered",
18
+ )
19
+ return resolver(self.value)
20
+
21
+ def as_path(self, leading_slash: bool = True) -> str:
22
+ path = f"{self.table}/{self.value}"
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
+
app/data/participants.txt ADDED
File without changes
app/data/topics.txt ADDED
File without changes
client.py ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from enum import IntEnum
3
+ from uuid import UUID
4
+
5
+ import httpx
6
+
7
+ BASE_URL = "http://127.0.0.1:8000"
8
+
9
+ NEWLINE = "\n"
10
+ SEPARATOR_60 = "=" * 60
11
+ SEPARATOR_90 = "-" * 90
12
+ DASH_LINE_60 = "-" * 60
13
+ MAIN_MENU_TITLE = " FORUM CLIENT - MAIN MENU"
14
+ PROMPT_SELECT_OPTION = "Select option: "
15
+ MSG_GOODBYE = "Goodbye!"
16
+ LABEL_ALL_PARTICIPANTS = "ALL PARTICIPANTS:"
17
+ LABEL_ALL_TOPICS = "ALL TOPICS:"
18
+ LABEL_ALL_MESSAGES = "ALL MESSAGES:"
19
+ LABEL_CREATE_PARTICIPANT = "CREATE NEW PARTICIPANT:"
20
+ LABEL_CREATE_TOPIC = "CREATE NEW TOPIC:"
21
+ LABEL_PUBLISH_MESSAGE = "PUBLISH MESSAGE TO TOPIC:"
22
+ PROMPT_PARTICIPANT_ID = "Enter participant ID: "
23
+ PROMPT_TOPIC_ID = "Enter topic ID: "
24
+ PROMPT_TITLE = " Title: "
25
+ PROMPT_DESCRIPTION = " Description: "
26
+ PROMPT_FIRST_NAME = " First name: "
27
+ PROMPT_LAST_NAME = " Last name: "
28
+ PROMPT_NICKNAME = " Nickname: "
29
+ PROMPT_ACTIVITY = " Activity rating: "
30
+ PROMPT_TOPIC_ID_INLINE = " Topic ID: "
31
+ PROMPT_PARTICIPANT_ID_INLINE = " Participant ID: "
32
+ PROMPT_MESSAGE_CONTENT = " Message content: "
33
+ MSG_NO_PARTICIPANTS = "No participants found."
34
+ MSG_NO_TOPICS = "No topics found."
35
+ MSG_INVALID_OPTION = "Invalid option. Please try again."
36
+ MSG_INVALID_UUID = "Invalid UUID format."
37
+ MSG_GOODBYE_INTERRUPT = "\n\nInterrupted. Goodbye!"
38
+ MSG_TOPIC_ID_REQUIRED = "Topic ID is required."
39
+ MSG_PARTICIPANT_ID_REQUIRED = "Participant ID is required."
40
+ MSG_TOPIC_NOT_FOUND = "Topic not found."
41
+ MSG_PARTICIPANT_NOT_FOUND = "Participant not found."
42
+ MSG_NO_MESSAGES = " No messages yet."
43
+ MSG_PARTICIPANT_CREATED = "\nParticipant created successfully!"
44
+ MSG_TOPIC_CREATED = "\nTopic created successfully!"
45
+ MSG_MESSAGE_PUBLISHED = "\nMessage published successfully!"
46
+ MSG_ERROR_TEMPLATE = "Error: {status} - {text}"
47
+ PARTICIPANT_NAME_RULE = "First name must contain only letters and '-'."
48
+ PARTICIPANT_LAST_NAME_RULE = "Last name must contain only letters and '-'."
49
+ PARTICIPANT_NICKNAME_RULE = "Nickname must contain only letters and '-'."
50
+ ACTIVITY_RANGE_RULE = "Activity rating must be between 0.1 and 5.0."
51
+ ACTIVITY_NUMBER_RULE = "Activity rating must be a number between 0.1 and 5.0."
52
+ PARTICIPANT_TABLE_HEADER = f"{'ID':<40} {'Name':<25} {'Nickname':<15} {'Rating':<10}"
53
+ PARTICIPANT_ROW_TEMPLATE = (
54
+ "{id:<40} {full_name:<25} {nickname:<15} {rating:<10}"
55
+ )
56
+ TOPIC_TITLE_TEMPLATE = "\nTopic: {title}"
57
+ TOPIC_ID_TEMPLATE = " ID: {id}"
58
+ TOPIC_DESCRIPTION_TEMPLATE = " Description: {description}"
59
+ TOPIC_CREATED_TEMPLATE = " Created: {created_at}"
60
+ TOPIC_MESSAGES_TEMPLATE = " Messages ({count}):"
61
+ TOPIC_PARTICIPANTS_TEMPLATE = " Participants: {count}"
62
+ TOPIC_MESSAGE_TEMPLATE = " {order}. [{participant_name}]: {content}"
63
+ MESSAGE_LIST_TEMPLATE = "{topic_title} | #{order} [{participant_name}]: {content}"
64
+ PARTICIPANT_SUMMARY_TEMPLATE = "\nParticipant: {first} {last}"
65
+ PARTICIPANT_ID_TEMPLATE = " ID: {id}"
66
+ PARTICIPANT_NICKNAME_TEMPLATE = " Nickname: {nickname}"
67
+ PARTICIPANT_RATING_TEMPLATE = " Rating: {rating}"
68
+ PARTICIPANT_REGISTERED_TEMPLATE = " Registered: {registered}"
69
+ TOPIC_CREATED_ID_TEMPLATE = " ID: {id}"
70
+ PARTICIPANT_CREATED_ID_TEMPLATE = " ID: {id}"
71
+ TOPIC_MESSAGES_COUNT_TEMPLATE = "Topic now has {count} message(s)."
72
+ RESULT_MESSAGE_TEMPLATE = "\n{result}"
73
+ MENU_HEADER = NEWLINE + SEPARATOR_60
74
+
75
+ class ForumClient:
76
+ def __init__(self, base_url: str = BASE_URL):
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()
83
+ return response.json()
84
+
85
+ def get_participant(self, participant_id: str) -> dict:
86
+ response = self.client.get(f"{self.base_url}/participants/{participant_id}")
87
+ response.raise_for_status()
88
+ return response.json()
89
+
90
+ def create_participant(
91
+ self, first_name: str, last_name: str, nickname: str, activity_rating: float
92
+ ) -> dict:
93
+ payload = {
94
+ "first_name": first_name,
95
+ "last_name": last_name,
96
+ "nickname": nickname,
97
+ "activity_rating": activity_rating,
98
+ }
99
+ response = self.client.post(f"{self.base_url}/participants/", json=payload)
100
+ response.raise_for_status()
101
+ return response.json()
102
+
103
+ def get_topics(self) -> list:
104
+ response = self.client.get(f"{self.base_url}/topics/")
105
+ response.raise_for_status()
106
+ return response.json()
107
+
108
+ def get_topic(self, topic_id: str) -> dict:
109
+ response = self.client.get(f"{self.base_url}/topics/{topic_id}")
110
+ response.raise_for_status()
111
+ return response.json()
112
+
113
+ def get_messages(self) -> list:
114
+ response = self.client.get(f"{self.base_url}/messages/")
115
+ response.raise_for_status()
116
+ return response.json()
117
+
118
+ def get_messages_by_topic(self, topic_id: str) -> list:
119
+ response = self.client.get(f"{self.base_url}/messages/topic/{topic_id}")
120
+ response.raise_for_status()
121
+ return response.json()
122
+
123
+ def create_topic(
124
+ self, title: str, description: str, participants: list = None
125
+ ) -> dict:
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)
133
+ response.raise_for_status()
134
+ return response.json()
135
+
136
+ def publish_message(
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(
144
+ f"{self.base_url}/topics/{topic_id}/messages", json=payload
145
+ )
146
+ if response.status_code == 400:
147
+ error_detail = response.json().get("detail", "Unknown error")
148
+ return f"ERROR: {error_detail}"
149
+ response.raise_for_status()
150
+ return response.json()
151
+
152
+ def close(self):
153
+ self.client.close()
154
+
155
+
156
+ def print_separator():
157
+ print(SEPARATOR_60)
158
+
159
+
160
+ def print_participants(participants: list):
161
+ if not participants:
162
+ print(MSG_NO_PARTICIPANTS)
163
+ return
164
+ print(PARTICIPANT_TABLE_HEADER)
165
+ print(SEPARATOR_90)
166
+ for p in participants:
167
+ full_name = f"{p['first_name']} {p['last_name']}"
168
+ print(
169
+ PARTICIPANT_ROW_TEMPLATE.format(
170
+ id=p["id"],
171
+ full_name=full_name,
172
+ nickname=p["nickname"],
173
+ rating=p["activity_rating"],
174
+ )
175
+ )
176
+
177
+
178
+ def print_topics(topics: list):
179
+ if not topics:
180
+ print(MSG_NO_TOPICS)
181
+ return
182
+ for topic in topics:
183
+ print(TOPIC_TITLE_TEMPLATE.format(title=topic["title"]))
184
+ print(TOPIC_ID_TEMPLATE.format(id=topic["id"]))
185
+ print(TOPIC_DESCRIPTION_TEMPLATE.format(description=topic["description"]))
186
+ print(TOPIC_CREATED_TEMPLATE.format(created_at=topic["created_at"]))
187
+ print(TOPIC_MESSAGES_TEMPLATE.format(count=len(topic["messages"])))
188
+ for msg in topic["messages"]:
189
+ print(
190
+ TOPIC_MESSAGE_TEMPLATE.format(
191
+ participant_name=msg["participant_name"],
192
+ content=msg["content"],
193
+ order=msg.get("order_in_topic", 0),
194
+ )
195
+ )
196
+
197
+
198
+ def print_topic_detail(topic: dict):
199
+ print(TOPIC_TITLE_TEMPLATE.format(title=topic["title"]))
200
+ print(TOPIC_ID_TEMPLATE.format(id=topic["id"]))
201
+ print(TOPIC_DESCRIPTION_TEMPLATE.format(description=topic["description"]))
202
+ print(TOPIC_CREATED_TEMPLATE.format(created_at=topic["created_at"]))
203
+ print(TOPIC_PARTICIPANTS_TEMPLATE.format(count=len(topic["participants"])))
204
+ print(NEWLINE + TOPIC_MESSAGES_TEMPLATE.format(count=len(topic["messages"])))
205
+ if not topic["messages"]:
206
+ print(MSG_NO_MESSAGES)
207
+ for msg in topic["messages"]:
208
+ print(
209
+ TOPIC_MESSAGE_TEMPLATE.format(
210
+ participant_name=msg["participant_name"],
211
+ content=msg["content"],
212
+ order=msg.get("order_in_topic", 0),
213
+ )
214
+ )
215
+
216
+
217
+ def print_messages(messages: list):
218
+ if not messages:
219
+ print(MSG_NO_MESSAGES)
220
+ return
221
+ for msg in messages:
222
+ print(
223
+ MESSAGE_LIST_TEMPLATE.format(
224
+ topic_title=msg["topic_title"],
225
+ order=msg["order_in_topic"],
226
+ participant_name=msg["participant_name"],
227
+ content=msg["content"],
228
+ )
229
+ )
230
+
231
+
232
+ def main_menu():
233
+ print(MENU_HEADER)
234
+ print(MAIN_MENU_TITLE)
235
+ print(SEPARATOR_60)
236
+ for option in MENU_OPTIONS:
237
+ print(option)
238
+ print(DASH_LINE_60)
239
+ return input(PROMPT_SELECT_OPTION).strip()
240
+
241
+
242
+ class MenuChoice(IntEnum):
243
+ LIST_PARTICIPANTS = 1, "List all participants"
244
+ GET_PARTICIPANT = 2, "Get participant by ID"
245
+ CREATE_PARTICIPANT = 3, "Create new participant"
246
+ LIST_TOPICS = 4, "List all topics"
247
+ GET_TOPIC = 5, "Get topic by ID"
248
+ CREATE_TOPIC = 6, "Create new topic"
249
+ PUBLISH_MESSAGE = 7, "Publish message to topic"
250
+ LIST_MESSAGES = 8, "List all messages"
251
+ EXIT = 0, "Exit"
252
+
253
+ def __new__(cls, value, label=""):
254
+ obj = int.__new__(cls, value)
255
+ obj._value_ = value
256
+ obj.label = label
257
+ return obj
258
+
259
+
260
+ MENU_OPTIONS = tuple(f"{choice.value}. {choice.label}" for choice in MenuChoice)
261
+
262
+
263
+ def main():
264
+ client = ForumClient()
265
+
266
+ try:
267
+ while True:
268
+ raw_choice = main_menu()
269
+ try:
270
+ choice = MenuChoice(int(raw_choice))
271
+ except (ValueError, TypeError):
272
+ print(MSG_INVALID_OPTION)
273
+ continue
274
+
275
+ if choice == MenuChoice.EXIT:
276
+ print(MSG_GOODBYE)
277
+ break
278
+
279
+ elif choice == MenuChoice.LIST_PARTICIPANTS:
280
+ print_separator()
281
+ print(LABEL_ALL_PARTICIPANTS)
282
+ try:
283
+ participants = client.get_participants()
284
+ print_participants(participants)
285
+ except httpx.HTTPStatusError as e:
286
+ print(
287
+ MSG_ERROR_TEMPLATE.format(
288
+ status=e.response.status_code, text=e.response.text
289
+ )
290
+ )
291
+
292
+ elif choice == MenuChoice.GET_PARTICIPANT:
293
+ print_separator()
294
+ participant_id = input(PROMPT_PARTICIPANT_ID).strip()
295
+ try:
296
+ UUID(participant_id)
297
+ participant = client.get_participant(participant_id)
298
+ print(
299
+ PARTICIPANT_SUMMARY_TEMPLATE.format(
300
+ first=participant["first_name"],
301
+ last=participant["last_name"],
302
+ )
303
+ )
304
+ print(PARTICIPANT_ID_TEMPLATE.format(id=participant["id"]))
305
+ print(
306
+ PARTICIPANT_NICKNAME_TEMPLATE.format(
307
+ nickname=participant["nickname"]
308
+ )
309
+ )
310
+ print(
311
+ PARTICIPANT_RATING_TEMPLATE.format(
312
+ rating=participant["activity_rating"]
313
+ )
314
+ )
315
+ print(
316
+ PARTICIPANT_REGISTERED_TEMPLATE.format(
317
+ registered=participant["registered_at"]
318
+ )
319
+ )
320
+ except ValueError:
321
+ print(MSG_INVALID_UUID)
322
+ except httpx.HTTPStatusError as e:
323
+ print(
324
+ MSG_ERROR_TEMPLATE.format(
325
+ status=e.response.status_code, text=e.response.text
326
+ )
327
+ )
328
+
329
+ elif choice == MenuChoice.CREATE_PARTICIPANT:
330
+ print_separator()
331
+ print(LABEL_CREATE_PARTICIPANT)
332
+ while True:
333
+ first_name = input(PROMPT_FIRST_NAME).strip()
334
+ if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", first_name):
335
+ print(PARTICIPANT_NAME_RULE)
336
+ continue
337
+ break
338
+
339
+ while True:
340
+ last_name = input(PROMPT_LAST_NAME).strip()
341
+ if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", last_name):
342
+ print(PARTICIPANT_LAST_NAME_RULE)
343
+ continue
344
+ break
345
+
346
+ while True:
347
+ nickname = input(PROMPT_NICKNAME).strip()
348
+ if not re.fullmatch(r"[A-Za-zА-Яа-яЁё-]+", nickname):
349
+ print(PARTICIPANT_NICKNAME_RULE)
350
+ continue
351
+ break
352
+
353
+ while True:
354
+ activity_rating_raw = input(PROMPT_ACTIVITY).strip()
355
+ try:
356
+ activity_rating = float(activity_rating_raw)
357
+ if activity_rating < 0.1 or activity_rating > 5.0:
358
+ print(ACTIVITY_RANGE_RULE)
359
+ continue
360
+ except ValueError:
361
+ print(ACTIVITY_NUMBER_RULE)
362
+ continue
363
+ break
364
+
365
+ try:
366
+ participant = client.create_participant(
367
+ first_name, last_name, nickname, activity_rating
368
+ )
369
+ print(MSG_PARTICIPANT_CREATED)
370
+ print(
371
+ PARTICIPANT_CREATED_ID_TEMPLATE.format(id=participant["id"])
372
+ )
373
+ except httpx.HTTPStatusError as e:
374
+ print(
375
+ MSG_ERROR_TEMPLATE.format(
376
+ status=e.response.status_code, text=e.response.text
377
+ )
378
+ )
379
+
380
+ elif choice == MenuChoice.LIST_TOPICS:
381
+ print_separator()
382
+ print(LABEL_ALL_TOPICS)
383
+ try:
384
+ topics = client.get_topics()
385
+ print_topics(topics)
386
+ except httpx.HTTPStatusError as e:
387
+ print(
388
+ MSG_ERROR_TEMPLATE.format(
389
+ status=e.response.status_code, text=e.response.text
390
+ )
391
+ )
392
+
393
+ elif choice == MenuChoice.LIST_MESSAGES:
394
+ print_separator()
395
+ print(LABEL_ALL_MESSAGES)
396
+ try:
397
+ messages = client.get_messages()
398
+ print_messages(messages)
399
+ except httpx.HTTPStatusError as e:
400
+ print(
401
+ MSG_ERROR_TEMPLATE.format(
402
+ status=e.response.status_code, text=e.response.text
403
+ )
404
+ )
405
+
406
+ elif choice == MenuChoice.GET_TOPIC:
407
+ print_separator()
408
+ topic_id = input(PROMPT_TOPIC_ID).strip()
409
+ try:
410
+ UUID(topic_id)
411
+ topic = client.get_topic(topic_id)
412
+ print_topic_detail(topic)
413
+ except ValueError:
414
+ print(MSG_INVALID_UUID)
415
+ except httpx.HTTPStatusError as e:
416
+ print(
417
+ MSG_ERROR_TEMPLATE.format(
418
+ status=e.response.status_code, text=e.response.text
419
+ )
420
+ )
421
+
422
+ elif choice == MenuChoice.CREATE_TOPIC:
423
+ print_separator()
424
+ print(LABEL_CREATE_TOPIC)
425
+ title = input(PROMPT_TITLE).strip()
426
+ description = input(PROMPT_DESCRIPTION).strip()
427
+ try:
428
+ topic = client.create_topic(title, description)
429
+ print(MSG_TOPIC_CREATED)
430
+ print(TOPIC_CREATED_ID_TEMPLATE.format(id=topic["id"]))
431
+ except httpx.HTTPStatusError as e:
432
+ print(
433
+ MSG_ERROR_TEMPLATE.format(
434
+ status=e.response.status_code, text=e.response.text
435
+ )
436
+ )
437
+
438
+ elif choice == MenuChoice.PUBLISH_MESSAGE:
439
+ print_separator()
440
+ print(LABEL_PUBLISH_MESSAGE)
441
+ while True:
442
+ topic_id = input(PROMPT_TOPIC_ID_INLINE).strip()
443
+ if not topic_id:
444
+ print(MSG_TOPIC_ID_REQUIRED)
445
+ continue
446
+ try:
447
+ client.get_topic(topic_id)
448
+ except httpx.HTTPStatusError as e:
449
+ if e.response.status_code in (404, 422):
450
+ print(MSG_TOPIC_NOT_FOUND)
451
+ else:
452
+ print(
453
+ MSG_ERROR_TEMPLATE.format(
454
+ status=e.response.status_code, text=e.response.text
455
+ )
456
+ )
457
+ continue
458
+ break
459
+
460
+ while True:
461
+ participant_id = input(PROMPT_PARTICIPANT_ID_INLINE).strip()
462
+ if not participant_id:
463
+ print(MSG_PARTICIPANT_ID_REQUIRED)
464
+ continue
465
+ try:
466
+ client.get_participant(participant_id)
467
+ except httpx.HTTPStatusError as e:
468
+ if e.response.status_code in (404, 422):
469
+ print(MSG_PARTICIPANT_NOT_FOUND)
470
+ else:
471
+ print(
472
+ MSG_ERROR_TEMPLATE.format(
473
+ status=e.response.status_code, text=e.response.text
474
+ )
475
+ )
476
+ continue
477
+ break
478
+
479
+ content = input(PROMPT_MESSAGE_CONTENT).strip()
480
+
481
+ result = client.publish_message(topic_id, participant_id, content)
482
+ if isinstance(result, str):
483
+ print(RESULT_MESSAGE_TEMPLATE.format(result=result))
484
+ else:
485
+ print(MSG_MESSAGE_PUBLISHED)
486
+ print(
487
+ TOPIC_MESSAGES_COUNT_TEMPLATE.format(
488
+ count=len(result["messages"])
489
+ )
490
+ )
491
+
492
+ else:
493
+ print(MSG_INVALID_OPTION)
494
+
495
+ except KeyboardInterrupt:
496
+ print(MSG_GOODBYE_INTERRUPT)
497
+ finally:
498
+ client.close()
499
+
500
+
501
+ if __name__ == "__main__":
502
+ main()
data/messages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ 1da2f311-7fbb-4528-8710-c169bb8855d0|523f5745-ebde-4556-8e38-6a7ed95baf08|1|0cb6d70f-fd72-477f-863f-33b3f6932f90|2025-12-09T15:30:54.854152|SGVsbG8gd29ybGQ=
data/participants.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
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
data/topics.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
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=
main.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app import create_app
2
+
3
+ app = create_app()
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ annotated-doc==0.0.4
2
+ annotated-types==0.7.0
3
+ anyio==4.11.0
4
+ certifi==2025.11.12
5
+ click==8.3.1
6
+ fastapi==0.122.0
7
+ h11==0.16.0
8
+ httpcore==1.0.9
9
+ httptools==0.7.1
10
+ httpx==0.28.1
11
+ idna==3.11
12
+ pydantic==2.12.4
13
+ pydantic_core==2.41.5
14
+ python-dotenv==1.2.1
15
+ PyYAML==6.0.3
16
+ sniffio==1.3.1
17
+ starlette==0.50.0
18
+ typing-inspection==0.4.2
19
+ typing_extensions==4.15.0
20
+ uvicorn==0.38.0
21
+ uvloop==0.22.1
22
+ watchfiles==1.1.1
23
+ websockets==15.0.1