Commit
·
69f2337
0
Parent(s):
init
Browse files- .gitignore +1 -0
- .idea/.gitignore +8 -0
- .idea/inspectionProfiles/Project_Default.xml +169 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/lab4.iml +10 -0
- .idea/misc.xml +7 -0
- .idea/modules.xml +8 -0
- .vscode/launch.json +17 -0
- .vscode/settings.json +20 -0
- .vscode/tasks.json +15 -0
- Dockerfile +15 -0
- README.md +10 -0
- app/__init__.py +26 -0
- app/api/messages/__init__.py +1 -0
- app/api/messages/parser.py +71 -0
- app/api/messages/schemas.py +28 -0
- app/api/messages/services.py +128 -0
- app/api/messages/views.py +85 -0
- app/api/participants/__init__.py +1 -0
- app/api/participants/parser.py +51 -0
- app/api/participants/schemas.py +25 -0
- app/api/participants/services.py +49 -0
- app/api/participants/views.py +31 -0
- app/api/topics/__init__.py +1 -0
- app/api/topics/parser.py +126 -0
- app/api/topics/schemas.py +66 -0
- app/api/topics/services.py +134 -0
- app/api/topics/views.py +101 -0
- app/core/__init__.py +4 -0
- app/core/config.py +71 -0
- app/core/file_manager.py +27 -0
- app/core/link.py +38 -0
- app/data/participants.txt +0 -0
- app/data/topics.txt +0 -0
- client.py +502 -0
- data/messages.txt +1 -0
- data/participants.txt +7 -0
- data/topics.txt +3 -0
- main.py +3 -0
- requirements.txt +23 -0
.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
|