Compare commits

...

184 Commits

Author SHA1 Message Date
Kseninia Mikhaylova 8f1e312f5b is check filter 2024-08-22 16:59:12 +03:00
Kseninia Mikhaylova 0cf975820a is check 2024-08-22 16:54:56 +03:00
Kseninia Mikhaylova d57429d9c6 update values in loop 2024-08-22 16:40:37 +03:00
Kseninia Mikhaylova f63a9ef6b3 update values in loop 2024-08-22 16:38:04 +03:00
Kseninia Mikhaylova b45d0029ae update values in loop 2024-08-22 16:30:44 +03:00
Kseninia Mikhaylova 5404f44fbc update values in loop 2024-08-22 16:28:15 +03:00
Kseninia Mikhaylova ecc886689e update values in loop 2024-08-22 16:24:17 +03:00
Kseninia Mikhaylova 664f081b9f part of sort 2024-08-22 14:13:12 +03:00
Kseninia Mikhaylova 6dbac8bd4b stat page 2024-08-20 10:20:45 +03:00
Kseninia Mikhaylova e6079daf32 annotate in serializer 2024-08-20 09:28:55 +03:00
Kseninia Mikhaylova 93b60444ea serializer test 2024-08-19 17:06:59 +03:00
Kseninia Mikhaylova 6137d78f3f styles 2024-08-19 12:38:38 +03:00
Kseninia Mikhaylova 0aafb8c7aa paged 2024-08-19 12:22:08 +03:00
Kseninia Mikhaylova 64b3271c35 delete btn 2024-08-19 11:29:41 +03:00
Kseninia Mikhaylova 6e2cee0968 sort by created at 2024-08-19 11:09:35 +03:00
Kseninia Mikhaylova 10b1f82bf3 test fix 2024-08-19 10:58:55 +03:00
Kseninia Mikhaylova 463405b955 Del inv 2024-08-19 09:24:42 +03:00
Kseninia Mikhaylova 63af4b9891 add log 2024-08-19 09:06:08 +03:00
Kseninia Mikhaylova 626c8b5b91 caption 2024-08-13 10:02:13 +03:00
Kseninia Mikhaylova c7f7675941 caption 2024-08-13 09:59:27 +03:00
Kseninia Mikhaylova 83d9d85ffd upload photo AND text 2024-08-13 09:50:24 +03:00
Kseninia Mikhaylova 1153876c81 remove kyeboard 2024-08-13 09:39:32 +03:00
Kseninia Mikhaylova e67cee0513 parse mode 2024-08-12 16:46:25 +03:00
Kseninia Mikhaylova 301e9a0964 reply html 2024-08-12 16:42:30 +03:00
Kseninia Mikhaylova 65de9d7fbd modal 2024-08-12 16:38:57 +03:00
Kseninia Mikhaylova 666b38f2f9 blob 2024-08-12 16:32:51 +03:00
Kseninia Mikhaylova 18ecaea6af blob 2024-08-12 16:28:20 +03:00
Kseninia Mikhaylova 23ef8eab20 get s3 url 2024-08-12 14:41:38 +03:00
Kseninia Mikhaylova b1527a8f49 get s3 url 2024-08-12 14:38:59 +03:00
Kseninia Mikhaylova a1cbaf86fe aws url 2024-08-09 09:59:02 +03:00
Kseninia Mikhaylova 83602ecb79 show image 2024-08-09 09:38:28 +03:00
Kseninia Mikhaylova d9a55e2364 save no expiration link 2024-08-09 09:30:27 +03:00
Kseninia Mikhaylova 3473547171 aws cloud add 2024-08-08 17:13:57 +03:00
Kseninia Mikhaylova accb2c8c50 aws cloud add 2024-08-08 17:08:49 +03:00
Kseninia Mikhaylova c74a81c160 test aws 2024-08-08 09:43:30 +03:00
aarizona a59b6ee0a0 all modules to network 2024-08-07 22:24:46 +03:00
Kseninia Mikhaylova c92508aa5f test 2024-08-07 17:20:13 +03:00
Kseninia Mikhaylova 32adcd2831 test celery 2024-08-06 17:14:25 +03:00
Kseninia Mikhaylova 5e149b21a9 celery 2024-08-06 16:43:56 +03:00
Kseninia Mikhaylova 59eb963554 celery 2024-08-06 16:19:30 +03:00
Kseninia Mikhaylova a415ab31f4 celery 2024-08-06 15:34:23 +03:00
Kseninia Mikhaylova 5f027e2066 tg style 2024-08-06 09:31:47 +03:00
Kseninia Mikhaylova f29e667561 dockerfile 2024-08-05 17:11:37 +03:00
Kseninia Mikhaylova 10842b9a36 dockerfile 2024-08-05 17:01:32 +03:00
Kseninia Mikhaylova ebe440af12 env 2024-08-05 16:53:47 +03:00
Kseninia Mikhaylova c97ddc41c7 add tg filter 2024-08-05 15:57:30 +03:00
Kseninia Mikhaylova 85875778ae port default 2024-07-23 10:42:33 +03:00
Kseninia Mikhaylova ad36e16f93 test docker 2024-07-23 09:51:52 +03:00
Kseninia Mikhaylova c67d171472 test docker 2024-07-23 09:50:30 +03:00
Kseninia Mikhaylova 9593ee02fa csrf remove 2024-07-23 09:42:13 +03:00
Kseninia Mikhaylova db671877b1 test post 2024-07-22 17:13:07 +03:00
Kseninia Mikhaylova b92de6ac9f partial update 2024-07-22 16:51:24 +03:00
Kseninia Mikhaylova 65f54695d7 front part 2024-07-22 15:51:21 +03:00
Kseninia Mikhaylova 0809427959 start msg 2024-07-22 11:13:16 +03:00
Kseninia Mikhaylova f211df9c7d start msg 2024-07-22 11:09:55 +03:00
Kseninia Mikhaylova 8691855301 territory set 2024-07-22 10:44:14 +03:00
Kseninia Mikhaylova 92a1b96e37 ter and terdeep command 2024-07-22 10:11:54 +03:00
Kseninia Mikhaylova 6a4333e7c1 territory deep 2024-07-22 09:23:43 +03:00
Kseninia Mikhaylova c1aa007b1a add part of territories 2024-07-19 16:54:50 +03:00
Kseninia Mikhaylova 58994c596c foreign key 2024-07-19 15:44:35 +03:00
Kseninia Mikhaylova c0c6b4a285 add territory 2024-07-19 15:30:33 +03:00
Kseninia Mikhaylova 857244f3fc badge 2024-07-19 14:59:33 +03:00
Kseninia Mikhaylova c3fafd5645 group data in front 2024-07-19 14:27:34 +03:00
Kseninia Mikhaylova 1550c2fe6e prevent link 2024-07-19 13:54:32 +03:00
Kseninia Mikhaylova 679996692a updated at 2024-07-19 13:51:42 +03:00
Kseninia Mikhaylova e01a416c76 stop inv button 2024-07-19 12:38:29 +03:00
Kseninia Mikhaylova 449c023dff add message filter 2024-07-19 12:17:38 +03:00
Kseninia Mikhaylova ddfb7994c8 success message 2024-07-19 12:05:58 +03:00
Kseninia Mikhaylova ac2040eee9 file id filter fix 2024-07-19 11:53:13 +03:00
Kseninia Mikhaylova db276f2bec get text 2024-07-19 11:48:37 +03:00
Kseninia Mikhaylova 5e8f4649a2 format strings 2024-07-19 11:18:17 +03:00
Kseninia Mikhaylova c36f21b819 add user id 2024-07-19 10:55:32 +03:00
Kseninia Mikhaylova b231676736 add pagination 2024-07-19 10:39:13 +03:00
Kseninia Mikhaylova 29f1391288 new inv 2024-07-19 10:08:34 +03:00
Kseninia Mikhaylova 8e35d2ecb5 load images 2024-07-19 09:10:21 +03:00
Kseninia Mikhaylova a38f5ad12c images store part 2024-07-18 17:11:49 +03:00
Kseninia Mikhaylova 24705fe8e0 heading styles 2024-07-18 16:55:18 +03:00
Kseninia Mikhaylova 71b914f07f word break 2024-07-18 15:04:26 +03:00
Kseninia Mikhaylova 1cf0a60c27 add timeout 2024-07-18 14:47:14 +03:00
Kseninia Mikhaylova 76b7ce67eb test tg bot 2024-07-18 14:32:01 +03:00
Kseninia Mikhaylova 424a25ed15 front part 2024-07-18 14:26:20 +03:00
Kseninia Mikhaylova 5466afc08b get temporary img link 2024-07-18 14:01:58 +03:00
Kseninia Mikhaylova ce81205317 add part of get image 2024-07-18 13:49:56 +03:00
Kseninia Mikhaylova b32f930cbd set webhook 2024-07-18 13:18:21 +03:00
Kseninia Mikhaylova 2f725bbfc7 add data correct 2024-07-18 12:55:49 +03:00
Kseninia Mikhaylova b118fb7124 many to many 2024-07-18 12:12:05 +03:00
Kseninia Mikhaylova 463e7451fb add image upload 2024-07-17 17:15:52 +03:00
Kseninia Mikhaylova 979c19e67f add tmc element 2024-07-17 17:08:39 +03:00
Kseninia Mikhaylova 25237f11ed part of new tg logic 2024-07-17 16:49:30 +03:00
Kseninia Mikhaylova 2ba26c942b created delete field 2024-07-17 11:52:00 +03:00
Kseninia Mikhaylova b2184eb5c3 install curl 2024-07-17 11:14:29 +03:00
Kseninia Mikhaylova cbf4c566b0 docker fix 2024-07-17 10:59:54 +03:00
Kseninia Mikhaylova 8d52302c7e not main thread 2024-07-16 17:16:19 +03:00
Kseninia Mikhaylova 8204f47e72 ln files 2024-07-16 17:06:06 +03:00
Kseninia Mikhaylova a550d156d2 ln files 2024-07-16 17:05:46 +03:00
Kseninia Mikhaylova d2064e462c env 2024-07-16 16:59:53 +03:00
Kseninia Mikhaylova 71a41e003b env 2024-07-16 16:54:25 +03:00
Kseninia Mikhaylova 932d96c203 poetry 2024-07-16 16:46:21 +03:00
Kseninia Mikhaylova 2916826bfb base tg updater 2024-07-16 16:25:09 +03:00
Kseninia Mikhaylova 1b310dbfbe part of tg 2024-07-05 16:50:49 +03:00
Kseninia Mikhaylova ef08733979 very base tg bot 2024-07-05 15:38:13 +03:00
Kseninia Mikhaylova 431655eaac add one tmc 2024-07-05 13:53:01 +03:00
Kseninia Mikhaylova 3922bd3454 add name inv 2024-07-05 13:04:18 +03:00
Kseninia Mikhaylova 1fea37ec76 Merge branch 'bx-581-startproject' into dev 2024-07-05 11:46:18 +03:00
aarizona 5b4db31b68 tg loop 2024-07-04 22:19:01 +03:00
aarizona 2750cea9c4 que tg 2024-07-04 22:09:32 +03:00
aarizona 639857b310 tg loop 2024-07-04 21:33:00 +03:00
aarizona f89a1f365a test tg bot 2024-06-30 20:37:20 +03:00
Зеленская Анастасия Николаевна f1a2a640e1 admin 2024-06-25 15:19:54 +03:00
Зеленская Анастасия Николаевна 96741cf0a0 admin 2024-06-25 15:14:50 +03:00
Зеленская Анастасия Николаевна 363fa02663 admin 2024-06-25 15:11:01 +03:00
Kseninia Mikhaylova 3ef164c72f add csrf trusted 2024-06-25 14:46:55 +03:00
Kseninia Mikhaylova d8d9cef44d add csrf trusted 2024-06-25 14:44:46 +03:00
Зеленская Анастасия Николаевна fdfd8e28f6 Merge branch 'bx-854-admin-fields' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-854-admin-fields 2024-06-25 14:20:36 +03:00
Зеленская Анастасия Николаевна 30f6451727 admin 2024-06-25 14:20:29 +03:00
Kseninia Mikhaylova 50405639c2 Merge branch 'bx-854-admin-fields' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-854-admin-fields 2024-06-25 14:19:53 +03:00
Kseninia Mikhaylova b94df630bc settings 2024-06-25 14:19:37 +03:00
Зеленская Анастасия Николаевна bafab7f93c Merge branch 'bx-854-admin-fields' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-854-admin-fields 2024-06-25 13:59:31 +03:00
Зеленская Анастасия Николаевна 1fd958a511 3d 2024-06-25 13:59:20 +03:00
Kseninia Mikhaylova 6a9264ff50 add base front 2024-06-25 13:58:38 +03:00
Зеленская Анастасия Николаевна 9d09431c42 Merge branch 'bx-854-admin-fields' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-854-admin-fields 2024-06-25 13:51:17 +03:00
Зеленская Анастасия Николаевна ac73ad09ec admin 2024-06-25 13:51:15 +03:00
Kseninia Mikhaylova b4ffe1c551 show depth 2024-06-25 13:44:26 +03:00
Kseninia Mikhaylova b0c632030f add viewsets 2024-06-25 13:26:39 +03:00
Kseninia Mikhaylova 65b340b873 many to many 2024-06-25 13:11:35 +03:00
Зеленская Анастасия Николаевна 44fb008fd6 admin 2024-06-25 13:00:47 +03:00
Зеленская Анастасия Николаевна 69fc2eabd8 admin 2024-06-25 12:48:38 +03:00
Зеленская Анастасия Николаевна cece90eaa2 admin 2024-06-25 12:27:29 +03:00
Зеленская Анастасия Николаевна e8448a91db admin 2024-06-25 12:26:18 +03:00
Зеленская Анастасия Николаевна ba0e8ba0d3 admin 2024-06-25 12:23:02 +03:00
Зеленская Анастасия Николаевна 2a41108ced Merge branch 'bx-581-startproject' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-581-startproject 2024-06-25 12:11:46 +03:00
Зеленская Анастасия Николаевна a65fdc02d5 admin 2024-06-25 12:11:31 +03:00
Kseninia Mikhaylova c143650bdd djano python 2024-06-04 13:44:22 +03:00
Kseninia Mikhaylova c4dac25528 extra lower level tg bot 2024-06-04 12:26:11 +03:00
Kseninia Mikhaylova e6b95be013 добавить категорию расходные материалы 2024-06-03 16:20:37 +03:00
Kseninia Mikhaylova 7bdf4c628d styleS 2024-06-03 16:01:05 +03:00
Kseninia Mikhaylova 28406ad98a docker fix 2024-06-03 15:30:02 +03:00
Kseninia Mikhaylova 61d6823e5f api nginx 2024-06-03 13:49:38 +03:00
Kseninia Mikhaylova 0c95cda20d api nginx 2024-06-03 13:49:16 +03:00
Kseninia Mikhaylova cf9998c5f9 docker nuxt front 2024-06-03 13:34:54 +03:00
Kseninia Mikhaylova 153b7f601e docker 2024-05-31 17:02:56 +03:00
Kseninia Mikhaylova a56dcbd4d2 local dev 2024-05-31 10:08:01 +03:00
Kseninia Mikhaylova 877f99f466 uniq 2024-05-31 09:48:48 +03:00
Kseninia Mikhaylova 2e361ff919 external el in edit 2024-05-31 09:05:28 +03:00
Kseninia Mikhaylova 642b8ca73d external el 2024-05-31 09:05:23 +03:00
aarizona 43668907f0 add show element name 2024-05-30 14:05:20 +03:00
aarizona 66a298e101 add additional text 2024-05-30 13:44:38 +03:00
aarizona 15c54bd3e6 add validation 2024-05-30 13:33:08 +03:00
aarizona 2dbd6742f7 merge 2024-05-30 12:13:10 +03:00
aarizona 01ba1b8bce Merge branch 'bx-581-startproject' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-581-startproject 2024-05-30 12:11:19 +03:00
aarizona cb45928ebd inventory new from org 2024-05-30 12:11:10 +03:00
Зеленская Анастасия Николаевна 69672c8721 инвентаризация 2024-05-30 11:54:06 +03:00
Зеленская Анастасия Николаевна a23e604954 инвентаризация 2024-05-30 11:50:53 +03:00
aarizona 516e06b3df add some headers 2024-05-30 11:49:49 +03:00
aarizona ef56974447 fix front redirect 2024-05-30 11:08:59 +03:00
aarizona 46c3995b6f Merge branch 'bx-581-startproject' of https://git.svs-tech.pro/ksenia_mikhailova/to_inventory into bx-581-startproject 2024-05-30 10:45:08 +03:00
aarizona def5f8873f columns at front 2024-05-30 10:44:23 +03:00
Зеленская Анастасия Николаевна e7a91b0014 авторы 2024-05-30 10:44:22 +03:00
aarizona c2edb2fe2a counters 2024-05-30 10:22:31 +03:00
Зеленская Анастасия Николаевна 659e1ee439 названия 2024-05-30 09:34:40 +03:00
aarizona fa49de322d base add 2024-05-29 18:57:17 +03:00
aarizona dd32cf6432 filter data 2024-05-29 18:22:30 +03:00
aarizona 43fb670765 show_data 2024-05-29 18:19:58 +03:00
aarizona 1dab0e2cc7 save data 2024-05-29 18:02:43 +03:00
aarizona 14c0054d7f subfolder 2024-05-29 17:52:09 +03:00
aarizona 8c7e4332dd save partner to db 2024-05-29 17:21:15 +03:00
aarizona e1e353bc51 typescript fix 2024-05-29 16:30:35 +03:00
aarizona 58934e8666 последовательный выбор категории 2024-05-29 15:38:42 +03:00
aarizona b71ebfc398 part of get data 2024-05-29 12:54:47 +03:00
aarizona 698f791920 add external elements query 2024-05-29 12:25:36 +03:00
aarizona ebc48e3b87 add first field 2024-05-29 11:08:18 +03:00
aarizona 2fad0b67d2 grid css 2024-05-29 07:57:36 +03:00
aarizona 2e8cc9f387 remove idea and vscode from git 2024-05-29 07:52:26 +03:00
Kseninia Mikhaylova 7b2d7ff294 base front 2024-05-28 17:11:24 +03:00
Kseninia Mikhaylova f2f8a62fa2 get external partners view 2024-05-28 17:05:11 +03:00
Kseninia Mikhaylova cbfc7daf6f add remote partners get 2024-05-28 16:55:40 +03:00
Зеленская Анастасия Николаевна 2332532236 admin 2024-05-28 16:15:50 +03:00
Kseninia Mikhaylova 100d78a53b add element viewset 2024-05-28 16:04:27 +03:00
Kseninia Mikhaylova 2e09d6dcd4 env 2024-05-28 16:02:18 +03:00
Kseninia Mikhaylova 7b89dd25e7 move folder to root 2024-05-28 15:48:00 +03:00
Kseninia Mikhaylova a21ee1d965 rename fields 2024-05-28 15:15:18 +03:00
Kseninia Mikhaylova a939178e3f Merge branch 'main' into bx-581-startproject 2024-05-28 15:12:35 +03:00
Kseninia Mikhaylova 2c5d3ffe10 dev 2024-05-28 15:10:48 +03:00
Kseninia Mikhaylova 34db8582e7 front pages folder 2024-05-28 14:36:26 +03:00
79 changed files with 6190 additions and 235 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
back/.venv/

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea/
.vscode/
.venv/
.env
env/*.env

3
.idea/.gitignore vendored
View File

@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12 (Course_FirstProject) (3)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Course_FirstProject) (3)" project-jdk-type="Python SDK" />
<component name="PyCharmProfessionalAdvertiser">
<option name="shown" value="true" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/to_inventory.iml" filepath="$PROJECT_DIR$/.idea/to_inventory.iml" />
</modules>
</component>
</project>

View File

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3
back/.gitignore vendored
View File

@ -87,3 +87,6 @@ local_settings.py
.env .env
db.sqlite3 db.sqlite3
.pgpass
/*/migrations/0*

36
back/Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM ci.svs-tech.pro/library/node:22-bookworm-slim
# Set environment variables for Python and unbuffered mode
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
# Configure Poetry
ENV POETRY_VERSION=1.8.3
ENV POETRY_HOME=/opt/poetry
ENV POETRY_VENV=/opt/poetry-venv
ENV POETRY_CACHE_DIR=/opt/.cache
ENV WORKING_DIR=/app
WORKDIR ${WORKING_DIR}
RUN apt-get update
RUN apt-get install curl -y
RUN apt-get install python3-pip -y
RUN apt-get install python3-venv -y
ENV PATH="${PATH}:${POETRY_VENV}/bin"
# Install poetry separated from system interpreter
RUN python3 -m venv $POETRY_VENV \
&& $POETRY_VENV/bin/pip install -U pip setuptools \
&& $POETRY_VENV/bin/pip install poetry==${POETRY_VERSION}
RUN poetry -vvv --version
COPY pyproject.toml ./
RUN poetry install
COPY . ${WORKING_DIR}
# CMD ["poetry", "run", "/app/.venv/bin/celery"]

16
back/api/celery.py Normal file
View File

@ -0,0 +1,16 @@
import os
from django.conf import settings
from celery import Celery
from datetime import timedelta
from celery.utils.log import get_task_logger
logger = get_task_logger(__name__)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "api.settings")
celery_app = Celery("api")
celery_app.config_from_object(f'django.conf:settings', namespace='CELERY')
celery_app.autodiscover_tasks()

View File

@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

@ -1,18 +0,0 @@
from django.db import models
class Counter_agent(models.Model):
id = models.AutoField(primary_key=True)
def __str__(self):
return f' Counter_agent {self.id}'
class element(models.Model):
id = models.AutoField(primary_key=True)
external_id = models.IntegerField()
type = models.CharField(max_length=100)
photo = models.ImageField(upload_to='.')
dop_text = models.TextField()
Counter_agent = models.ForeignKey(Counter_agent, related_name='element', on_delete=models.CASCADE)
def __str__(self):
return f' element {self.id} (Counter_agent {self.Counter_agent.id})'

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@ -11,7 +11,10 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
""" """
from pathlib import Path from pathlib import Path
import os
import dotenv
dotenv.load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env"))
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -20,83 +23,120 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-ruo!wst&sb8(f9)j5u4rda-w!673lj_-c0a%gx_t@)ff*q*2ze' SECRET_KEY = "django-insecure-ruo!wst&sb8(f9)j5u4rda-w!673lj_-c0a%gx_t@)ff*q*2ze"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
NGROK_TEMP = os.environ.get("NGROK_TEMP")
ALLOWED_HOSTS = [] ALLOWED_HOSTS = [
"localhost",
NGROK_TEMP,
"192.168.106.234",
"back",
"toinv.svs-tech.pro",
]
CORS_ALLOWED_ORIGINS = [
"http://localhost",
"http://localhost:3000",
"http://localhost:3001",
"http://192.168.106.234:3000",
"https://toinv.svs-tech.pro",
]
CSRF_TRUSTED_ORIGINS = [
"http://localhost",
"https://toinv.svs-tech.pro",
]
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'django.contrib.admin', "corsheaders",
'django.contrib.auth', "django_filters",
'django.contrib.contenttypes', "rest_framework",
'django.contrib.sessions', "inventory",
'django.contrib.messages', "tgbot",
'django.contrib.staticfiles', "django.contrib.admin",
'inventory.apps.PostsConfig' "django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"tmc",
"djangoviz",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', "django.middleware.security.SecurityMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "corsheaders.middleware.CorsMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = 'api.urls' ROOT_URLCONF = "api.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'api.wsgi.application' WSGI_APPLICATION = "api.wsgi.application"
# Rest Framework
REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
"DEFAULT_PAGINATION_CLASS": "tgbot.pagination.CustomPagination",
"PAGE_SIZE": 5,
}
# Database # Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases # https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.postgresql",
'NAME': BASE_DIR / 'db.sqlite3', "NAME": os.environ.get("DB_NAME"),
"USER": os.environ.get("DB_USER"),
"PASSWORD": os.environ.get("DB_PASSWORD"),
"HOST": os.environ.get("DB_HOST"),
"PORT": os.environ.get("DB_PORT"),
} }
} }
CELERY_BROKER_URL = f'redis://{os.getenv("REDIS_HOST", "redis")}:6379/0'
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
}, },
] ]
@ -104,9 +144,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/ # https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
@ -116,9 +156,52 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = "static/"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"large": {
"format": "%(asctime)s %(levelname)s %(process)d %(filename)s:%(lineno)d "
+ "%(funcName)s %(message)s "
},
"simple": {
"format": "{levelname} {asctime} {module} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "large",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
ODATA_AUTH = os.environ.get("ODATA_AUTH")
TGBOT = {
"TOKEN": os.environ.get("TG_TOKEN"),
"BASE_URL": NGROK_TEMP,
"WEBHOOK_URL": f"webhook/{os.environ.get('TG_TOKEN')}",
}
SELECTEL = {
"access": os.environ.get("AWS_ACCESS"),
"secret": os.environ.get("AWS_SECRET"),
}

View File

@ -14,9 +14,29 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from rest_framework import routers
from inventory import views
from tgbot import views as tgbot_views
from tmc import views as tmc_views
router = routers.DefaultRouter()
router.register(r'api/partner', views.PartnerViewSet)
router.register(r'api/element', views.ElementViewSet)
router.register(r'api/inventory', views.InventoryItemViewSet)
router.register(r'api/tmc/fields', tmc_views.BaseCustomFieldViewSet)
router.register(r'api/tmc/ter', tmc_views.TerritoryViewSet)
router.register(r'api/tmc/terdeep', tmc_views.TerritoryItemViewSet)
router.register(r'api/tmc/items', tmc_views.CustomTableViewSet)
router.register(r'api/tgbot', tgbot_views.TgItemViewSet)
router.register(r'api/tgbot_items', tgbot_views.TmcFieldViewset)
router.register(r'api/stat', tgbot_views.TmcStatViewset, basename='stat')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
] ]

5
back/inventory/admin.py Normal file
View File

@ -0,0 +1,5 @@
from django.contrib import admin
from .models import Partner, Element
admin.site.register(Partner)
admin.site.register(Element)

46
back/inventory/models.py Normal file
View File

@ -0,0 +1,46 @@
from django.db import models
class Partner(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
external_id = models.CharField()
def __str__(self):
return f"Partner {self.id}"
class Author(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
telegram_id = models.CharField(max_length=50)
def str(self):
return f"Author {self.id} - {self.name}"
class InventoryItem(models.Model):
id = models.AutoField(primary_key=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
name = models.CharField(max_length=255)
author = models.ForeignKey(Author, on_delete=models.CASCADE, null=True)
partner = models.ForeignKey(
Partner, related_name="Inventory", on_delete=models.CASCADE
)
class Element(models.Model):
id = models.AutoField(primary_key=True)
external_id = models.CharField()
element_id = models.CharField(max_length=100)
photo = models.ImageField(upload_to=".", null=True)
additional_text = models.TextField(null=True, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
inventory = models.ForeignKey(
InventoryItem, related_name="Element", on_delete=models.CASCADE
)
def __str__(self):
return f"Element {self.id} (Inventory {self.inventory.id})"

View File

@ -0,0 +1,56 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator
from .models import Element, InventoryItem, Partner
import logging
logger = logging.getLogger("root")
class PartnerSerializer(serializers.ModelSerializer):
total_inventory = serializers.SerializerMethodField()
class Meta:
model = Partner
fields = ["id", "external_id", "name", "total_inventory"]
def get_total_inventory(self, instance):
return InventoryItem.objects.filter(partner=instance).count()
class InventorySerializer(serializers.ModelSerializer):
partner_name = serializers.CharField(source="partner.name", read_only=True)
total_elements = serializers.SerializerMethodField()
class Meta:
model = InventoryItem
fields = [
"id",
"name",
"created_at",
"total_elements",
"partner",
"partner_name",
]
def get_total_elements(self, instance):
return Element.objects.filter(inventory=instance).count()
class ElementSerializer(serializers.ModelSerializer):
inventory_name = serializers.CharField(source="inventory.name", read_only=True)
element_id = serializers.CharField(
max_length=100,
validators=[UniqueValidator(queryset=Element.objects.all())]
)
class Meta:
model = Element
fields = [
"id",
"external_id",
"element_id",
"photo",
"additional_text",
"inventory",
"inventory_name",
]

251
back/inventory/views.py Normal file
View File

@ -0,0 +1,251 @@
import requests
import datetime
from django.conf import settings
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework import status
from rest_framework.response import Response
from .models import Element, InventoryItem, Partner
from .serializers import ElementSerializer, InventorySerializer, PartnerSerializer
import logging
logger = logging.getLogger("root")
class PartnerViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows partners to be viewed or edited.
"""
queryset = Partner.objects.all()
serializer_class = PartnerSerializer
@action(detail=False, methods=["get"], url_path=r"external")
def get_remote_partners(self, request):
params = {
"$format": "json",
"$select": ",".join(["НаименованиеПолное", "Description", "Ref_Key"]),
"$filter": "Недействителен eq false",
}
remote_url = (
"https://1c.svs-tech.pro/UNF/odata/standard.odata/Catalog_Контрагенты?"
+ "&".join([f"{p}={params[p]}" for p in params])
)
data = requests.get(remote_url, headers={"Authorization": settings.ODATA_AUTH})
try:
parsed_data = data.json()
return Response(parsed_data["value"])
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=["get"], url_path=r"external/(?P<cat_id>[^/.]+)")
def get_remote_partner_one(self, request, cat_id):
params = {
"$format": "json",
"$select": ",".join(["НаименованиеПолное", "Description", "Ref_Key"]),
}
remote_url = (
f"https://1c.svs-tech.pro/UNF/odata/standard.odata/Catalog_Контрагенты(guid'{cat_id}')?"
+ "&".join([f"{p}={params[p]}" for p in params])
)
logger.info(remote_url)
data = requests.get(remote_url, headers={"Authorization": settings.ODATA_AUTH})
try:
parsed_data = data.json()
return Response(parsed_data)
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def get_depth_cat(id):
params = {
"$format": "json",
"$select": ",".join(["Description", "Ref_Key", "Parent_Key"]),
"$filter": f"Parent_Key eq guid'{id}'",
}
remote_url = (
"https://1c.svs-tech.pro/UNF/odata/standard.odata/Catalog_КатегорииНоменклатуры?"
+ "&".join([f"{p}={params[p]}" for p in params])
)
data = requests.get(remote_url, headers={"Authorization": settings.ODATA_AUTH})
parsed_data = data.json()
return parsed_data["value"]
class InventoryItemViewSet(viewsets.ModelViewSet):
queryset = InventoryItem.objects.all()
serializer_class = InventorySerializer
def get_queryset(self):
queryset = InventoryItem.objects.all()
partner = self.request.query_params.get("partner_id")
if partner is not None:
queryset = queryset.filter(partner=partner)
return queryset
def create(self, request, **kwargs):
data = request.data
# check if partner exist
if Partner.objects.filter(external_id=data["partner"]).exists():
partner_object = Partner.objects.get(external_id=data["partner"])
else:
partner_object = Partner.objects.create(
external_id=data["partner"],
name=data["partner_name"],
)
partner_serializer = PartnerSerializer(partner_object, many=False)
inventory_object = InventoryItem.objects.create(
partner=partner_object,
name=f"{data['partner_name']} {datetime.datetime.now()}",
)
inventory_serializer = InventorySerializer(inventory_object, many=False)
return Response(
{
"partner": partner_serializer.data,
"inventory": inventory_serializer.data,
}
)
class ElementViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows elements to be viewed or edited.
"""
queryset = Element.objects.all()
serializer_class = ElementSerializer
def get_queryset(self):
queryset = Element.objects.all()
inventory = self.request.query_params.get("inventory_id")
if inventory is not None:
queryset = queryset.filter(inventory=inventory)
return queryset
def create(self, request, **kwargs):
data = request.data
# check if partner exist
if Partner.objects.filter(external_id=data["partner"]).exists():
partner_object = Partner.objects.get(external_id=data["partner"])
else:
partner_object = Partner.objects.create(
external_id=data["partner"],
name=data["partner_name"],
)
partner_serializer = PartnerSerializer(partner_object, many=False)
# check if inventory exist
if (
"inventory"
in data
# and InventoryItem.objects.filter(id=data["inventory"]).exists()
):
inventory_object = InventoryItem.objects.get(id=data["inventory"])
else:
inventory_object = InventoryItem.objects.create(
partner=partner_object,
name=f"{data['partner_name']} {datetime.datetime.now()}",
)
inventory_serializer = InventorySerializer(inventory_object, many=False)
element_data = {
"inventory": inventory_object.id,
"external_id": data["element"],
"element_id": data["element_id"],
}
if "element_additional_data" in data:
element_data["additional_text"] = data["element_additional_data"]
element_serializer = ElementSerializer(data=element_data, many=False)
if element_serializer.is_valid():
element_data["inventory"] = inventory_object
Element.objects.create(**element_data)
return Response(
{
"partner": partner_serializer.data,
"inventory": inventory_serializer.data,
"element": element_serializer.data,
}
)
else:
return Response(
element_serializer.errors, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(
detail=False,
methods=["get"],
url_path=r"external_categories",
)
def get_remote_categories(self, request, cat_id=None):
try:
categories = get_depth_cat("87e91e07-7e10-11ee-ab5a-a47a2bd811cb")
categories.extend(get_depth_cat("20e1e6f6-a575-11ee-ab60-ec3c37e2e642"))
categories.extend(get_depth_cat('1f89290b-ffaf-11ed-ab4e-e3e667c628bd'))
return Response(categories)
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(
detail=False,
methods=["get"],
url_path=r"external_categories/(?P<cat_id>[^/.]+)",
)
def get_remote_categories_child(self, request, cat_id=None):
try:
categories = get_depth_cat(cat_id)
return Response(categories)
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=["get"], url_path=r"external/(?P<cat_id>[^/.]+)")
def get_remote_element(self, request, pk=None, cat_id=None):
try:
params = {
"$format": "json",
"$select": ",".join(["Description", "Ref_Key", "Parent_Key"]),
"$filter": f"КатегорияНоменклатуры_Key eq guid'{cat_id}'",
}
remote_url = (
"https://1c.svs-tech.pro/UNF/odata/standard.odata/Catalog_Номенклатура?"
+ "&".join([f"{p}={params[p]}" for p in params])
)
data = requests.get(
remote_url, headers={"Authorization": settings.ODATA_AUTH}
)
parsed_data = data.json()
return Response(parsed_data["value"])
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=["get"], url_path=r"external/id/(?P<cat_id>[^/.]+)")
def get_remote_element_by_id(self, request, pk=None, cat_id=None):
try:
params = {
"$format": "json",
"$select": ",".join(["Description", "Ref_Key", "Parent_Key"]),
}
remote_url = (
f"https://1c.svs-tech.pro/UNF/odata/standard.odata/Catalog_Номенклатура(guid'{cat_id}')?"
+ "&".join([f"{p}={params[p]}" for p in params])
)
data = requests.get(
remote_url, headers={"Authorization": settings.ODATA_AUTH}
)
parsed_data = data.json()
return Response(parsed_data)
except Exception as e:
logger.error(e)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

989
back/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,14 +2,25 @@
name = "back" name = "back"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = ["k.mikhailova@svs-tech.pro"] authors = ["Ksenia Miqhailova <k.mikhailova@svs-tech.pro>"]
readme = "readme.md" readme = "readme.md"
package-mode = false
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.11"
Django = "^5.0.6" Django = "^5.0.6"
djangorestframework = "^3.15.1" djangorestframework = "^3.15.1"
psycopg2-binary = "^2.9.9"
markdown = "^3.6"
django-filter = "^24.2"
pillow = "^10.3.0"
python-dotenv = "^1.0.1"
requests = "^2.32.2"
django-cors-headers = "^4.3.1"
python-telegram-bot = { extras = ["job-queue"], version = "^21.3" }
more-itertools = "^10.3.0"
djangoviz = "^0.1.1"
celery = { extras = ["redis"], version = "^5.4.0" }
boto3 = "^1.34.154"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
taskipy = "^1.12.2" taskipy = "^1.12.2"
@ -20,4 +31,5 @@ build-backend = "poetry.core.masonry.api"
[tool.taskipy.tasks] [tool.taskipy.tasks]
migrate = "python manage.py makemigrations && python manage.py migrate" migrate = "python manage.py makemigrations && python manage.py migrate"
server = "python manage.py runserver" server = "python manage.py runserver 0.0.0.0:8000"
celery = "celery worker --loglevel=info"

38
back/requirements.txt Normal file
View File

@ -0,0 +1,38 @@
asgiref==3.8.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47 \
--hash=sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590
django-filter==24.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:48e5fc1da3ccd6ca0d5f9bb550973518ce977a4edde9d2a8a154a7f4f0b9f96e \
--hash=sha256:df2ee9857e18d38bed203c8745f62a803fa0f31688c9fe6f8e868120b1848e48
django==5.0.6 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905 \
--hash=sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f
djangorestframework==3.15.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6 \
--hash=sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1
markdown==3.6 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f \
--hash=sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224
psycopg2==2.9.9 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981 \
--hash=sha256:38a8dcc6856f569068b47de286b472b7c473ac7977243593a288ebce0dc89516 \
--hash=sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3 \
--hash=sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa \
--hash=sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a \
--hash=sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693 \
--hash=sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372 \
--hash=sha256:bac58c024c9922c23550af2a581998624d6e02350f4ae9c5f0bc642c633a2d5e \
--hash=sha256:c92811b2d4c9b6ea0285942b2e7cac98a59e166d59c588fe5cfe1eda58e72d59 \
--hash=sha256:d1454bde93fb1e224166811694d600e746430c006fbb031ea06ecc2ea41bf156 \
--hash=sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024 \
--hash=sha256:de80739447af31525feddeb8effd640782cf5998e1a4e9192ebdf829717e3913 \
--hash=sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c
sqlparse==0.5.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93 \
--hash=sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663
typing-extensions==4.12.0 ; python_version >= "3.10" and python_version < "3.11" \
--hash=sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8 \
--hash=sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594
tzdata==2024.1 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" \
--hash=sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd \
--hash=sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252

7
back/tgbot/admin.py Normal file
View File

@ -0,0 +1,7 @@
from django.contrib import admin
from .models import TgItem, TmcElement, TmcField
# Register your models here.
admin.site.register(TgItem)
admin.site.register(TmcElement)
admin.site.register(TmcField)

20
back/tgbot/apps.py Normal file
View File

@ -0,0 +1,20 @@
from django.apps import AppConfig
import threading
import os
class TgbotConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "tgbot"
def ready(self):
from .updater import tg_bot_updater_instance
if not tg_bot_updater_instance.is_run and os.environ.get("RUN_MAIN", None) == "true":
threading.Thread(
target=(tg_bot_updater_instance.run_func),
name="tg_updater_thread",
daemon=True,
).start()
tg_bot_updater_instance.is_run = True
return super().ready()

View File

49
back/tgbot/models.py Normal file
View File

@ -0,0 +1,49 @@
import uuid
from django.db import models
from tmc.models import CustomTable, BaseCustomField, TerritoryItem
import logging
logger = logging.getLogger("root")
def group_based_upload_to(instance, filename):
logger.info(instance)
return "files/image/{}/{}/{}".format(
type(instance).__name__.lower(), instance.id, filename
)
class TmcField(models.Model):
field = models.ForeignKey(BaseCustomField, models.RESTRICT)
text = models.CharField(null=True, blank=True)
file_id = models.CharField(null=True, blank=True)
image_aws_url = models.CharField(null=True, blank=True)
class TmcElement(models.Model):
tmc = models.ForeignKey(CustomTable, models.RESTRICT)
name = models.CharField(null=True, blank=True)
field = models.ManyToManyField(TmcField)
class TgItem(models.Model):
id = models.UUIDField(
# auto_created=True,
primary_key=True,
default=uuid.uuid4,
editable=False,
unique=True,
)
user_id = models.BigIntegerField()
name = models.CharField(max_length=255)
location = models.ForeignKey(TerritoryItem, models.RESTRICT)
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
tmc = models.ManyToManyField(TmcElement)
is_check = models.BooleanField()
def __str__(self):
return f"Tg item {self.name}"

11
back/tgbot/pagination.py Normal file
View File

@ -0,0 +1,11 @@
from rest_framework import pagination
from rest_framework.response import Response
class CustomPagination(pagination.PageNumberPagination):
page_size_query_param = 'size'
def get_paginated_response(self, data):
return Response({
'count': self.page.paginator.count,
'per_page': self.page.paginator.per_page,
'results': data
})

74
back/tgbot/serializers.py Normal file
View File

@ -0,0 +1,74 @@
from rest_framework import serializers
from django.db.models import Count
from tmc.models import Territory, CustomTable
from tmc.serializers import TerritorySerializer
from .models import TgItem, TmcElement, TmcField
import logging
logger = logging.getLogger("root")
class CustomTableSerializer(serializers.ModelSerializer):
class Meta:
model = CustomTable
fields = ["name"]
class TmcElementSerializer(serializers.Serializer):
tmc__name = serializers.CharField()
count = serializers.IntegerField()
class TerritorySerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
count = serializers.IntegerField(required=False)
class TgItemSerializer(serializers.ModelSerializer):
class Meta:
model = TgItem
fields = "__all__"
depth = 3
class TgStatItemSerializer(serializers.Serializer):
location = serializers.SerializerMethodField(required=False)
inv_count = serializers.IntegerField()
tmc = serializers.SerializerMethodField()
def get_location(self, obj):
if not obj.get("location__parent"):
return None
if isinstance(obj.get("location__parent"), list):
location_parent_ids = obj.get("location__parent")
queryset = Territory.objects.filter(id__in=obj.get("location__parent"))
for q in queryset:
q.count = location_parent_ids.count(q.id)
serializer = TerritorySerializer(queryset, many=True)
else:
queryset = Territory.objects.get(id=obj.get("location__parent"))
serializer = TerritorySerializer(queryset)
return serializer.data
def get_tmc(self, obj):
queryset = (
TmcElement.objects.filter(id__in=obj.get("tmc"))
.values("tmc__name")
.annotate(count=Count("id"))
)
serializer = TmcElementSerializer(queryset, many=True)
return serializer.data
class Meta:
depth = 2
class TmcFieldSerializer(serializers.ModelSerializer):
class Meta:
model = TmcField
fields = "__all__"

37
back/tgbot/tasks.py Normal file
View File

@ -0,0 +1,37 @@
import os
import boto3
import time
import requests
from django.conf import settings
from celery import shared_task
from celery.utils.log import get_task_logger
from api.celery import celery_app
from .models import TmcField
logger = get_task_logger(__name__)
aws_access_key_id = os.environ.get("AWS_ACCESS")
aws_secret_access_key = os.environ.get("AWS_SECRET")
@celery_app.task
def upload_file(file_id):
if not TmcField.objects.filter(file_id=file_id).exists():
return
obj = TmcField.objects.get(file_id=file_id)
logger.info(f"start upload file {file_id}")
file_url = requests.get(f"https://api.telegram.org/bot{settings.TGBOT['TOKEN']}/getFile?file_id={file_id}")
file_json = file_url.json()
response = f"https://api.telegram.org/file/bot{settings.TGBOT['TOKEN']}/{file_json['result']['file_path']}"
logger.info(f"get tg url {response}")
r = requests.get(response, stream=True)
s3 = boto3.client(
service_name="s3",
endpoint_url="https://s3.ru-1.storage.selcloud.ru",
aws_access_key_id=settings.SELECTEL['access'],
aws_secret_access_key=settings.SELECTEL['secret'],
)
s3.upload_fileobj(r.raw, 'inventorization', file_id)
obj.image_aws_url = file_id

3
back/tgbot/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

615
back/tgbot/tgbot.py Normal file
View File

@ -0,0 +1,615 @@
import traceback
import html
import json
import logging
import re
import more_itertools as mit
from telegram import (
Update,
ReplyParameters,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
KeyboardButton,
InlineKeyboardMarkup,
InlineKeyboardButton,
)
from telegram.ext import (
ApplicationBuilder,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
CallbackContext,
ContextTypes,
filters,
)
from telegram.constants import ParseMode
from django.conf import settings
from django.urls import reverse
from django.db import models
from .models import TgItem, TmcElement, TmcField
from .tasks import upload_file
from tmc.models import CustomTable, BaseCustomField, Territory, TerritoryItem
logger = logging.getLogger(__name__)
def chunk(n, l):
return [l[i : i + n] for i in range(0, len(l), n)]
class TgBot:
_app = None
def __init__(self) -> None:
self.token = settings.TGBOT["TOKEN"]
self.baseurl = settings.TGBOT["BASE_URL"]
self.webhook = settings.TGBOT["WEBHOOK_URL"]
if not self.token or not self.baseurl:
raise Exception("no token or baseurl")
TgBot.app = (
ApplicationBuilder().token(settings.TGBOT["TOKEN"]).updater(None).build()
)
async def set_webhook(self):
await TgBot.app.bot.setWebhook(
f"https://{self.baseurl}{reverse('tgitem-send-tg-data')}",
drop_pending_updates=True,
)
async def start_app(self):
await TgBot.app.initialize()
async def admin_action(self, name, queryset):
from .updater import tg_bot_updater_instance
if name == "admin_get_image":
item = queryset
try:
result = await TgBot.app.bot.get_file(item)
tg_bot_updater_instance.return_values[item] = result.file_path
except Exception as e:
tg_bot_updater_instance.return_values[item] = None
if name == "admin_get_name":
item = queryset
try:
result = await TgBot.app.bot.get_chat(item)
tg_bot_updater_instance.return_values[item] = result.effective_name
except Exception as e:
tg_bot_updater_instance.return_values[item] = None
async def set_handlers(self):
TgBot.app.add_handler(
CommandHandler("start", self.start, filters.ChatType.PRIVATE)
)
TgBot.app.add_handler(CommandHandler("my", self.my, filters.ChatType.PRIVATE))
TgBot.app.add_handler(CommandHandler("inv", self.inv, filters.ChatType.PRIVATE))
TgBot.app.add_handler(
MessageHandler(
(
filters.ChatType.PRIVATE
& ~filters.COMMAND
& (filters.TEXT | filters.PHOTO)
),
self.inv,
)
)
TgBot.app.add_handler(CommandHandler("ter", self.ter, filters.ChatType.PRIVATE))
TgBot.app.add_handler(CallbackQueryHandler(self.ter_back, "ter_back"))
TgBot.app.add_handler(CallbackQueryHandler(self.ter_next, "ter_next"))
TgBot.app.add_handler(
MessageHandler(
filters.ChatType.PRIVATE & filters.Regex(r"/ter_(\d.*)"),
self.terdeep,
)
)
TgBot.app.add_handler(CallbackQueryHandler(self.terdeep_back, "terdeep_back"))
TgBot.app.add_handler(CallbackQueryHandler(self.terdeep_next, "terdeep_next"))
TgBot.app.add_handler(
MessageHandler(
filters.ChatType.PRIVATE & filters.Regex(r"/terdeep_(\d.*)"),
self.terdeep_set,
)
)
TgBot.app.add_handler(CallbackQueryHandler(self.stop_inv, "stop_inv"))
TgBot.app.add_handler(CallbackQueryHandler(self.get_inv, r"get_inv@(.*?)"))
TgBot.app.add_handler(CallbackQueryHandler(self.add_tmc, r"add_tmc@(.*?)"))
TgBot.app.add_handler(
CallbackQueryHandler(self.add_element, r"add_element@(.*?)")
)
TgBot.app.add_error_handler(self.error_handler)
async def start(self, update: Update, context: CallbackContext):
await update.effective_message.reply_html(
(
"Это бот для проведения инвентаризации\n"
"<strong>/ter</strong> — список территорий\n"
"<strong>/inv</strong> — начать новую инвентаризацию\n"
"<strong>/my</strong> — продолжить инвентаризацию\n"
),
# reply_markup=ForceReply(selective=True),
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
)
async def my(self, update: Update, context: CallbackContext):
user = update.effective_user
current_step = context.chat_data.get("step", None)
logger.info(f"Step {current_step} from user {user.full_name} with data {context.chat_data}")
inv = []
async for e in TgItem.objects.filter(user_id=user.id, is_check=False).order_by('-created_at'):
inv.append({"name": e.name, "id": str(e.id)})
keys = chunk(1, inv)
if len(inv) > 0:
await update.message.reply_html(
("<strong>Ваши инвентаризации</strong>"),
reply_markup=InlineKeyboardMarkup(
[
[
InlineKeyboardButton(
i["name"],
callback_data=f'get_inv@{i["id"]}',
)
for i in arr
]
for arr in keys
]
),
reply_parameters=ReplyParameters(message_id=update.message.message_id),
)
else:
await update.message.reply_html(
"У вас нет доступных для редактирования инвентаризаций"
)
def format_username(self, user):
return f"Специалист {user.name or user.full_name}, ID <code>{user.id}</code>"
def format_inv(self, inv):
return f"Инвентаризация <strong>{inv.name}</strong> от <code>{inv.created_at.strftime('%x')}</code>"
async def get_tmc_count(self, inv):
tmc_count = []
async for e in inv.tmc.values("tmc__name").annotate(
count=models.Count("tmc__name")
):
tmc_count.append(e)
return tmc_count
def format_tmc_count(self, tmc_count):
res = ["Всего ТМЦ:"]
res.append(", ".join([f"{e['tmc__name']} ({e['count']})" for e in tmc_count]))
return " ".join(res)
def format_tmc_name(self, tmc):
return f"Название ТМЦ <code>{tmc.name}</code>"
def format_element(self, field):
return f"Элемент <code>{field.name}</code>"
def stop_inv_button(self):
return [
InlineKeyboardButton(
"❌❌❌ Остановить инвентаризацию", callback_data="stop_inv"
)
]
async def stop_inv(self, update: Update, context: CallbackContext):
query = update.callback_query
await update.effective_message.edit_reply_markup(InlineKeyboardMarkup([]))
await query.answer()
context.chat_data.clear()
await self.start(update, context)
async def get_inv(self, update: Update, context: CallbackContext):
query = update.callback_query
await update.effective_message.edit_reply_markup(InlineKeyboardMarkup([]))
await query.answer()
inv_id = query.data.split("@")[-1]
inv = await TgItem.objects.aget(id=inv_id)
context.chat_data["terdeep_value"] = inv.location_id
context.chat_data["inv"] = inv.id
context.chat_data["step"] = "name"
await self.inv(update, context)
async def add_tmc(self, update: Update, context: CallbackContext):
query = update.callback_query
if update.callback_query:
await update.effective_message.edit_reply_markup(InlineKeyboardMarkup([]))
await query.answer()
tmc_id = query.data.split("@")[-1]
tmc = await CustomTable.objects.aget(id=tmc_id)
tmc_element = await TmcElement.objects.acreate(tmc=tmc)
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
async for f in tmc.fields.all():
tmc_field = await TmcField.objects.acreate(field=f)
await tmc_element.field.aadd(tmc_field)
await inv.tmc.aadd(tmc_element)
await inv.asave()
context.chat_data["tmc"] = tmc_element.id
context.chat_data["step"] = "add_tmc"
await self.inv(update, context)
async def add_element(self, update: Update, context: CallbackContext):
query = update.callback_query
if update.callback_query:
await update.effective_message.edit_reply_markup(InlineKeyboardMarkup([]))
await query.answer()
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
tmc = await inv.tmc.aget(id=context.chat_data["tmc"])
field_id = query.data.split("@")[-1]
tmc_field = await tmc.field.aget(id=field_id)
context.chat_data["element"] = tmc_field.id
context.chat_data["step"] = "add_element"
await self.inv(update, context)
def set_step(self, update: Update, context: CallbackContext, prefix="ter"):
context.chat_data["step"] = f"{prefix}"
context.chat_data[f"{prefix}_start"] = 0
context.chat_data[f"{prefix}_count"] = 10
context.chat_data[f"{prefix}_renew"] = False
async def step_btn(
self, update: Update, context: CallbackContext, prefix="ter", step_type="plus"
):
query = update.callback_query
await query.answer()
step_start = context.chat_data[f"{prefix}_start"]
step_count = context.chat_data[f"{prefix}_count"]
context.chat_data[f"{prefix}_renew"] = True
if step_type == "plus":
context.chat_data[f"{prefix}_start"] += step_count
elif step_type == "minus":
context.chat_data[f"{prefix}_start"] -= step_count
func = getattr(self, prefix)
await func(update, context)
async def paged_data(
self, update: Update, context: CallbackContext, prefix="ter", queryset=[]
):
user = update.effective_user
current_step = context.chat_data.get("step", None)
logger.info(f"Step {current_step} from user {user.full_name} with data {context.chat_data}")
locations = []
async for e in queryset:
locations.append({"name": e.name, "id": e.id})
step_start = context.chat_data[f"{prefix}_start"]
step_step = context.chat_data[f"{prefix}_count"]
step_end = step_start + step_step
text = "\n".join(
[
self.format_username(user),
"Выберите объект:",
"\n".join(
[
f"/{prefix}_{i['id']} {i['name']}"
for i in locations[step_start:step_end]
]
),
]
)
keyboard = []
if step_start > 0:
keyboard.append(
InlineKeyboardButton(text="", callback_data=f"{prefix}_back")
)
if step_end < len(locations):
keyboard.append(
InlineKeyboardButton(text="", callback_data=f"{prefix}_next")
)
keyboard = [keyboard]
renew = context.chat_data.get(f"{prefix}_renew", None)
logger.info(update.message)
if renew:
await update.effective_message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode=ParseMode.HTML,
)
else:
await update.effective_message.reply_html(
text,
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
async def ter_back(self, update: Update, context: CallbackContext):
await self.step_btn(update, context, "ter", "minus")
async def ter_next(self, update: Update, context: CallbackContext):
await self.step_btn(update, context, "ter", "plus")
async def ter(self, update: Update, context: CallbackContext):
if not update.callback_query:
self.set_step(update, context, "ter")
await self.paged_data(update, context, "ter", Territory.objects.all())
async def terdeep_back(self, update: Update, context: CallbackContext):
await self.step_btn(update, context, "terdeep", "minus")
async def terdeep_next(self, update: Update, context: CallbackContext):
await self.step_btn(update, context, "terdeep", "plus")
async def terdeep(self, update: Update, context: CallbackContext):
if not update.callback_query:
self.set_step(update, context, "terdeep")
parent = context.chat_data.get("terdeep", None)
if len(re.findall(r"/ter_(\d.*)", update.effective_message.text)):
parent = re.findall(r"/ter_(\d.*)", update.effective_message.text)[0]
context.chat_data["terdeep"] = parent
context.chat_data["terdeep_renew"] = False
await self.paged_data(
update, context, "terdeep", TerritoryItem.objects.filter(parent_id=parent)
)
async def terdeep_set(self, update: Update, context: CallbackContext):
context.chat_data["terdeep_value"] = re.findall(
r"/terdeep_(\d.*)", update.effective_message.text
)[0]
text = (
"Вы выбрали территорию инвентаризации\n"
"Теперь вы можете начать инвентаризацию /inv"
)
await update.effective_message.reply_html(
text=text,
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
)
async def inv(self, update: Update, context: CallbackContext):
user = update.effective_user
current_step = context.chat_data.get("step", None)
logger.info(f"Step {current_step} from user {user.full_name} with data {context.chat_data}")
if not context.chat_data.get("terdeep_value", None):
await update.effective_message.reply_html(
text=("Вы не выбрали территорию /ter"),
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
)
return
if update.effective_message.text == "/inv":
if 'inv' in context.chat_data:
del context.chat_data["inv"]
context.chat_data["step"] = "name"
current_ter_id = context.chat_data.get("terdeep_value", None)
current_ter = await TerritoryItem.objects.aget(id=current_ter_id)
await update.effective_message.reply_html(
"\n".join([self.format_username(user), f"Введите название объекта"]),
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
reply_markup=ReplyKeyboardMarkup([[KeyboardButton(current_ter.name)]]),
)
return
if current_step == "name":
if not context.chat_data.get("inv", None):
current_ter_id = context.chat_data.get("terdeep_value", None)
current_ter = await TerritoryItem.objects.aget(id=current_ter_id)
inv = await TgItem.objects.acreate(
user_id=user.id, location=current_ter
)
inv.name = update.message.text
await inv.asave()
await update.effective_message.reply_html(
f"Ок, сохранено название {inv.name}",
reply_markup=ReplyKeyboardRemove(),
)
# await res.delete()
else:
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
tmc = []
async for e in CustomTable.objects.all():
tmc.append({"name": e.name, "id": e.id})
keys = chunk(2, tmc)
context.chat_data["inv"] = inv.id
context.chat_data["step"] = "add_tmc"
text = "\n".join(
[
self.format_inv(inv),
self.format_tmc_count(await self.get_tmc_count(inv)),
"Выберите, какую ТМЦ вы осматриваете:",
]
)
keyboard = [
[
InlineKeyboardButton(
i["name"],
callback_data=f'{context.chat_data["step"]}@{i["id"]}',
)
for i in arr
]
for arr in keys
]
keyboard.append(self.stop_inv_button())
await update.effective_message.reply_html(
text,
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
reply_markup=InlineKeyboardMarkup(keyboard),
)
elif current_step == "add_tmc":
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
tmc_element = await inv.tmc.aget(id=context.chat_data["tmc"])
tmc = await CustomTable.objects.aget(id=tmc_element.tmc_id)
fields = []
async for e in tmc_element.field.filter(text=None, file_id=None):
f = await BaseCustomField.objects.aget(id=e.field_id)
fields.append({"name": f.name, "id": e.id})
keys = chunk(1, fields)
context.chat_data["tmc"] = tmc_element.id
context.chat_data["step"] = "add_element"
text = "\n".join(
[
self.format_inv(inv),
self.format_tmc_count(await self.get_tmc_count(inv)),
self.format_tmc_name(tmc),
f"Что вы загружаете?",
]
)
keyboard = [
[
InlineKeyboardButton(
i["name"],
callback_data=f'{context.chat_data["step"]}@{i["id"]}',
)
for i in arr
]
for arr in keys
]
keyboard.append(self.stop_inv_button())
await update.effective_message.reply_html(
text,
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
reply_markup=InlineKeyboardMarkup(keyboard),
)
elif current_step == "add_element":
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
tmc_element = await inv.tmc.aget(id=context.chat_data["tmc"])
tmc = await CustomTable.objects.aget(id=tmc_element.tmc_id)
element = await TmcField.objects.aget(id=context.chat_data["element"])
field = await BaseCustomField.objects.aget(id=element.field_id)
context.chat_data["step"] = "add_element_data"
await update.effective_message.reply_html(
"\n".join(
[
self.format_inv(inv),
self.format_tmc_count(await self.get_tmc_count(inv)),
self.format_tmc_name(tmc),
self.format_element(field),
f"Загрузите фото или пришлите текст",
]
),
reply_parameters=ReplyParameters(
message_id=update.effective_message.message_id
),
reply_markup=ReplyKeyboardRemove(),
)
elif current_step == "add_element_data":
inv = await TgItem.objects.aget(id=context.chat_data["inv"])
tmc_element = await inv.tmc.aget(id=context.chat_data["tmc"])
tmc = await CustomTable.objects.aget(id=tmc_element.tmc_id)
element = await TmcField.objects.aget(id=context.chat_data["element"])
field = await BaseCustomField.objects.aget(id=element.field_id)
if update.message.photo:
element.file_id = update.message.photo[-1].file_id
upload_file.delay(element.file_id)
if update.message.caption:
element.text = update.message.caption
if update.message.text:
element.text = update.message.text
await element.asave()
await inv.asave()
await update.effective_message.reply_html(
"Изображение сохранено" if update.message.photo else "Текст сохранен"
)
empty_fields = await tmc_element.field.filter(
text=None, file_id=None
).acount()
if empty_fields > 0:
context.chat_data["step"] = "add_tmc"
await self.inv(update, context)
else:
context.chat_data["step"] = "name"
await self.inv(update, context)
else:
# logger.info(update.message.entities)
logger.info(f"no step for update {update}")
if "step" in context.chat_data and context.chat_data["step"] == current_step:
context.chat_data["step"] = None
context.chat_data["inv"] = None
async def error_handler(
self, update: object, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""Log the error and send a telegram message to notify the developer."""
# Log the error before we do anything else, so we can see it even if something breaks.
# logger.error("Exception while handling an update:", exc_info=context.error)
# traceback.format_exception returns the usual python message about an exception, but as a
# list of strings rather than a single string, so we have to join them together.
tb_list = traceback.format_exception(
None, context.error, context.error.__traceback__
)
tb_string = "".join(tb_list)
# Build the message with some markup and additional information about what happened.
# You might need to add some logic to deal with messages longer than the 4096 character limit.
update_str = update.to_dict() if isinstance(update, Update) else str(update)
message = (
"An exception was raised while handling an update\n"
f"<pre>update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
"</pre>\n\n"
f"<pre>context.chat_data = {html.escape(str(context.chat_data))}</pre>\n\n"
f"<pre>context.user_data = {html.escape(str(context.user_data))}</pre>\n\n"
f"<pre>{html.escape(tb_string)}</pre>"
)
# logger.error(context.error)
logger.info(f"error in tgbot {context.error}\n{tb_string}\nReply update")

71
back/tgbot/updater.py Normal file
View File

@ -0,0 +1,71 @@
import asyncio
import queue
from .tgbot import TgBot
class TgBotUpdater:
is_run = False
app = None
tgbot_class = None
my_queue = queue.Queue()
return_values = dict()
import logging
logger = logging.getLogger(__name__)
def __init__(self) -> None:
self.loop = asyncio.get_event_loop()
try:
self.tgbot_class = TgBot()
except Exception as e:
self.logger.error(e)
async def _set_webhook(self):
await self.tgbot_class.set_webhook()
async def _start_app(self):
await self.tgbot_class.start_app()
async def _set_hadlers(self):
await self.tgbot_class.set_handlers()
async def _run_func(self):
from .tgbot import TgBot
while hasattr(TgBot, "app"):
# self.logger.info(f"check updates in {await TgBot.app.bot.get_webhook_info()}")
if not self.my_queue.empty():
item = self.my_queue.get()
if (
isinstance(item, dict)
and "name" in item
and item["name"].startswith("admin_")
):
await self.tgbot_class.admin_action(item["name"], item["queryset"])
else:
try:
await TgBot.app.process_update(item)
except Exception as e:
print(f"Error in tg thread {e}")
await TgBot.app.process_update(item)
self.my_queue.task_done()
await asyncio.sleep(1)
async def main(self):
await asyncio.gather(
self._set_webhook(),
self._set_hadlers(),
self._start_app(),
self._run_func(),
)
def run_func(self):
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self.main())
self.loop.close()
tg_bot_updater_instance = TgBotUpdater()

160
back/tgbot/views.py Normal file
View File

@ -0,0 +1,160 @@
from django.conf import settings
from django.db import models
from django.http import HttpResponse
import json
import time
import boto3
from telegram import Update
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.decorators import action
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Count, Subquery, OuterRef, Value
from django.db.models.functions import Concat
from django.contrib.postgres.aggregates import ArrayAgg
from .tgbot import TgBot
from .updater import tg_bot_updater_instance
from .models import TgItem, TmcElement, TmcField
from .serializers import TgItemSerializer, TmcFieldSerializer, TgStatItemSerializer
import logging
logger = logging.getLogger("root")
class TgItemViewSet(viewsets.ModelViewSet):
queryset = TgItem.objects.filter(is_check=True).order_by("-created_at")
serializer_class = TgItemSerializer
http_method_names = ["post", "get", "patch", "delete"]
permission_classes = ()
authentication_classes = ()
filter_backends = [DjangoFilterBackend]
filterset_fields = ["user_id"]
def retrieve(self, request, pk=None):
item = TgItem.objects.get(id=pk)
logger.info(
item.tmc.values("tmc__name").annotate(count=models.Count("tmc__name"))
)
return super().retrieve(request, pk)
# return Response(serializer.data)
def partial_update(self, request, *args, **kwargs):
if "location_id" in request.data:
request.data["location"] = 35
return super().partial_update(request)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
if instance.tmc.count() == 0:
return super().destroy(request)
else:
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=False, methods=["post"], url_path=settings.TGBOT["WEBHOOK_URL"])
def send_tg_data(self, request):
tg_bot_updater_instance.my_queue.put(
Update.de_json(data=json.loads(request.body), bot=TgBot.app.bot)
)
return Response({"result": "ok"})
# return super().create(request, *args, **kwargs)
class TmcFieldViewset(viewsets.ModelViewSet):
queryset = TmcField.objects.all()
serializer_class = TmcFieldSerializer
http_method_names = ["post", "get", "patch"]
permission_classes = ()
authentication_classes = ()
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request)
@action(detail=False, methods=["get"], url_path=r"get_name/(?P<chat_id>[^/.]+)")
def get_name(self, request, chat_id):
tg_bot_updater_instance.my_queue.put(
{"name": "admin_get_name", "queryset": chat_id}
)
response = []
timer = 30
while timer > 0:
sleeping = 1
timer -= sleeping
time.sleep(sleeping)
if chat_id in tg_bot_updater_instance.return_values:
response.append(tg_bot_updater_instance.return_values[chat_id])
del tg_bot_updater_instance.return_values[chat_id]
break
return Response(response)
@action(detail=False, methods=["get"], url_path=r"get_image/(?P<field_id>[^/.]+)")
def get_image(self, request, field_id):
tg_bot_updater_instance.my_queue.put(
{"name": "admin_get_image", "queryset": field_id}
)
response = []
timer = 30
while timer > 0:
sleeping = 1
timer -= sleeping
time.sleep(sleeping)
if field_id in tg_bot_updater_instance.return_values:
response.append(tg_bot_updater_instance.return_values[field_id])
del tg_bot_updater_instance.return_values[field_id]
break
return Response(response)
@action(detail=False, methods=["get"], url_path=r"get_image_s3/(?P<file_id>[^/.]+)")
def get_image_s3(self, request, file_id):
s3 = boto3.client(
service_name="s3",
endpoint_url="https://s3.ru-1.storage.selcloud.ru",
aws_access_key_id=settings.SELECTEL["access"],
aws_secret_access_key=settings.SELECTEL["secret"],
)
get_object_response = s3.get_object(Bucket="inventorization", Key=file_id)
image = get_object_response["Body"].read()
response = HttpResponse(image, content_type="image/jpeg")
response["Content-Disposition"] = 'inline; filename="image.jpeg"'
return response
class TmcStatViewset(viewsets.ViewSet):
queryset = TgItem.objects.filter(is_check=True).order_by("-created_at")
http_method_names = ["get"]
def list(self, request):
if 'type' in request.query_params and request.query_params['type'] == 'location':
queryset = (
TgItem.objects.filter(is_check=True)
.values("location__parent")
.annotate(
inv_count=Count("location__parent"),
tmc_count=Count("tmc__tmc_id", distinct=True)
)
.annotate(
tmc=ArrayAgg("tmc"),
)
)
else:
queryset = (
TgItem.objects.all()
.values("tmc__tmc_id")
.annotate(
inv_count=Count("location__parent"),
tmc_count=Count("id", distinct=True)
)
.annotate(
location__parent=ArrayAgg("location__parent"),
tmc=ArrayAgg("tmc"),
)
)
# logger.info(queryset)
# logger.info(TgItem.objects.all().values())
serializer = TgStatItemSerializer(queryset, many=True)
return Response(serializer.data)

0
back/tmc/__init__.py Normal file
View File

8
back/tmc/admin.py Normal file
View File

@ -0,0 +1,8 @@
from django.contrib import admin
from .models import BaseCustomField, CustomTable, Territory, TerritoryItem
admin.site.register(CustomTable)
admin.site.register(BaseCustomField)
admin.site.register(Territory)
admin.site.register(TerritoryItem)

6
back/tmc/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TmcConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tmc'

View File

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
import csv
from tmc.models import Territory, TerritoryItem
class Command(BaseCommand):
help = "Creating model objects according the file path specified"
def handle(self, *args, **options):
file_path = "tmc/management/commands/data.csv"
with open(file_path, mode="r") as file:
csvFile = csv.reader(file)
for lines in csvFile:
if not Territory.objects.filter(name=lines[0]).exists():
parent = Territory.objects.create(name=lines[0])
else:
parent = Territory.objects.get(name=lines[0])
if not TerritoryItem.objects.filter(name=lines[1]).exists():
TerritoryItem.objects.create(name=lines[1], parent=parent)

View File

30
back/tmc/models.py Normal file
View File

@ -0,0 +1,30 @@
from django.conf import settings
from django.db import models
class BaseCustomField(models.Model):
name = models.CharField(max_length=120, )
comment = models.TextField(null=True, blank=True)
def __str__(self):
return self.name
class CustomTable(models.Model):
name = models.CharField(max_length=120, )
fields = models.ManyToManyField(BaseCustomField)
def __str__(self):
return self.name
class Territory(models.Model):
name = models.CharField()
def __str__(self):
return self.name
class TerritoryItem(models.Model):
name = models.CharField()
parent = models.ForeignKey(Territory, models.RESTRICT)
def __str__(self):
return self.name

30
back/tmc/serializers.py Normal file
View File

@ -0,0 +1,30 @@
from rest_framework import serializers
from .models import BaseCustomField, CustomTable, Territory, TerritoryItem
import logging
logger = logging.getLogger("root")
class BaseCustomFieldSerializer(serializers.ModelSerializer):
class Meta:
model = BaseCustomField
fields = "__all__"
class CustomTableSerializer(serializers.ModelSerializer):
class Meta:
model = CustomTable
depth = 1
fields = "__all__"
class TerritoryItemSerializer(serializers.ModelSerializer):
class Meta:
model = TerritoryItem
fields = "__all__"
class TerritorySerializer(serializers.ModelSerializer):
class Meta:
model = Territory
fields = "__all__"

3
back/tmc/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

36
back/tmc/views.py Normal file
View File

@ -0,0 +1,36 @@
from django.shortcuts import render
from rest_framework import viewsets, filters
from .models import BaseCustomField, CustomTable, Territory, TerritoryItem
from .serializers import (
BaseCustomFieldSerializer,
CustomTableSerializer,
TerritoryItemSerializer,
TerritorySerializer,
)
class BaseCustomFieldViewSet(viewsets.ModelViewSet):
queryset = BaseCustomField.objects.all()
serializer_class = BaseCustomFieldSerializer
class CustomTableViewSet(viewsets.ModelViewSet):
page_size_query_param = "size"
queryset = CustomTable.objects.all()
serializer_class = CustomTableSerializer
class TerritoryItemViewSet(viewsets.ModelViewSet):
page_size_query_param = "size"
queryset = TerritoryItem.objects.all()
serializer_class = TerritoryItemSerializer
filter_backends = [filters.SearchFilter]
search_fields = ["name"]
class TerritoryViewSet(viewsets.ModelViewSet):
queryset = Territory.objects.all()
serializer_class = TerritorySerializer

12
compose-dev.yaml Normal file
View File

@ -0,0 +1,12 @@
services:
app:
entrypoint:
- sleep
- infinity
image: docker/dev-environments-javascript:stable-1
init: true
volumes:
- type: bind
source: /var/run/docker.sock
target: /var/run/docker.sock

5
dev.sh
View File

@ -1,3 +1,4 @@
#!/bin/bash #!/bin/bash
cd front && npm run dev & x-terminal-emulator -title "To Invetory FRONT" -e "cd front && npm run dev -p 80 -- --host"&
cd back && poetry run task server & x-terminal-emulator -title "To Invetory BACK" -e "cd back && poetry run task server" &&
x-terminal-emulator -title "To Invetory BACK" -e "cd back && poetry run task celery"

84
docker-compose.yml Normal file
View File

@ -0,0 +1,84 @@
services:
redis:
image: redis:7
restart: always
ports:
- "6379:6379"
networks:
- toinv-network
healthcheck:
test: ["CMD", "redis-cli","ping"]
back:
build:
context: ./back
dockerfile: Dockerfile
image: ci.svs-tech.pro/toinv_back:latest
command: poetry run python manage.py runserver 0.0.0.0:8000
restart: always
expose:
- "8000"
volumes:
- ./env/back.env:/app/.env
networks:
- toinv-network
healthcheck:
test: curl -f http://localhost:8000/ || exit 1
interval: 5s
timeout: 3s
retries: 20
celery:
build:
context: ./back
dockerfile: Dockerfile
image: ci.svs-tech.pro/toinv_back:latest
command: poetry run celery --app api worker --loglevel=info
restart: always
volumes:
- ./env/back.env:/app/.env
networks:
- toinv-network
depends_on:
back:
condition: service_healthy
redis:
condition: service_healthy
front:
build:
context: ./front
dockerfile: Dockerfile
restart: always
volumes:
- ./env/front.env:/app/.env
expose:
- "3000"
depends_on:
back:
condition: service_healthy
networks:
- toinv-network
image: ci.svs-tech.pro/toinv_front:latest
nginx:
image: ci.svs-tech.pro/nginx:1.25
restart: always
ports:
- "${WEB_PORT:-80}:80"
depends_on:
back:
condition: service_healthy
links:
- back:back
- front:front
- redis:redis
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/conf.d:/etc/nginx/conf.d
networks:
- toinv-network
networks:
toinv-network:
driver: bridge

12
env/back.env.example vendored Normal file
View File

@ -0,0 +1,12 @@
DB_NAME=
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=
ODATA_AUTH=
TG_TOKEN=
NGROK_TEMP=
AWS_ACCESS=
AWS_SECRET=

1
env/docker.env.example vendored Normal file
View File

@ -0,0 +1 @@
WEB_PORT=80

2
env/front.env.example vendored Normal file
View File

@ -0,0 +1,2 @@
NUXT_PUBLIC_API_BASE='http://localhost/api'
NUXT_PUBLIC_TGBOT='svstech_inventory_bot'

12
front/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM ci.svs-tech.pro/library/node:22
RUN mkdir -p /app
COPY package.json app/package.json
WORKDIR /app
RUN npm install --omit=dev
COPY . /app
RUN npm run build
CMD npm run preview -- --host

6
front/app.config.ts Normal file
View File

@ -0,0 +1,6 @@
export default defineAppConfig({
ui: {
primary: 'pink',
gray: 'stone'
}
})

View File

@ -1,5 +1,20 @@
<script setup lang="ts">
import 'assets/main.scss'
import Logo from 'assets/logo.svg'
</script>
<template> <template>
<div> <div class="container">
<NuxtWelcome /> <div class="header">
<NuxtLink to="/" class="logo">
<Logo />
Инвентаризация
</NuxtLink>
</div>
<div class="sidebar">
<Sidebar />
</div>
<div class="content">
<NuxtPage />
</div>
</div> </div>
</template> </template>

1
front/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><path fill="currentColor" d="m15.5 17.125l4.95-4.95q.275-.275.7-.275t.7.275t.275.7t-.275.7l-5.65 5.65q-.3.3-.7.3t-.7-.3l-2.85-2.85q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v4q0 .425-.288.713T20 10t-.712-.288T19 9V5h-2v2q0 .425-.288.713T16 8H8q-.425 0-.712-.288T7 7V5H5v14h5q.425 0 .713.288T11 20t-.288.713T10 21zm7-16q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"/></svg>

After

Width:  |  Height:  |  Size: 611 B

71
front/assets/main.scss Normal file
View File

@ -0,0 +1,71 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
a[href*='/']:not([class]),
a[href*='/'][class=""] {
@apply text-primary hover:underline;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
.container {
@apply grid grid-cols-12 mx-auto gap-4
}
.header {
@apply col-span-12 flex gap-2 p-2.5 border-b border-primary-700 bg-gradient-to-l from-primary-700;
.logo {
@apply text-2xl;
svg {
@apply text-3xl inline-block;
}
}
}
.sidebar {
@apply col-span-2
}
.content {
@apply col-span-10
}
.page-header {
@apply border-b border-primary-200 dark:border-primary-950 py-2.5 bg-gradient-to-l from-primary-200 dark:from-primary-950;
}
dl {
@apply grid grid-cols-12;
dt {
@apply col-span-full
}
dd {
@apply col-span-full col-start-2;
&:last-of-type {
@apply mb-2
}
}
}
}
th.tmc {
@apply min-w-[800px];
}

201
front/components/edit.vue Normal file
View File

@ -0,0 +1,201 @@
<script setup lang="ts">
import { apiBase } from '~/helpers';
import type { ApiTypeList, ApiTypeExternal, ApiElementSave } from '~/helpers';
import type { FormError, FormSubmitEvent } from '#ui/types'
const props = defineProps(['elements', 'partner'])
const route = useRoute()
const headers = new Headers();
headers.append("Content-Type", "application/json");
type ExternalDataType = {
partner?: ApiTypeExternal[],
categories?: ApiTypeExternal[][],
element?: ApiTypeExternal[],
}
type StateDataType = {
partner?: string
categories?: string[]
inventory?: string
element?: string
element_id?: string
element_additional_data?: string
}
const loading = ref(false)
const state = reactive<StateDataType>({
partner: undefined,
categories: [],
inventory: route.params.inv_id as string || undefined,
element: undefined,
element_id: undefined,
element_additional_data: undefined
})
const external_data = reactive<ExternalDataType>({
partner: [],
categories: [],
element: [],
})
const show_error = ref()
const elements = ref(props.elements)
const validate = (state: any): FormError[] => {
const errors = []
const txt = 'Это поле обязательно'
if (!state.partner) errors.push({ path: 'partner', message: txt })
if (!external_data || !external_data.partner || !external_data.partner.find(el => el.Ref_Key == state.partner)) {
errors.push({ path: 'partner', message: txt })
}
if (!state.element_id) errors.push({ path: 'element_id', message: txt })
// if (!state.element_additional_data) errors.push({ path: 'element_additional_data', message: txt })
return errors
}
async function onSubmit(event: FormSubmitEvent<any>) {
show_error.value = undefined
const prepader_data = event.data
if (!external_data || !external_data.partner) {
return false
}
prepader_data.partner_name = external_data.partner.find(el => el.Ref_Key == state.partner)?.Description
const data = await $fetch<ApiElementSave>(`${apiBase}/element/`, {
method: 'POST', body: JSON.stringify(prepader_data), onResponseError: (error) => {
if (error.response.status == 500) {
show_error.value = Object.entries(error.response._data).map(el => `${el[0]}: ${el[1]}`).join('\n')
}
}
})
const inv_id = route.params.inv_id || data.inventory?.id
if (!route.params.inv_id) {
navigateTo(`/organization/p_${data.partner.id}/i_${data.inventory.id}`)
} else {
const newElements = await $fetch<ApiTypeList>(`${apiBase}/element?inventory_id=${inv_id}`, { headers })
elements.value = newElements.results
external_data.element = []
external_data.categories = []
state.categories = []
state.element = undefined
state.element_id = undefined
state.element_additional_data = undefined
}
}
const searchInExternal = (q: string) => {
if (!q.length) {
return external_data.partner?.splice(0, 10)
}
return external_data.partner?.filter(el => {
return el.Description.toLowerCase().indexOf(q.toLowerCase()) !== -1
}).slice(0, 10)
}
const loadPartners = async () => {
loading.value = true
const data = await $fetch<ApiTypeExternal[]>(`${apiBase}/partner/external/`)
if (data) {
external_data.partner = data
if (props.partner) {
state.partner = props.partner
}
}
loading.value = false
}
const loadCategories = async () => {
loading.value = true
const lastCat = state.categories?.at(-1) || ''
const data = await $fetch<ApiTypeExternal[]>(`${apiBase}/element/external_categories/${lastCat}`)
if (data.length) {
external_data.categories?.push(data)
} else {
await loadElements()
}
loading.value = false
}
const loadElements = async () => {
loading.value = true
const lastCat = state.categories?.at(-1) || ''
const data = await $fetch<ApiTypeExternal[]>(`${apiBase}/element/external/${lastCat}`)
if (data) {
external_data.element = data
}
loading.value = false
}
const loadDeepCategories = async (i: number) => {
if ((i + 1) <= (state.categories as string[]).length) {
state.categories = state.categories?.slice(0, i + 1)
external_data.categories = external_data.categories?.slice(0, i + 1)
state.element = undefined
external_data.element = []
}
loadCategories()
}
onMounted(async () => {
await loadPartners()
await loadCategories()
})
</script>
<template>
<div class="grid grid-cols-10 gap-4">
<div class="col-span-7">
<UForm :state="state" :validate="validate" class="flex flex-col gap-4" @submit="onSubmit">
<UFormGroup label="Выбрать организацию" name="partner">
<USelectMenu v-model="state.partner" :options="external_data.partner" value-attribute="Ref_Key"
option-attribute="Description" :searchable="searchInExternal"
searchable-placeholder="Выберите организацию из списка контрагентов" :loading="loading"
:disabled="!!props.partner" />
</UFormGroup>
<UFormGroup label="Добавить элемент инвентаризации" v-if="state.partner" :help="!external_data.element?.length ? `Последовательно
выбирайте категорию. Если все выбрали, а элементов нет -
значит в этой категории нет элементов`: ''">
<template v-for="(item, i) in external_data.categories">
<USelectMenu v-model="(state.categories as string[])[i]" :options="item"
value-attribute="Ref_Key" option-attribute="Description" :searchable="true"
:loading="loading" :placeholder="`Категории (${item.length})`"
@change="loadDeepCategories(i)" />
</template>
<USelectMenu v-if="external_data.element?.length" v-model="state.element"
:options="external_data.element" value-attribute="Ref_Key" option-attribute="Description"
:searchable="true" :loading="loading"
:placeholder="`Элементы (${external_data.element.length})`" />
</UFormGroup>
<div v-if="state.element">
<h4>Данные об элементе «{{ external_data.element?.find(el => el['Ref_Key'] ===
state.element)?.Description }}»</h4>
<UFormGroup label="ID" name="element_id">
<UInput placeholder="ID" v-model="state.element_id" />
</UFormGroup>
<UFormGroup label="Дополнительные сведения" name="element_additional_data">
<UTextarea placeholder="Дополнительные сведения" v-model="state.element_additional_data" />
</UFormGroup>
</div>
<UButton type="submit" :disabled="!state.element_id">
Сохранить
</UButton>
</UForm>
</div>
<div class="col-span-3 flex flex-col gap-4">
<h2>{{ external_data.partner?.find(el => el["Ref_Key"] == state.partner)?.Description }}</h2>
<UAlert v-if="show_error" title="Server error" :description="show_error" color="primary" />
<dl v-if="elements?.length">
<template v-for="item in elements">
<dt>
<Element :id="item.external_id" />
</dt>
<dd>{{ item.element_id }}</dd>
<dd>{{ item.additional_text }}</dd>
</template>
</dl>
</div>
</div>
</template>
<style scoped>
label {
word-wrap: break-word;
}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { apiBase } from '~/helpers';
import type { ApiTypeExternal } from '~/helpers';
type ExternalNameType = { [s: string]: string; }
const props = defineProps(['id'])
const id = props.id
const external_elements = useState<ExternalNameType>('external_elements', () => { return {} })
const name = ref()
const loadOneElement = async (id: string) => {
external_elements.value[id] = 'loading'
const data = await $fetch<ApiTypeExternal>(`${apiBase}/element/external/id/${id}`)
external_elements.value[id] = data.Description
name.value = external_elements.value[id]
}
if (external_elements.value[id]) {
name.value = external_elements.value[id]
} else {
await loadOneElement(id)
}
watch(external_elements, () => {
if (name.value !== external_elements.value[id]) {
name.value = external_elements.value[id]
}
}, { deep: true })
</script>
<template>
{{ name }}
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import { useAuthorsStore } from '~/store/authors';
const props = defineProps(['user_id'])
const authorStore = useAuthorsStore()
const author = ref(authorStore.getItem(props.user_id))
const isOpen = ref(false)
watch(authorStore, () => {
author.value = authorStore.getItem(props.user_id)
}, { deep: true })
</script>
<template>
<template v-if="author.status == 'success'">
{{ author.result }}
</template>
<template v-else>
{{ props.user_id }}
</template>
</template>

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { useImagesStore } from '~/store/images';
const props = defineProps(['file_id', 'image_aws_url', 'type'])
const imagesStore = useImagesStore()
const img = ref(imagesStore.getImage(props.file_id, props.image_aws_url ? 'aws' : 'tg'))
const isOpen = ref(false)
const openImage = () => {
isOpen.value = !isOpen.value
}
watch(imagesStore, () => {
img.value = imagesStore.getImage(props.file_id, props.image_aws_url ? 'aws' : 'tg')
}, { deep: true })
</script>
<template>
<template v-if="img.status == 'success'">
<template v-if="props.type == 'img'">
<a href="#" @click.prevent="openImage">
<NuxtImg :src="img.result" />
</a>
</template>
<template v-else>
<a href="#" @click.prevent="openImage">
<Icon name="i-mdi-image" />
</a>
</template>
<UModal v-model="isOpen" :ui="{ width: 'w-full', }">
<img :src="img.result" />
</UModal>
</template>
<template v-else-if="img.status == 'error'">
<Icon name="i-mdi-close-circle-outline" /> <span class="text-sm text-gray-600">Фото не найдено</span>
</template>
<template v-else-if="img.status == 'pending'"> <span class="text-sm text-gray-600">Фото загружается</span>
<Icon name="i-mdi-reload" />
</template>
<template v-else-if="img.status == 'idle'">
<Icon name="i-mdi-progress-question" />
</template>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
const route = useRoute()
const links = [
{
label: 'Список ТМЦ (шаблоны)',
icon: 'i-heroicons-archive-box',
to: '/tmc'
},
{
label: 'Инвентаризации',
icon: 'i-heroicons-pencil',
to: '/table'
}
,
{
label: 'Статистика',
icon: 'i-heroicons-funnel',
to: '/stat'
}
]
</script>
<template>
<UVerticalNavigation :links="links" />
</template>

11
front/helpers.ts Normal file
View File

@ -0,0 +1,11 @@
const config = useRuntimeConfig()
export const apiBase = config.public.apiBase
export const makeColumns = (cols: string[]) => {
return cols.map(el => {
return {
key: el,
label: el.toUpperCase()
}
})
}

View File

@ -1,4 +1,17 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
devtools: { enabled: true } ssr: false,
devtools: { enabled: true },
modules: [
"@nuxt/ui",
"nuxt-svgo",
"@pinia/nuxt",
"@nuxt/image",
],
runtimeConfig: {
public: {
apiBase: '/api',
tgbot: ''
},
},
}) })

2334
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,20 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@nuxt/image": "^1.7.0",
"@nuxt/ui": "^2.16.0",
"@pinia/nuxt": "^0.5.1",
"nuxt": "^3.11.2", "nuxt": "^3.11.2",
"nuxt-svgo": "^4.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.27", "vue": "^3.4.27",
"vue-router": "^4.3.2" "vue-router": "^4.3.2",
"vue3-telegram-login": "^1.1.0",
"yup": "^1.4.0",
"chart.js": "^3.9.1",
"vue-chart-3": "^3.1.8"
},
"devDependencies": {
"sass": "^1.77.2"
} }
} }

3
front/pages/index.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
pages index vue
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
const isOrg = ref(true)
const data = ref([] as RootObject[])
data.value = await apiCall<RootObject[]>(`stat/?type=location`, 'get')
interface RootObject {
location: Location | Location[];
inv_count: number;
tmc: Tmc[];
}
interface Tmc {
tmc__name: string;
count: number;
}
interface Location {
id: number;
name: string;
}
const getData = async () => {
isOrg.value = !isOrg.value
data.value = []
data.value = await apiCall<RootObject[]>(`stat/${isOrg.value == true ? '?type=location' : ''}`, 'get')
}
</script>
<template>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 page-header">
<h1>Результаты инвентаризации</h1>
</div>
<div class="col-span-12">
<UButtonGroup>
<UButton :disabled="isOrg" @click="getData">По организациям</UButton>
<UButton :disabled="!isOrg" @click="getData">По продуктам</UButton>
</UButtonGroup>
</div>
<UCard v-for="item in data" class="col-span-4" v-if="data && isOrg == true">
<template #header>
{{ item.location.name }} <UBadge>{{ item.inv_count }}</UBadge>
</template>
<UTable :rows="item.tmc" />
</UCard>
<UCard v-for="item in data" class="col-span-6" v-if="data && isOrg == false">
<template #header>
<template v-for="el in item.tmc">
{{ el.tmc__name }}
</template>
<UBadge>{{ item.inv_count }}</UBadge>
</template>
<UTable :rows="item.location"
:columns="[{ key: 'name', label: 'name' }, { key: 'count', label: 'count' }]" />
</UCard>
</div>
</template>

117
front/pages/table/[id].vue Normal file
View File

@ -0,0 +1,117 @@
<script setup lang="ts">
type location = { id: number, name: string }
type tmc_field = { name: string, comment: string, id: number, text: string, file_id: string, image_aws_url: string, }
type api_field = {
id: number;
name: string;
comment: string;
}
type api_tmc_field = {
id: number;
text: string;
file_id?: any;
image_aws_url?: any;
field: api_field;
}
type api_tmc_list = {
id: number;
name?: any;
field: api_tmc_field[];
tmc: {
id: number,
name: string,
fields: api_field[]
}
}
const route = useRoute()
const item = ref()
const terdeep = ref()
const state = reactive({} as
{
id: string, name?: string,
location: location,
tmc: {
name: string, id: number,
fields: tmc_field[]
}[]
})
const loadData = async () => {
const items_data = await apiCall<any>(`tgbot/${route.params.id}/`)
item.value = items_data
state.id = items_data.id
state.name = items_data.name
state.location = items_data.location
state.tmc = items_data.tmc.map((el: api_tmc_list) => {
return {
name: el.tmc.name,
id: el.id,
fields: el.field.map(item => {
return {
name: item.field.name,
comment: item.field.comment,
id: item.id,
text: item.text,
file_id: item.file_id,
image_aws_url: item.image_aws_url,
}
})
}
})
}
const search = async (q: string) => {
const terdeep_data = await apiCall<ApiPaged<{ id: number, name: string }>>(`tmc/terdeep/?search=${q}`)
terdeep.value = [toRaw(state.location), ...terdeep_data.results]
return terdeep.value
}
onMounted(async () => {
await loadData()
terdeep.value = [toRaw(state.location)]
})
const patchField = async (field: { id: number, text: string }) => {
await apiCall(`tgbot_items/${field.id}/`, 'patch', { text: field.text })
}
const patchItem = async () => {
await apiCall(`tgbot/${state.id}/`, 'patch', { name: state.name, location_id: state.location })
}
</script>
<template>
<UForm :state="state" v-if="item">
<div class="grid grid-cols-4 gap-2 items-end">
<div class="col-span-2">
<div class="flex items-end gap-2">
<UFormGroup label="Название" class="grow">
<UInput v-model="state.name" />
</UFormGroup>
<UButton @click="patchItem">Сохранить</UButton>
</div>
</div>
<div class="col-span-2">
<UFormGroup label="Локация" class="col-span-3">
<USelectMenu v-model="state.location.id" :options="terdeep" :searchable="search"
value-attribute="id" option-attribute="name" :disabled="true" />
</UFormGroup>
</div>
<UFormGroup label="ТМЦ" v-if="state.tmc" class="col-span-4">
<div v-for="el in state.tmc" class="ml-4">
<strong>{{ el.name }}</strong>
<ul>
<li v-for="field in el.fields" class="grid grid-cols-3 gap-4">
<span class="col-span-1 flex flex-col gap-2 items-start">
{{ field.name }}
<UInput v-model="field.text"
:placeholder="`Введите ${field.comment || 'с изображения'}`" />
<UButton @click="patchField(field)">Сохранить</UButton>
</span>
<span class="col-span-2 max-w-80">
<GetImage :file_id="field.file_id" :image_aws_url="field.image_aws_url" type="img" />
</span>
</li>
</ul>
</div>
</UFormGroup>
</div>
</UForm>
</template>

157
front/pages/table/index.vue Normal file
View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { telegramLoginTemp } from 'vue3-telegram-login'
const config = useRuntimeConfig()
const items = ref()
const page = ref(1)
const pagination = ref({ total: 10, pageCount: 10 })
const user_id = ref()
const loading = ref(false)
if (process.env.NODE_ENV == 'development') {
user_id.value = ''
}
const loadData = async () => {
loading.value = true
const items_data = await apiCall<ApiPaged<TgItem>>(`tgbot/?page=${page.value}&user_id=${user_id.value || ''}`)
const res = items_data.results
res.map(item => {
const uniq = [...new Set(item.tmc.map(el => el.tmc.id))]
const uniq_tmc = uniq.map(id => item.tmc.filter(el => el.tmc.id == id))
item.uniq = uniq_tmc
})
items.value = res
pagination.value.total = items_data.count
pagination.value.pageCount = items_data.per_page
loading.value = false
}
onMounted(loadData)
watch(page, loadData)
const columns = [
{
key: 'name',
label: 'Название'
},
{
class: 'tmc',
key: 'tmc',
label: 'ТМЦ'
},
{
key: 'user_id',
label: 'ID автора'
}
]
const testCallback = (user: any) => {
user_id.value = user.id
loadData()
}
const deleteInv = async (id: string) => {
const res = await apiCall<ApiPaged<TgItem>>(`tgbot/${id}/`, 'delete')
loadData()
}
</script>
<template>
<div class="col-span-12 page-header">
<h1>Проведенные инвентаризации</h1>
</div>
<div class="col-span-12" v-if="user_id == undefined">
<UCard>
<template #header>
Необходимо залогиниться
</template>
<telegramLoginTemp mode="callback" :telegram-login="config.public.tgbot" @callback="testCallback" />
</UCard>
</div>
<div class="col-span-12" v-else>
<UTable :rows="items" :columns="columns" :ui="{ td: { base: 'whitespace-normal max-w-sm align-top' } }"
:loading="loading">
<template #name-data="{ row }">
<h3>
<NuxtLink :to="`table/${row.id}`">{{ row.name }}</NuxtLink>
</h3>
Создано: {{ new Date(row.created_at).toLocaleString('ru-RU') }}<br />
Обновлено: {{ new Date(row.updated_at).toLocaleString('ru-RU') }}
</template>
<template #tmc-data="{ row }">
<template v-if="row.uniq.length">
<div v-for="uniq_tmc in row.uniq" class="inv_element">
<h3>{{ uniq_tmc[0].tmc.name }}</h3>
<div class="inv_item" v-for="item in uniq_tmc">
<ul class="inv_list">
<li v-for="el in item.field" class="inv_inner">
<span class="inv_inner_name">
{{ el.field.name }}:
</span>
<span class="inv_inner_text" v-if="el.text">
{{ el.text }}
</span>
<UBadge color="gray" v-else>no text</UBadge>
<span class="inv_inner_image" v-if="el.file_id">
<GetImage :file_id="el.file_id" :image_aws_url="el.image_aws_url" />
</span>
<UBadge color="gray" v-else>no img</UBadge>
</li>
</ul>
</div>
</div>
</template>
<template v-else>
<UButton @click="deleteInv(row.id)" :disabled="loading">Удалить инвентаризацию</UButton>
</template>
</template>
<template #user_id-data="{ row }">
<GetAuthor :user_id="row.user_id" />
</template>
</UTable>
<UPagination v-model="page" v-bind="pagination" />
</div>
</template>
<style scoped lang="scss">
table {
max-width: 100%;
}
.inv_element {
counter-reset: inv;
}
.inv_item {
@apply flex align-top gap-1;
&::before {
counter-increment: inv;
content: counter(inv) "."
}
}
.inv_inner {
>* {
@apply mr-2;
}
&_name {}
&_text {
@apply break-all;
}
&_image {}
&__error {
@apply text-red-400 dark:text-red-800;
}
}
.inv_list {
// @apply flex flex-col gap-2;
}
.inv_item {
@apply mb-2;
}
</style>

28
front/pages/tmc/index.vue Normal file
View File

@ -0,0 +1,28 @@
<script setup lang="ts">
const items = ref()
onMounted(async () => {
const items_data = await apiCall<ApiPaged<TmcItem>>(`tmc/items/?size=30`)
items.value = items_data.results
})
</script>
<template>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 page-header">
<h1>Список шаблонов ТМЦ</h1>
</div>
<UCard v-for="item in items" class="col-span-3">
<template #header>
<div class="prose dark:prose-invert">
<h4>{{ item.name }}</h4>
</div>
</template>
<div class="prose dark:prose-invert text-sm">
<p v-for="child in item.fields">
<strong>{{ child.name }}</strong><br/>
{{ child.comment }}
</p>
</div>
</UCard>
</div>
</template>

43
front/store/authors.ts Normal file
View File

@ -0,0 +1,43 @@
interface imagesList {
[key: string]: {
status: 'idle' | 'pending' | 'success' | 'error',
result?: string
}
}
export const useAuthorsStore = defineStore('authors', {
state: () => ({ list: {} as imagesList }),
actions: {
getItem(name: string) {
if (!this.list[name]) {
this.list[name] = {
status: 'idle',
}
}
if (
!Object.entries(this.list).filter(el => el[1].status == 'pending').length &&
Object.entries(this.list).filter(el => el[1].status == 'idle').length) {
this.loadItems()
}
return this.list[name]
},
async loadOneItem(name: string) {
const result_data = await apiCall<string[]>(`tgbot_items/get_name/${name}/`)
if (result_data.length > 0 && result_data[0] !== null) {
this.list[name].status = 'success'
this.list[name].result = result_data[0]
} else {
this.list[name].status = 'error'
}
if (Object.entries(this.list).filter(el => el[1].status == 'idle').length) {
this.loadItems()
}
},
loadItems() {
const elements = Object.entries(this.list).filter(el => el[1].status == 'idle')
elements.slice(0, 2).map(el => {
this.list[el[0]].status = 'pending'
this.loadOneItem(el[0])
})
}
},
})

52
front/store/images.ts Normal file
View File

@ -0,0 +1,52 @@
interface imagesList {
[key: string]: {
status: 'idle' | 'pending' | 'success' | 'error',
type: 'tg' | 'aws',
result?: string
}
}
export const useImagesStore = defineStore('images', {
state: () => ({ list: {} as imagesList }),
actions: {
getImage(name: string, type: 'tg' | 'aws') {
if (!this.list[name]) {
this.list[name] = {
type,
status: 'idle',
}
}
if (!Object.values(this.list).filter(el => el.status == 'pending').length) {
this.loadImages()
}
return this.list[name]
},
async loadOneImage(name: string) {
let file_url_data
if (this.list[name].type == 'tg') {
file_url_data = await apiCall<string[]>(`tgbot_items/get_image/${name}/`)
if (file_url_data.length > 0 && file_url_data[0] !== null) {
this.list[name].status = 'success'
this.list[name].result = file_url_data[0]
} else {
this.list[name].status = 'error'
}
} else if (this.list[name].type == 'aws') {
file_url_data = await apiCall<string[]>(`tgbot_items/get_image_s3/${name}/`)
this.list[name].result = URL.createObjectURL(file_url_data);
this.list[name].status = 'success'
}
if (Object.entries(this.list).filter(el => el[1].status == 'idle').length) {
this.loadImages()
}
},
loadImages() {
const elements = Object.entries(this.list).filter(el => el[1].status == 'idle')
elements.slice(0, 2).map(el => {
this.list[el[0]].status = 'pending'
this.loadOneImage(el[0])
})
}
},
})

64
front/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,64 @@
declare global {
type ApiTypeList = {
count: number;
next?: any;
previous?: any;
results: ApiTypeBase[]
}
type ApiTypeBase =
ApiPartner | ApiInventory | ApiElement;
type ApiPartner = { id: number, external_id: number, name: string, total_inventory: number }
type ApiInventory = { id: number, partner: number, name: string }
type ApiElement = { id: number, external_id: string, element_id: number, photo: string, additional_text: string, inventory: number }
type ApiElementSave = {
partner: ApiPartner,
inventory: ApiInventory,
element: ApiElement
}
type ApiTypeExternal = {
'НаименованиеПолное': string;
Description: string;
Ref_Key: string;
}
interface TmcField {
id: number
name: string
}
interface TmcItem {
id: number
name: string
fields: TmcField[]
}
interface TgItem {
id: string
user_id: number
name: string
created_at: string
updated_at: string
tmc: {
id: number,
name?: string,
tmc: TmcItem,
field: {
id?: number
text?: string
file_id?: string
fields: TmcField
}[]
}[]
uniq?: any[]
}
type ApiPaged<T> = {
count: number;
per_page: number
next?: any;
previous?: any;
results: T[]
};
}
export { }

7
front/utils/apiCall.ts Normal file
View File

@ -0,0 +1,7 @@
import { apiBase } from '~/helpers';
export default async function <T>(path: string, method = 'GET', body: any = null) {
const headers = new Headers();
headers.append("Content-Type", "application/json");
return await $fetch<T>(`${apiBase}/${path}`, { method, headers, body })
}

62
nginx/nginx.conf Normal file
View File

@ -0,0 +1,62 @@
user root;
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream django {
server back:8000;
keepalive 16;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://front:3000;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://back:8000/api/;
}
location /admin/ {
proxy_pass http://back:8000/admin/;
}
location /static/admin/ {
proxy_pass http://back:8000/static/admin/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

View File

@ -1,5 +1,16 @@
# TO INVENTORY # TO INVENTORY
## Прод
* примонтируйте энв файлы
* * или запускайте докер с путем к энв `docker compose --env-file ./env/docker.env up`
## DEV ## DEV
* Как запустить: `./dev.sh` * Как запустить: `./dev.sh`
## Примонтировать энв-файлы
* `ln -s ~/projects/to_inventory/env/docker.env ~/projects/to_inventory/.env`
* `ln -s ~/projects/to_inventory/env/back.env ~/projects/to_inventory/back/.env`
* `ln -s ~/projects/to_inventory/env/front.env ~/projects/to_inventory/front/.env`