From f2214e2b9e31506cea4e83ea93856dc8f47d7041 Mon Sep 17 00:00:00 2001 From: Zikil Date: Fri, 28 Feb 2025 20:03:23 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0.=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B1=D0=B5=D0=BA?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes backend/.DS_Store | Bin 0 -> 6148 bytes backend/app/.DS_Store | Bin 0 -> 6148 bytes backend/app/__init__.py | 0 .../app/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 171 bytes .../app/__pycache__/config.cpython-310.pyc | Bin 0 -> 1820 bytes backend/app/__pycache__/core.cpython-310.pyc | Bin 0 -> 3432 bytes backend/app/__pycache__/main.cpython-310.pyc | Bin 0 -> 1649 bytes .../app/__pycache__/routers.cpython-310.pyc | Bin 0 -> 13681 bytes .../app/__pycache__/services.cpython-310.pyc | Bin 0 -> 13327 bytes backend/app/config.py | 53 ++ backend/app/core.py | 102 ++++ backend/app/main.py | 57 ++ backend/app/models/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 178 bytes .../catalog_models.cpython-310.pyc | Bin 0 -> 2851 bytes .../content_models.cpython-310.pyc | Bin 0 -> 1708 bytes .../__pycache__/order_models.cpython-310.pyc | Bin 0 -> 2885 bytes .../__pycache__/review_models.cpython-310.pyc | Bin 0 -> 1184 bytes .../__pycache__/user_models.cpython-310.pyc | Bin 0 -> 1829 bytes backend/app/models/catalog_models.py | 73 +++ backend/app/models/content_models.py | 40 ++ backend/app/models/order_models.py | 73 +++ backend/app/models/review_models.py | 24 + backend/app/models/user_models.py | 45 ++ backend/app/repositories/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 184 bytes .../__pycache__/catalog_repo.cpython-310.pyc | Bin 0 -> 10624 bytes .../__pycache__/content_repo.cpython-310.pyc | Bin 0 -> 6106 bytes .../__pycache__/order_repo.cpython-310.pyc | Bin 0 -> 10154 bytes .../__pycache__/review_repo.cpython-310.pyc | Bin 0 -> 5591 bytes .../__pycache__/user_repo.cpython-310.pyc | Bin 0 -> 5669 bytes backend/app/repositories/catalog_repo.py | 542 ++++++++++++++++++ backend/app/repositories/content_repo.py | 285 +++++++++ backend/app/repositories/order_repo.py | 533 +++++++++++++++++ backend/app/repositories/review_repo.py | 264 +++++++++ backend/app/repositories/user_repo.py | 244 ++++++++ backend/app/routers.py | 354 ++++++++++++ backend/app/schemas/__init__.py | 0 .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 179 bytes .../catalog_schemas.cpython-310.pyc | Bin 0 -> 5033 bytes .../content_schemas.cpython-310.pyc | Bin 0 -> 2970 bytes .../__pycache__/order_schemas.cpython-310.pyc | Bin 0 -> 4975 bytes .../review_schemas.cpython-310.pyc | Bin 0 -> 1769 bytes .../__pycache__/user_schemas.cpython-310.pyc | Bin 0 -> 3795 bytes backend/app/schemas/catalog_schemas.py | 142 +++++ backend/app/schemas/content_schemas.py | 75 +++ backend/app/schemas/order_schemas.py | 133 +++++ backend/app/schemas/review_schemas.py | 37 ++ backend/app/schemas/user_schemas.py | 95 +++ backend/app/services.py | 482 ++++++++++++++++ backend/requirements.txt | 12 + docker-compose.yml | 129 +++++ .dockerignore => frontend/.dockerignore | 0 .gitignore => frontend/.gitignore | 0 Dockerfile => frontend/Dockerfile | 0 README.md => frontend/README.md | 0 app.json => frontend/app.json | 0 .../components}/Collections.tsx | 0 .../components}/CookieNotification.tsx | 0 .../components}/Footer.tsx | 0 .../components}/Header.tsx | 0 {components => frontend/components}/Hero.tsx | 0 .../components}/NewArrivals.tsx | 0 .../components}/PopularCategories.tsx | 0 .../components}/TabSelector.tsx | 0 {data => frontend/data}/categories.ts | 0 {data => frontend/data}/collections.ts | 0 {data => frontend/data}/products.ts | 0 next.config.js => frontend/next.config.js | 0 .../package-lock.json | 0 package.json => frontend/package.json | 0 {pages => frontend/pages}/_app.tsx | 0 {pages => frontend/pages}/api/hello.js | 0 {pages => frontend/pages}/category/[slug].tsx | 0 {pages => frontend/pages}/category/index.tsx | 0 .../pages}/collections/[slug].tsx | 0 .../pages}/collections/index.tsx | 0 {pages => frontend/pages}/index.tsx | 0 .../pages}/new-arrivals/index.tsx | 0 {pages => frontend/pages}/product/[slug].tsx | 0 .../postcss.config.js | 0 .../public}/category/dress.jpg | Bin {public => frontend/public}/category/hat.jpg | Bin .../public}/category/jacket.jpg | Bin .../public}/category/pants.jpg | Bin .../public}/category/scarf.jpg | Bin .../public}/category/shoes.jpg | Bin {public => frontend/public}/category/silk.jpg | Bin .../public}/category/sweaters.jpg | Bin {public => frontend/public}/favicon.ico | Bin .../public}/hero_photos/hero1.png | Bin .../public}/hero_photos/photo_main_main_1.png | Bin .../public}/hero_photos/photo_main_main_3.png | Bin {public => frontend/public}/logo.png | Bin {public => frontend/public}/logotip.png | Bin .../public}/photos/autumn_winter.jpg | Bin .../public}/photos/based_outfit.jpg | Bin .../public}/photos/business_outfit.jpg | Bin .../public}/photos/head_photo.png | Bin .../public}/photos/night_dress.jpg | Bin {public => frontend/public}/photos/photo1.jpg | Bin {public => frontend/public}/photos/photo2.jpg | Bin {public => frontend/public}/vercel.svg | 0 {public => frontend/public}/wear/bag1.jpg | Bin {public => frontend/public}/wear/bag2.jpg | Bin .../public}/wear/classic_bruk1.jpg | Bin .../public}/wear/classic_bruk2.jpg | Bin {public => frontend/public}/wear/coat1.jpg | Bin {public => frontend/public}/wear/coat2.jpg | Bin {public => frontend/public}/wear/hat1.jpg | Bin .../public}/wear/jumpsuit_1.jpg | Bin .../public}/wear/jumpsuit_2.jpg | Bin .../public}/wear/kozh_boots1.jpg | Bin .../public}/wear/kozh_boots2.jpg | Bin {public => frontend/public}/wear/palto1.jpg | Bin {public => frontend/public}/wear/palto2.jpg | Bin {public => frontend/public}/wear/pidzak1.jpg | Bin {public => frontend/public}/wear/pidzak2.jpg | Bin .../public}/wear/sherst_sweater1.jpg | Bin .../public}/wear/sherst_sweater2.jpg | Bin {public => frontend/public}/wear/silk1.jpg | Bin {public => frontend/public}/wear/silk2.jpg | Bin .../public}/wear/silk_scarf1.jpg | Bin .../public}/wear/silk_scarf2.jpg | Bin .../public}/wear/sorochka1.jpg | Bin .../public}/wear/sorochka2.jpg | Bin {styles => frontend/styles}/Home.module.css | 0 {styles => frontend/styles}/globals.css | 0 .../tailwind.config.js | 0 tsconfig.json => frontend/tsconfig.json | 0 131 files changed, 3794 insertions(+) create mode 100644 .DS_Store create mode 100644 backend/.DS_Store create mode 100644 backend/app/.DS_Store create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/app/__pycache__/config.cpython-310.pyc create mode 100644 backend/app/__pycache__/core.cpython-310.pyc create mode 100644 backend/app/__pycache__/main.cpython-310.pyc create mode 100644 backend/app/__pycache__/routers.cpython-310.pyc create mode 100644 backend/app/__pycache__/services.cpython-310.pyc create mode 100644 backend/app/config.py create mode 100644 backend/app/core.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/app/models/__pycache__/catalog_models.cpython-310.pyc create mode 100644 backend/app/models/__pycache__/content_models.cpython-310.pyc create mode 100644 backend/app/models/__pycache__/order_models.cpython-310.pyc create mode 100644 backend/app/models/__pycache__/review_models.cpython-310.pyc create mode 100644 backend/app/models/__pycache__/user_models.cpython-310.pyc create mode 100644 backend/app/models/catalog_models.py create mode 100644 backend/app/models/content_models.py create mode 100644 backend/app/models/order_models.py create mode 100644 backend/app/models/review_models.py create mode 100644 backend/app/models/user_models.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc create mode 100644 backend/app/repositories/__pycache__/content_repo.cpython-310.pyc create mode 100644 backend/app/repositories/__pycache__/order_repo.cpython-310.pyc create mode 100644 backend/app/repositories/__pycache__/review_repo.cpython-310.pyc create mode 100644 backend/app/repositories/__pycache__/user_repo.cpython-310.pyc create mode 100644 backend/app/repositories/catalog_repo.py create mode 100644 backend/app/repositories/content_repo.py create mode 100644 backend/app/repositories/order_repo.py create mode 100644 backend/app/repositories/review_repo.py create mode 100644 backend/app/repositories/user_repo.py create mode 100644 backend/app/routers.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/app/schemas/__pycache__/catalog_schemas.cpython-310.pyc create mode 100644 backend/app/schemas/__pycache__/content_schemas.cpython-310.pyc create mode 100644 backend/app/schemas/__pycache__/order_schemas.cpython-310.pyc create mode 100644 backend/app/schemas/__pycache__/review_schemas.cpython-310.pyc create mode 100644 backend/app/schemas/__pycache__/user_schemas.cpython-310.pyc create mode 100644 backend/app/schemas/catalog_schemas.py create mode 100644 backend/app/schemas/content_schemas.py create mode 100644 backend/app/schemas/order_schemas.py create mode 100644 backend/app/schemas/review_schemas.py create mode 100644 backend/app/schemas/user_schemas.py create mode 100644 backend/app/services.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml rename .dockerignore => frontend/.dockerignore (100%) rename .gitignore => frontend/.gitignore (100%) rename Dockerfile => frontend/Dockerfile (100%) rename README.md => frontend/README.md (100%) rename app.json => frontend/app.json (100%) rename {components => frontend/components}/Collections.tsx (100%) rename {components => frontend/components}/CookieNotification.tsx (100%) rename {components => frontend/components}/Footer.tsx (100%) rename {components => frontend/components}/Header.tsx (100%) rename {components => frontend/components}/Hero.tsx (100%) rename {components => frontend/components}/NewArrivals.tsx (100%) rename {components => frontend/components}/PopularCategories.tsx (100%) rename {components => frontend/components}/TabSelector.tsx (100%) rename {data => frontend/data}/categories.ts (100%) rename {data => frontend/data}/collections.ts (100%) rename {data => frontend/data}/products.ts (100%) rename next.config.js => frontend/next.config.js (100%) rename package-lock.json => frontend/package-lock.json (100%) rename package.json => frontend/package.json (100%) rename {pages => frontend/pages}/_app.tsx (100%) rename {pages => frontend/pages}/api/hello.js (100%) rename {pages => frontend/pages}/category/[slug].tsx (100%) rename {pages => frontend/pages}/category/index.tsx (100%) rename {pages => frontend/pages}/collections/[slug].tsx (100%) rename {pages => frontend/pages}/collections/index.tsx (100%) rename {pages => frontend/pages}/index.tsx (100%) rename {pages => frontend/pages}/new-arrivals/index.tsx (100%) rename {pages => frontend/pages}/product/[slug].tsx (100%) rename postcss.config.js => frontend/postcss.config.js (100%) rename {public => frontend/public}/category/dress.jpg (100%) rename {public => frontend/public}/category/hat.jpg (100%) rename {public => frontend/public}/category/jacket.jpg (100%) rename {public => frontend/public}/category/pants.jpg (100%) rename {public => frontend/public}/category/scarf.jpg (100%) rename {public => frontend/public}/category/shoes.jpg (100%) rename {public => frontend/public}/category/silk.jpg (100%) rename {public => frontend/public}/category/sweaters.jpg (100%) rename {public => frontend/public}/favicon.ico (100%) rename {public => frontend/public}/hero_photos/hero1.png (100%) rename {public => frontend/public}/hero_photos/photo_main_main_1.png (100%) rename {public => frontend/public}/hero_photos/photo_main_main_3.png (100%) rename {public => frontend/public}/logo.png (100%) rename {public => frontend/public}/logotip.png (100%) rename {public => frontend/public}/photos/autumn_winter.jpg (100%) rename {public => frontend/public}/photos/based_outfit.jpg (100%) rename {public => frontend/public}/photos/business_outfit.jpg (100%) rename {public => frontend/public}/photos/head_photo.png (100%) rename {public => frontend/public}/photos/night_dress.jpg (100%) rename {public => frontend/public}/photos/photo1.jpg (100%) rename {public => frontend/public}/photos/photo2.jpg (100%) rename {public => frontend/public}/vercel.svg (100%) rename {public => frontend/public}/wear/bag1.jpg (100%) rename {public => frontend/public}/wear/bag2.jpg (100%) rename {public => frontend/public}/wear/classic_bruk1.jpg (100%) rename {public => frontend/public}/wear/classic_bruk2.jpg (100%) rename {public => frontend/public}/wear/coat1.jpg (100%) rename {public => frontend/public}/wear/coat2.jpg (100%) rename {public => frontend/public}/wear/hat1.jpg (100%) rename {public => frontend/public}/wear/jumpsuit_1.jpg (100%) rename {public => frontend/public}/wear/jumpsuit_2.jpg (100%) rename {public => frontend/public}/wear/kozh_boots1.jpg (100%) rename {public => frontend/public}/wear/kozh_boots2.jpg (100%) rename {public => frontend/public}/wear/palto1.jpg (100%) rename {public => frontend/public}/wear/palto2.jpg (100%) rename {public => frontend/public}/wear/pidzak1.jpg (100%) rename {public => frontend/public}/wear/pidzak2.jpg (100%) rename {public => frontend/public}/wear/sherst_sweater1.jpg (100%) rename {public => frontend/public}/wear/sherst_sweater2.jpg (100%) rename {public => frontend/public}/wear/silk1.jpg (100%) rename {public => frontend/public}/wear/silk2.jpg (100%) rename {public => frontend/public}/wear/silk_scarf1.jpg (100%) rename {public => frontend/public}/wear/silk_scarf2.jpg (100%) rename {public => frontend/public}/wear/sorochka1.jpg (100%) rename {public => frontend/public}/wear/sorochka2.jpg (100%) rename {styles => frontend/styles}/Home.module.css (100%) rename {styles => frontend/styles}/globals.css (100%) rename tailwind.config.js => frontend/tailwind.config.js (100%) rename tsconfig.json => frontend/tsconfig.json (100%) diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e49474ea570e487d68c75de2845e5e47d1273a9d GIT binary patch literal 6148 zcmeHK%}T>S5T0!-MtZ3T;&ET0ZxBm-f`UiUBv!%H4gTB%zJqVzBM2Tnc@%sS!6)#W zouQ_riYHN-f!S{}JF`3cWyxfT$W5-xG0}*K5-4MD1I-HIan_FX)WUHt&T&lVR8}YB zX%RZ!X84T^@Vi^5oQ|lVGitrRc^%X3Ki+gb<{``SD&!bR?p}uXZx2tqtGvo@c-5zT zT6DFwxM)H%x}bowbR`*;mqnF_P;o|`K{;+R!PdbuZ>ZzbPIy*t?!QXHEBroE{>rbN z^!BaiyiAM%W55{rLkys1vm`@7YmEV8z!+FCz~2WCWlR+t!T9Mw6R!Zk0nAa*=eq>Q zc*Rt)5yS|@NhnZ4onA4Vgv0LDFI8*=C7hgIKAf)X^oHW%>X_e0cXFwqwZ?!ku*^VT z9!FgN_ddV>F9+F^F<=b*D+b&kn`RSiNo#9ob6jgZ^bpFzevRNF1f5ul;mf6X2O0%- X&mCZ@*a*S`u^)j*gEhv$k23HMFN9lM literal 0 HcmV?d00001 diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..74d715334807459fa0467914aee04080235e9d1c GIT binary patch literal 6148 zcmeHKOHRW;4E3}{1a;FT%eg{tFjVOYdI2br0Fh`Sq3-7>NGy5+Bu>Dd9p~T#Jddq5 zA(SOT)Ryd*`ZG>mq_`#`x7bv3q8SktP{z?2%s0Yu)`5)F!$HSq%vaa3X|s1c$Tr7c zWPsn@n69X$w!Z1UzwP5b(!{b{*KrL`@qT~z^zrg~(T!F=MBCfWsV4ztbV?iat0|&y zMGw8c93NzK`}?=)v-baTP80X{aX71YKhn*tF<=ZB1INYyW;RPP6*OxM7z4(@jsgBY zcqn5~OoI8-0h3n%-~{e0xaM7gYrJAmOoE6&oRk8k)ai=hq#S;)c|kD=N;$dQ8Ruwc zryGjP-Qo8+om>z!YYZ3zeFk(#pYi-(9^U`^MfS@WFb4h=18!2T$^{-tXY1hMc-DI8 qC6tBplHjfc7kLyTmq+nAGz@i(H=Ns+}9hNVr4DWNfTnJHyH%i~4)d6C=e%aWTnMx3k|)y4Qc?wQ99 z&A=fAmyFKo0yqOkB~I!#Sr=R6w|}?fS37o*>sR7E-VL5^+5%s@OUIb+l7iVmHKNEd z%H|*QTN~|DE&1P%0ElMv6hytR(QUTr{G#X>zFt=#74s_-S0Bpi- z4A-W{05v;+fy3M)JTPTbfi_jSBZji+@M{+rILs~DbW-Nc7{|R?xf6;qZ--x-a8iLq z=|uriz*k_y-gdbDAJw1#{Uo^)1w?^=rGRRN{ji5ma<#Scaa?OP+6fvP$K@7H3OZMg g%>|d@T{L5OruhOGILs}=1Ct*CBZG9Jz>g~M4#)JVG5`Po literal 0 HcmV?d00001 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__pycache__/__init__.cpython-310.pyc b/backend/app/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..355953ad89a7e456a1307e3989eb6f919e52900a GIT binary patch literal 171 zcmd1j<>g`kf`F9=(?RrO5P=LBfgA@QE@lA|DGb33nv8xc8Hzx{2;!HYerR!OQL%ni zX12adesXDUYF literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/config.cpython-310.pyc b/backend/app/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..041cf73e2f7d6a7f776bb8ce2921bd047a0feaa8 GIT binary patch literal 1820 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
    TOk1 zNt&ep;uTc)bzJYr*Bf%^IeW4~hFAl5gxG*RM}%guyKmG-LpQLPeC8~3IM{fCTQu|3 zs{57)k4fFeoc|c-keASWK&F5*{R=IDd-NV!qtBy9(E~*AQ8oG*#c!jd=trE7qa%d< z(`s~F#e%IO-Y1j81mqLshp$CHVc<7N-$_?=w4bW8z!v-II+jadW5H!S#LnsiHKfBF zahGNjpA-}H|IL>1Jh9G$AbbO*XAncoo+K#NUZI&D_7K9xWleDn8C^a#t$OEev}y-j zaCv_^f992i;n+RdRy-v=v4M?ZF)Ora4!g(-!NiuL##*~=HrK9i(itgjYjrLZf z)uef8_+YcsMJ3iSBQ05snNROU3-WhiwmOY>8_g~)|EJdC?B-edB{fD%j&Hjo2Ilm% zboNI3{npxsxzXrsu5YzEAJY73C7D=Uamw4piKe_@@;stEv!duw3 tinxMoRw)qq8Ir37GD}LDs_8_2=_h(d#aR++e;U~ZQZjN{iBvQL`QNS|@FD;J literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/routers.cpython-310.pyc b/backend/app/__pycache__/routers.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..40350e07d532bd134f89898fa4704e7368135af3 GIT binary patch literal 13681 zcmb6V&>V>HHK?!z}W;M>Mf9OLm+Ng8=dGp?Soti#4e z++Z$qnac)Zz?=p`a3GjKNH+Ph*~r=K{^)P>?QVAe@Mr(R*bekQ;Fb?2F7KHtA#qx`2C8q46%THEOJaUVCbK4~yRn#`1b=9d8$ zkUCJ3|jIt=<{%oU+u^8cj>;&1wn&d<_Q8u$?If+f8 z{$RF6wy+k$L)po)m9@$#Y>J%9rpjq-8uiy@r_1x$d9sbQ$r)^hJfEE}XR?`c7Mmq6 zU>C^QY_`0RT`1?UIdU$WE9bF!az2|c7qA5~&f;<*TPQDL7s*9zk-V5)EZbSTOt6Gp z%ofW_*d=laTOu!Im&(i7WpXK7Dlcc3%PZIw@=A84yoy~VuVz=vYuGjNT6V3xj$J3O zXV=Rc*bQ}H~= z&)y=}v-NTV+aNcxjdBy)Bsa6oatqrcx3aBr8`~zgv+eR$cB|aMcF5b0`$&zv>+bL5lMRX0>T{6wmk~1y^6S9MKP=7SrDZ5yg%&?5? zX5Df(+by#!Dm@gjaPaoz)*y}W&d-f3kKN`T%H z=VE>daUO(7FX5N!NFNf1!1K#Sm9tc=FOzdQzk=l43!HuY%27G50t=Rk4IX!xU(K%} z?jhhlFb?jL{nzsAi1R++>;;RPN}8)O5wp_I57+W_#Q6|;IFi$uXQ%ydJg(=lpQ?~MmvN4U`D^@j z;{6ds?G64T9kmnU1Vrt`s5ZPQy2@;Li@!~B-UQA*esWaKcfk5LMaJU}^LP1s#Qhd< z9~cLB$@lm92gLa{aQ5;KN9c7{om0ZntIkLKW1>Gv^d}(Oy34W+E8Y}u4^qmuPpE27 z6ggenBK*hvQ1L4so2dw7ZI`~#J}J_BMI*TPcQ$nl82wc z8VuHyMm@;C0xy0JdEWw7e662uBqID9{tJ@#zQ`Z{KB45bldhC!@ zNEM2f9oe4G=2Co3CM)cKhB+ne;O3%GeRg0)j`!JhTg0BCunKX*p0;s$vCy?-Q_8Y> zbBgO1Yk&n_)@>D*1=8aIJ8(%S;dT$K!UR5}lw|D2FhT&2{RRj(n7x z%&S}nx;v?uM>~kr>ugGK?(_hnz@17muak?caCzO%327xzU>ZPY4x%?fYbbSSHm_qM zxuWTl1to+X(^9}pUWAy%v=FbYWw`B0C_*=qToh87EEtV$xSe}bDwFCd*sYb4l3stM zM6bW4NV(z3$;ecvu%Va2=1SMSRWh931~j9PgNUet-Q-0>6_S~Pkam+2G6$pDp>oop ziKMe4r5rr$#0EH{IwX*AkQepX@1RjFQn%8np0vp7ca54!iM<)oYd30Ax2f&b%~&cA z{(vdET@h5L&$1ibk-;!DxM84%>peColkhdfM*y^z^<3mTv5NoD&A znY0D!@;OzA*TFo5)LcpV?G`8N?Hl3qJk$oaw=&R(`jKPtwK(T z#I95tvXv)b>LnDdR#tny&knn`F9G4-2LPA{Bc|#Bq(77>V20cEAWSBq45ccZKnIWk zov>N~xK4C~Bi-hZf4?~h?x@}HD(+WP;bDg{96c#1>~LN$YVax#ugFO|1mP=Y3*@Q; zB(k+SOA9)cLZ2)sb70vO@XiVWFb_7F0Yeo41*=hqRYZNm$A-@ge{uG);m_dtF^nLN z=@-MN`~3+pAzHowR=8F%J~0qfat%kwamiHE;Eio&_(@Y00VNlzO@~D?<*ad5Qi>aZ zOh-9tsy^heM{}L%Vnp4a^5}lw1ifT}IcQ*Xhx{cYro%hc3jhK+fOt()+_jAqO0f!v zwJkAI?T1!>BZMUZs|YT|!m{f$nyzJ-8bpOOz@|D6ac#&wh}Se^QrcO%$q=n$9daB% za1cQM^a@K71DYzC;rs1KsgcG6bbk;J!XGB&_ju?{A@dKK7_vcQC{PL-PIAZAfI5Wu z=mxhH%Qi2rc9>21T&Ab60o9>y%tAE;$oS$l1NG{7kqtHD)utD`@^~d(^;=O41@3;p z`Xh+}BEvw{X>5c&IGz+GulATu?QzD^pv;DRdH`kDG^^fA?c0zphu|Rq{ow>`xl%A^ zi#T_NU!;k53MSr};ZOP-@FnipGLT_mjrY|wQ9X!aDa8zRKHy;1!`M~Rtf@M?T+v>n zb|M>D`3PYB)5;7<40zNSxQ4RUh!n%2rr>#n!B6Ss%}GPe1d@0_J&Fp)Fji-lr_dc% zfxP_)4g(lpcxC}5goo7D4v$Wz5gNNu8HML@)Mx;3e-Ou-h;CbW*+etTH)Oao%1{?T zFDzTQR8^Ni+TLk(FzB@LMm?4@X~L#@0$Q++=AGRU6dsL@&+XZ$+OM8O)ivFoqO(Fv z_L2hE49DxI(1=#gh{S+PN;Rj{sF@JzF_fiK^+LcQ1)j#PF)Y(5;A(I)09OYm1C9VT zzEI3Tt$s&q?NGS;XD8fVD5!`~0z88XalqZ5@&!>*qG&hg0tIYS$jx(&ecL&{mpp|- z!0g2SS-|>do;&sl{pAE{p|KGo;0JcfO{ePPbWHjR#-Jane%X3WR?b6<^}sxb2GopG z8&rDlb4;uDY{x>U_Z}fLVyNe#4cX(`niy~yD0@nc5w^SKq#}FfJ4U{M_Kj}j%&P2h zHMv=YbJ@wFqkxSsix!}Ezas55qcgKAi>ei4(mI3tB4GV9Y78!Yx?1gP>;N4TL^2kH z96D8B8rO372XXwvQ7&zV0yxrxW@%+NR2&H6K_?+!0$ja}pr#iy&h^#p4KhZ+-6Dn6 ztI+CiPB<1P2Ixr}lCX=~LBhs2#tT6lB;s*+sMipTX5`320ME!bke619*8v;f2ww!0 zFv7=?wYJ)-5q7+E6YFi{rD}l&e8&N7*k%W;-I=@{%w}Y!0LNZ%ypZm)>+#SCMl~$} zP3?107;5LBDIEr8_op5Z{G<7d_3V(r4fr1f_@9OSVZ%2V9;|al9cFi6FgO?*3d3of ze}lqB3ckRiWfjMYn`{Pr@wDTYzm_wajup81(N<78N6VWda@n1Q z>RuGK8vxY*ixDTg0_p?AM>lSox9f5Bx>@xtGT(>bLjdCoN;{J4ppf#KL77&41m;EN zHzNc~PvN1)@MmWqwS)9xe9^uHh3aVk81b6X zo~*ZAB-}H~Mjndvr+~rdz<~1}*U7Mpjxfc|y6;b9*tq~Ie4<2YaY6Yb7QxiTp&7q*XIrghdfx;QM z(||$ICws<-o}MF>(ffDc*LJm->Pu+Bid2dq6dXL19TC2B7L&_7tA2`DRo+f6Ki~D@ z@*Psr=us`fVX0WB>!8COE9-h_9D-d~w@*!^8V>(+wm%3pJP4KBTe;zlKWj+;{t&zg z!5e=!y~R~M9`NdVjLvei`oP(ONWWlnJ;brHIsQ69{>G=2OK%u@yKsj7j zR{7EHvLq={Rz)V_rHl0+z+&NaG7pBrQ6pwV%_c*A10C=YtWUC?>Gmp|J;78P{urhk zoO0nQ`S?a@DF}1t8>U;Cg;jR7>iOnK_l68Etn`p!aqW*L1~dWAIQWJ~Q=snG%y0EG z)JnC^uP%43p-@96{}MaKuuErhm0d90K6M7!e+6JfFx)IspCO4>e+A+=aHJk4HmR>U zv0L>Hrb=@IYW@PH{2D-iOL;`_fE4d0M%d#AQu4F>K;lXi@;3;o#$t*Vqpvu;!DvF& zv}?i@=R5qC9uaugbE^s7>cZm97AF#hx(eR8pD`x@cQ!AyN2xNU;uFk?gyq>Y*-MV! zf)G5p$@LWANZH%8&=EKtgu|kU_biqjz{mLFb2VD&SAUBJ)*Pi4_tPB2dWOyL0`)sI ztjW<0ALU4dnOmo^5#jS{-D9Yg630;2pdkT83-1`#xF1yI*33q0XuKr;nCw`-xGS5ny5L43*ORUMB*Rkgu7OrNG_cg?f>`eN2fcFd7IjSEVAPju88o^7 zaAj$yJ4W&mPLOcZ0%4;{evZAnlb+7r{f|gmUcqU@Q;IdpP)JlYJE`vy@YKQ&54iBC zj~cLNM@l$9_3jIcc$zT$ByQO8(g3f5aK{AuD7sy=Y(IQO*mcUeuCGW6%t5zcSXd+= zHok73RgdTQ-Ei2N^g-KiO@VgcI7I6;W50jUucp99jW6jhga-A2Y~0+?AHg;5Vn~o4 zy1UHeVO!(emWR^W4Bm4Fx^i&;PjpG5v+KIx+5xUXY(Fek3KwF?{(pdn`bPv*Cuk>D zRVR%B2D}vInR+&75HU{pkpT6Yvtlx+U zeGCQs89@9)yRIVzrsXrr#7-O}g`=g=6ovV$FtHV8hN?$^HCi`q_rgcBMVEnefkp@OYC4k9bxRv!GZ37QU?zfD2rfV{8^MJL<{+4hU><_` z2o@lSBUp&wA_R*N(8nB3XhmN(ixHz;KkcnA#pY!QmLi}H0WHh47UHs`u&fZSbdmr*H|BXnt7Qs3M zHzBwg!7T{ZBiMjoBZ5r`HY3;qfK4m6%GocvVz(mEHU!%d+=^fag4+<>j(`DR)5}HZ zEr%=P4y3vh!CeUMMta((dcNqw<}xIaY$t*gf?WvG2si-NimK>_S%=WUS^aj%ne}w@ z9CB`XSp(i`C!Gc?-B>96RzcAfFWs231{ws1Lj4o!=mTK;^Th&P3#x0`WJ+z24?xA{!kzm zXffN2XkeBR3$~cEjh4VX{kPD=@eTGmJyQcyt0>x>cR`mUJ78`4>3L}Muq+E3KOzJ%mNAwA6CMGBA2K)?+1L_>4ls z>1vnRg?`f2ZnInc_Lx2RHL5G@Ub9!xWU4FeKC@4~XRE91ezV`c%Dl>6ZLU^zx#}AG zYV&G)t-01-XRfo?o9pcj<_3k!S3hA7m;?4T<~8=9IcQ&NUTa@xUZ-#!)lb?R&5ic; z=Joa_bCW$}4%wT{&GrrE4fYmui+!VcqoONRx7x$zu)WRPX5VDqWZ!Jwtm-?fx7gdw z?e?wat*YEr{gi#1d7FK^dAogwc?ascElu=T17d}hv+lI=NA)#{qlV}eE1yl7pH|pD z>rSx>Pb)=M^oy%NahIZ4ZQUKi8e)yO8rU5XcCB?yj3O)6iS?kkC!*M34YpByLJWZ7 z-iYEFjIzPHHb#{fgW_6H?Tk^yIlWGN5)``>#YSsmti2(w7n^|Ht*}EFVWV|@j3O&G ziyJ_(C!%QW-4I*Ejlhm5>3oxpx(8SJOUUBEt|uyH6;(oF3*|d33QGEvE zJYd}zqsWT=;z9KEP(%?QXH*;j_K?C-6AxO$ZS5Zthd^;SqByLmw#BIOVoZ#KYAi<8 zI#O0VERKL;JfdjD8sZW0D6kJNgEhrxfjtsoAG2SrU0No#u>#W7I<#bYhwO`(@b>((X4 zJ1(ji@9{R;E9YQ~Y0y2PMrCiUJQ<=UW-ww=yr}Thdx^(sqst3d)X~e6it2=QTU#$D z#VPbsQrKCvJz?D*qsWTW;x|As5mCgsXoxvr%L@CsHveac&x@yl6$<-|bw{jcL!1$3 zfwgka=F+*ecu7%x0ew7YWv#-Rgm^yqZ5=T$UJzfze2$9r;l$9nce%d}XTA(hL%4IN8 zbZf`0X>Y}(=vY*Y(Ya&^a&MQP2{yh6F;ma4T$zm!L- z5Yr*86BX-ZNLHJ6t!Y=4&_jM4J?vHt`0wL0WZmoBDWDUM6QIIkfK88)$)T**wHw5f zH8~s5Mdh%YgR&;-WjE|5C%|fCTYr>CA4$eT+&Soy`fJT z6|IpJ$#eQ#s-ZVhA|=x2lTcz~^a2#vu1wcm%kh#}w^bAPcCGU2L~@=kDDjhYuIW4m>b2 zS{!-g;J!m6#RvC|j*pETmJAZTEL%*MY?z9?k?lC1nGq93C5^t;fr|l;@;1<1NXWbJ z^UKlUarEE}pRODq-cu{rZK$g=jOCw{CEFQRYH5k$(V8qeb=pkPacj~Vo+y=}lw!Cv zGc$}1PE>%}Iy38aN^24p3v(a?>oA1F85Zefcie9t2LLm^S$7!+=@~QYihA1r7vkH-YUB0;`VJf6~{++j*s1c z;LtvEWRI7tIB=Y9<%H$+1r*zgyLRp=9vXRQeB|)hqUy>!*cVk)E%A`yYbTdm-a|k= zm7gY{HY#28!QCi&dHB}KwBm}wa5e&T=_x1<9|P)RWP5dTo2yzu_&7P<>94}a}eZcxl^%=fB zK(L>HzN2Nm7CV@cwWHX%N$$}43u?vGs$r!HNw@(x0_6hUmj^%)Y^(I5G4rP$hUWi^ zeiXvh^37ks&?_2bu8%4Pq2j(Lt*b3n2?>H#K2gnr3vL#Ctng*nHi?xjO^d-i%4qcq zveKnxHF*dxUM3Lfuf?s7O$N>?d zj-iD-OmGCi%Lnaf2b@CqN2$^l=vaTT8X%A?by-_T&0#Zaq$peYFpv?efpkJvgZ)Oy z_7Tvjd70gzfS2JpCgXs{CLd(5hcY(tvaD$3IIs|+m+?0vC*ltKuEnsiV291=n2pJ}wlHn70^Rjf@47p;SgpQI7D+$uBLfxb%f%J4rk85274lJ(3kJlZn zb=V_<1Erm$F$-Mh5Yxt|6~uXFx2)A#J7eJM z)T^#qL9a6uZc*U4)V6&7aVMg1rDZ*jp&hycY_fV*OC?iq+x$nw_RB&xrAvp*(ZR%4 z+Caq;vTtZg_Bv#rZ0Jh%sYVL2PeJx6$UX(xrvlldK9D`u1F}yCvPZ-%CpqdgKo~$M z7e|L7pCX{TN=Xp+<+6slui)_~rKw3FerTH%sk{taJx@)siP4-w3s)*kYj#l?YH{ef zgT|o}lf-x00ivC;6Cn5&9$%SR^6~-c$D45-@iLsyqr6IL zWyCCfv;<;C+!YvcIC|jA)aXmu@GQE2gM%p>W+^sU1{KsLWy5rCtq%2LLbJrY5rt+M z=#PJ{OS_pV6{d+K&joWv7^*dJDmc%SgfaR*0~o3KvWnPWyL^_x_A(*byL2*e1L1n*D5?H<5!lLXizZ{Vm9UXJ7~+{8c?rPh%Fz)o7vZbD~7%K(Ob}> z==ZGRG*5+fA;KTZTiM!07`|ovttC0{~ZsaXnEW zhk*P&c@{)oCYVBOZ8_yDOzBxv2AXV!rW7;|O^GDFDFui&Q}R|~%MJw_3h!Uh$k(yw z@aE-%rXO!wUub10Fs+cGz_jQjrQ#|wtz|gtkK1O*+<&kGWo<9u?W2X%YHhf$@uQoN zp}^V{LrYkj*B`geaQvTh{4&880rq++M(SQ3hq@UFHA9fYbqfEOhREXCjmJq~$8E7m zc<}b)67nYLc|#-@a1LLSn9nREc(UfX`-CR9sH0V$=kfUt)-7mXNqiMMt~zKHP}6x< z6PdLMx2vI@)z&6puf|lj$l@PobS>fb%&%C0M;FOP8ZDD^nTF8-{am(@o$nR-1?|h- zb2)eAd>{B&)yOTN%@M=^Dg2*JYKiIWO$le>BzATFKZ?ig$1c^;03V{z$e>T`{EP@h z=eg8e9&^14o+OWeu0wP+kS&16=pL1)Aq;hB^R%t+eu%$1BMx;~XKvs%9_!#w{!|^> zFXD*zwmP_9eztiD)o(LpFn|INfNwP~ZXIaO|*mZJNNHDaAagpas1%^ z133JTJTf*idU)S~(ZljS;!{(#%Czj{YfnJ32Z^iQV+RgBimaI{y|jH?R3tKXbwpBW z!tGwFhVTYS;A3?cxsZ->ZDy8C)F!6nm&oBDfLRD)Xnz8Q7B8o0I6Hsy>tb6ZypVlm zw%N@4l0rBnaYmW0JXN=f(HwJ%cm~O0B<|`*k6O~q`@-X?)XXt9SLJpO)ED#`x@1N4 z-(W&z84HUEix2vYyh>j5XvPQqMwix`%xZ;XkGAeZ1YBAd0uy-A_Y0YrA%rHR3jJwt$n{wcqx(Mufub@y!{~mwDKRA-`Bg=PTj9WQeU04c0O21u z#>h)>5n8;&$0Su6NxAd%dKVG+RQ46qVD`MOtNWfx(YurgSu%EQEyG0jWLvQOtCvyybKLtTFK zp5sRA%PfiNmR-CMxLY4d#aD(Re~)j!1>hM~sC{L|GnD-zPN+{;E7QllTy*^r=5M2Q zQe(>nb`g(ly}mZ@u7uFv;xQ)ef>jOD87_W#%$-WAd^wT@$d)5<;9>`W*G$p-hUwfmRl9w~3@S>ooMhHdBK~sx!}O9xJVbD%PNVJS8c2MGsq zwxZDLmt0+FLVq^Fv*}sH8$6{pk}e>kkfgfnNsY#m40L(q-QmN7@-vA>;_=L!217`! zNjwY}^%Oc~KGN)PYq%U_OjWi<)dFD)*SBu-Qna(S74vd;Oa{_-taEZ0PAA!00S8l3_ z??Wo2QPZUlkrzqbn&xD4KN|2Gy+W!sSMhz+Jwc)L;3aew1qh{(hNB=>eh&Z}Jg!m% z?h>!?6p_~647$+oA`Zp==d8k=m9_>a+~Y~PD!8tokZ{KZf2)$V@V9E(bsC@wOkYFn z`3&xncwKer^7FjX!cCIe4PPBVz^X2=SOytF)JvDo5I1o79O;|iCiNTGIls$DwRutP zk-j9;zJ~Qv!+gmr6$?~rF{bpwD~eliJ#lE)zCC+JMq`4dMC1CT(|$MUA~!|v zpk#{EhKl@gvBS*suP@F^%~vRA+}yq82OPL0{CI&H{&o-saK4r)9j5Q`BaT1ATbK<~ zC?!0qrjno+g?E1IxCG^oKzBNnU60n)Dm&tqH&=G#BBu(;@TOdY#Na=9PmcTw_I{Py zqYSThVX#xt-K)q1dYK(%v@u<_s#Qx2X=*lm@ym6uGaK^%kS;XyZU4CRYe+RCuSf4n zv}*poS7?)HtDRG#X2I8Jf5(+SW)E>$a>M6reAR!RLZ!{;^mg z8hUIqW{&YMP>Dc(6&rO1F3NA)>Yx>wnP>RoBineBKSBH70ZaZi%#xS$DH)u!8%PHD z-FTd<0a^?M_dpBjIhccQ2J$a~j+hH_907B!7lwZYGR2{9(_LZIvNWrI4eIlh9_7)? z+YQ!S;T9t!=I$Huh+Dwejn!j7MjQs=Ou%953M&q&cz%=}Us(A!KrB#rDu`k!7Ftkq z$SWo0UvIR^r3-m_$#5tbHpta*M6)}tBL;OF{GRI8OU)o@@zLU@nfyIR z*QJW}!;fd!G7KAuH?y=4*0%HUkHEea$(bdlSdc9F*o(A(XdO4O05xuqBm5`e6?2PM z%}X)xQ;>b145%oXrH`I+7$y7j4bOiq^Ht+66(;{P`u=e=RhHCc)3U>Vf!Nf3+jGCWl~{)3IY7j3=jqohp4tTx$HcZ;qn~DjB%hze#93TEbGk-H3;3b zjqT-lQdEb^pGLCv)R>|)>lrx6R%*h_<0~H=37Jnp=5AW3x0nUf5ijM~A`1?$;=w8R zkeQlvL$z0T2a8AC02z3>e?!Oe-w9OG`9JyA=H>nibw8v0=*xNx_+8cvX?rmMsuS|> zSWL>F;b(MoXwvI)o~o9r)hJWDUL1VeO>RVx#I0x=JdxwTxIK3bXdlaE=y0w33Pvk7-x zxdOoKA%a41D)>MRx321Y5I@D)M=IsIlmV?Ksl$nsoUQ8eKPbqnNXQLFt|8!dDAxjb zYg?P(>QsIF8MazSvh@TT2tGkDKyVGgAi=c&-kR1<{O}^|f_5Z7NwATC0gHEaE4lBV ziy=KTJ#vUtan6G8O+&KHB)frN3&HOb>>#)wz)M!9UG)u)`mjZPG@?GgkWY|gg1{m; zO5hOi!c?9hI7Ki^aF*aXf)@zB2;ikAYPG7$WXi9x^6LcOB=|PLYXq+o@T4fePw+#6 z9}zHMQUQz#1k`S<_CkN(p|_Uwm{K`2TDqj^DeZ01ai#5d1L0-wvT?y%wRTXeo!J><8b3GTe?cqgEU!y$(pDQiTA#KdHMnBK`oT4W>5b{Z^acYD{s;Ru^!jhWp}Z+Q zxEZzUHg*U2&P`ogx&}K3d$s^a{Dz)@zM$iCH5^mk2mDai^$(1s{wpJ;zn@Qcz4w2d ClVy bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +# Функции для работы с JWT токенами +def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + return encoded_jwt + +def verify_token(token: str) -> Dict[str, Any]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Невалидный токен аутентификации", + headers={"WWW-Authenticate": "Bearer"}, + ) + +# Функция для получения текущего пользователя +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Невалидные учетные данные", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = verify_token(token) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + from app.repositories.user_repo import get_user_by_username + user = get_user_by_username(db, username) + + if user is None: + raise credentials_exception + + return user + +# Функция для проверки активного пользователя +async def get_current_active_user(current_user = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Неактивный пользователь") + return current_user + +# Функция для проверки прав администратора +async def get_current_admin_user(current_user = Depends(get_current_active_user)): + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для выполнения операции" + ) + return current_user \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3845e22 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,57 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse +import os +from pathlib import Path + +from app.config import settings +from app.routers import router +from app.core import Base, engine + +# Создаем таблицы в базе данных +Base.metadata.create_all(bind=engine) + +# Создаем экземпляр приложения FastAPI +app = FastAPI( + title=settings.APP_NAME, + description=settings.APP_DESCRIPTION, + version=settings.APP_VERSION +) + +# Настраиваем CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Обработчик исключений +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + # В реальном приложении здесь должно быть логирование ошибок + return JSONResponse( + status_code=500, + content={"detail": "Внутренняя ошибка сервера"} + ) + +# Подключаем роутеры +app.include_router(router, prefix="/api") + +# Создаем директорию для загрузок, если она не существует +uploads_dir = Path(settings.UPLOAD_DIRECTORY) +uploads_dir.mkdir(parents=True, exist_ok=True) + +# Монтируем статические файлы +app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIRECTORY), name="uploads") + +# Корневой маршрут +@app.get("/") +async def root(): + return { + "message": "Добро пожаловать в API интернет-магазина", + "docs_url": "/docs", + "redoc_url": "/redoc" + } \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/__pycache__/__init__.cpython-310.pyc b/backend/app/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a091f91d9dac519cd54a35171fbb208c81629382 GIT binary patch literal 178 zcmYj~JqiLb6oj*~g$R2P8{Y*)EUaZgwtmFqxrF^mcrPjRMtcmeVe2idOe!6iZI|zcYFnKb#by5C(?KW10uML9$(uCM90gkTJ?lxpGKrAI tn`e|{3<*l~OmNj^pxJ^B0VZ#06hL&di-@~$f35d|0B_Y8dg{QZyF zN#8L3B4_#2g0qFM{29OuW@biihGuSsR&IxO-U?fJJ8b7p=;WQSle?j7Qa>x}=3eM& z+s=BqANtyEW&Jz|1Jn4@U~T67WH5&Z_v~;5euug6UF{FxcbNy@)BX^CkNNO@?T=Xh znlTCf#;S~otDH+^R_BH4Tq!C(<3c&tDv=g5)!xp^xKf>qWts7~P~LVacseV-;`_?I z99MiN&ADpr@H^GSRKDO@T%~0pZ=`eht=+mv==WkQc|z}6{y1>9@Rh#-xDlGn2rXuY zHgEAZv+&sW?a+bOVJ+6aZ-uV5sino7`>n7G--AbOJsQP**12cuh#mG331GWuU4cD- z-Mw$};lbz^*Z4X!xHB`E&-z%w7=FN3*Z^%CY>2##lzRz#pOs=?c}ZiX zT<#oNsy7#D9*g~G4;gBw1><+N4^8D3b(Y1~Gk$0*r!LZ)H5Wr{TdV>e^pEG*Oc}a& z7~hTStfG+>GJLlzIP5@jaf^#+skJQXIl~H6U)I-;r?8BqNN{2744~U zg+=?Z0m@35Am*zUHK~@&>Y3yuhz>=%^H}hriZFx-p}MJz;-pHkPZz%E zQP3{R+sL%wSRRW&NUzy7*@7xiIn6Z6oj3cFhlcow#uGgFe)=s^C#QGQz3JsLsdKzh zPLYb4h;un*f=h{scS{k;I!U;UWCfy6DKETW(|A6gA_+W`Q=~1<${FPZ?&f@7^zjB- zsyv~MmKj*SIWT>5gzp&7z#f>o*RxCD9}N9pO2A)~3X$Jmltuu%P4ci*2xJc_&PS|^ zlA!{#bwR9Ql!r1|KNzD-s311b@6o7_=m~z3^_j^6*r#BlK-iFtFw1Fpt4H2T@Yb}q zj3*|_B7gGXV z3B89ENwsjqeeoW3c%R?{0%E&(9iZG>&Bmqb2)!Ap9B9vs%VXv@*2gr4GETXaWVpsL zO?}Xmo{=W2ZwF=oTlas0u(Yc!n&el&a|zof)dsO}OX1i_xx3o7LQmUJZPq~x>9)^Y zD6^#%yUjdp6KPkpVq4(=_7HXtv>Y9*f|f+UwI`x|MA!NFakUnoV6ov~L67KR+8X9P zfMRP!l#t$rhM>VZMrduG!O)+Ek(!&2F_Q8rwg`xp5YHFkjXrVj;gS}5sfna)xmP#z z!ID9rC}uy@vLekA7w9#|ym>xLKBdk_BDh2a$_MdoNwG+)f&~#8&C*$WHUWOb`2R=% z2glVMKT)AM{^r6q`O3in$4sOkm3lNvSNDkZpd-Hc9LorUqdaQ*HwP;&p=qHjT1_53 zwyTrX7dkn0B!wRBR^Ll;V2FGJIOOb2w2$zFsZ*Eu@SKMZT rJ5S(m>_TNVv56~s4Nqf|a=&Q)8(g5mkORP3L$BS!r8gXm*M|QBrNoeR literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/content_models.cpython-310.pyc b/backend/app/models/__pycache__/content_models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7ec9422394503ce782a9ed15a27ccbf320f6451 GIT binary patch literal 1708 zcmaJ>&2A($5bpo>^z?Klvq=`RfVhBmIAmS`p=Ea!#E-&C2@)~~%V|54R@^_ayO*6@ z!!=jV%P|KYf-8^UD<@uo6II=lWTZvRB;~KlpIvtOtEnfG#DUN5?|&?Rj2!1LbT&T$ zIM3kUG+=}yT;-H5bxV(WrBD4bpg|eZu#9L_#xyPoB`)$jH7XOD^w?M9GNoyc12ri# znz_zbjtE8ciz6bLUHfzk{8$k1N$*d^=-8Pje?wMhPHgm|R$W=y`0J{bOQ~&i-0GrQ z+T^g-vRGE%%9SNAc`HwfQrh4|zG-dzysnkxl?}c*{{Cq0+Eh!$+oG<_dC`C$oOM-> z<8y9gj;S_3P>$j1zXFgBb%jGc;Zk1)G87(s{TrV~;Kd>k;f+U0k8vgt(M>={;3w<3 z@p=w<=?0zv&R|6h@h!kpz~si2+iz#TIbsCSNAE-vi0+6n6ezX9E54L8Y`~h6ThERg zU6fp}*acK1EGi-29NxN?RGm`%RLQw-qpm70JE^B|FFgYTC5VMQPiC6 zjMT5CX6soXlDg^|0fj!A?^?oG#Y@SUO&BX{(J90!W0xIQ>zN5-EhZjZ?O{?MjP(kk zQC>D|i&nv-8PzRyjFC96;hn42CZ%jS8|J4y7SiOpXz&#^L2gy+UC5rX3k>}j zj1BN1Z46I`TuWXIyw%bCiQY%n6Eq*7`4G*$7WGHyV##fcnc1B*6Zsm{z}i(KEx{gR zomMtkp9W}lygtkdoup_O7rkOYZnzOF0T~l5&!pDyH^LlSz$s9{Trp@R{-9d@2_EEA zH2Ax01Kh;M>wK2nC3}$XfabX3GcYJK^D!9Wpw?!+&Svf~qU!cs;u9Eq_R)OXCgxIc zm7mLUwNVgk%)TJQ*|017b{j5`J*+vXb=jYlZU$j~=t2VBagam1=oGiWcIY$z*99#3 WeE1Fd!UdYa1y7y6Q@p9all=>8;Jo|* literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/order_models.cpython-310.pyc b/backend/app/models/__pycache__/order_models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f21065629d90a16f96f37f128d53819ef4a80d41 GIT binary patch literal 2885 zcmb7`OLG%P5XZIBO1rDKW%GuFRFXm=n9qjXmaB)V3H^;~J|Oy%g2s z25T7Ij+(5A`f}8Y+pKLBE*C^aI1dZLk#n+n*W0&Q2R2uDuz82JdDyC=23t+G_e*R6 z<+^B~+%V-MC^tn5<(4TQ74}A<+kT3b7rLIVET_>pPPDU{p+zex>?M?Y0*Y+S8$6ZU;m5g{Erb)IPj!?GmjFVtU z$|au3fTo=NStwk@o4p4i3(OJ)RumR1i6XN_iIs)TDx%CBQDLrduxwY?t}7wcM$Yqb zrk#;Y1QyGxBb5d+%Sd-<@{ttG6EX_#OC^Q&0-gjiiePn>#Hxf8LwN7{e!^qv`?~7; zaVo|U>2=?~H|Ei_##tK-`>Vsty1KT0eR;640i8J;S60{727TuB2iI0_4%P>K?JX}2 zmj~CbL3P&$D>sJyLH|iX9fxy9JiFDqkx7;H_QIWBKMlsQO!BO^rqV6N)>%MQ}_D4 zKaCEr-+SzNMK{*?F0J(^nQy0p)dFm3nEL@&f>lO*cf=#sxXqItKUX}-?nuSj0nfJm zH1VfrusiHJY7SGW4vBdPR$Iodt#10uOY41BSzEfbwseg(E-ekO`kU)Z!;O`}I%_X4 zZCvrM5B=#j-+I2C6YT^IcM^ie%_wXSyocvxf64R1;QbY~vz|*^mPHXy=~0PQs3aX> z?^~iQDzLayi*so4Ob-O)qav$9uc2@CktOSo8^0kOVQs!-t;32y6b)gCCiFSzt$mAi zL>qfOr`=_)^3`0%x{CeH{Sa!l`J||8BNfJ6?fN?iz;XzJA3Sr%#M5FZR-RbeO~z5g zZ%6XkJfcC2gWSK*Rmi~@T0Es-Ln}wfJA54F)FDR%@1==^-o!u%6&%-LZJLZn0&%Ww zqL$hahyExXjS=KB(@l^|)7+fQGVIR3aKgIb`#G&>5=p9vC8!dFE`~xmq&Vb(9|~Qa zu9b8b(U;_5zN=j7QKyEbB>fp%q9mpkJ`z)PvO3aLjat|=ly;`$ypoesXh*i`hQxTXx^Kzq-I{Q4+I8^IFg#7zAi`YmR5XOOQ0pN{ zPs+rGEjy;J$mXFhO!Xr(_M^~`!R~>(j`N!}vJ)CW}ACGio7 z^CT7_bd4BVnl#m(8Qnl$HZ$yXcodyGi(Dadc|KL6ZJu*-AZ-c~gk|Ii;BdoqeGxZgTuSpQJ>Kh2% zesO-pH--Hx8U7AEKE#`yg_vcyW3@ptJrkN;kgcfo>v?{N+Bf8xi2bh&NV)QW^GP;r zK5k93VO!48r-{jX9q5$z<|lbiUBFUw&CoE-c?P;YZ-OA@xCwgiqM@P>1_R*?apbC6 zB|*fgk0FNLh5yTpcBgX-^*MF_f`nnEjKmwrSDvFe!XK&Sw^EiwmU%ukEy3 H3rGG1cyG0p literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/review_models.cpython-310.pyc b/backend/app/models/__pycache__/review_models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef987bdf71c72485c373580225caa083ea1fdb8a GIT binary patch literal 1184 zcmZWo&2H2%5RQ{pM8&u^dwWB zW}IoC`#Ru(4tc0Ye54~D>6phl;Rz!@Uv21=r#%kTSZ6%zai}JG%BReG?#YpizIZZ% z>F0oN%J{7}OMc@mxVh+AqdHx?_(k2q0*s5^wx+5VZgi-cqIKzEW1w2pFX7xJ2Sp1< zl?E3c!CC9#r%j`vsAtS&22{~jO>K{>CCcHvt4rFS78XjnYyBCau#cbpfeUz?Nss%I z@c=>?Ngv&2+o`_|$?64nTUHp1D`PJ;6ON6Kk4MMn72#psi!kG{s zyF#rj6CqkkX%LN%4Kg^cp7SedA_6g$AS&t7RnmkbgWazehNjq!+NxDxlHR*)w1&Dh z8zk*iR$v9yyaFkfohgrTJlq(y#S%|D1!-dRGh=cN>gJP6O9NPl6f}!Nt%MEzOfIBU zT)fKT$}D?d&_e(!3(H3d%p)EoBG>qn`QsrUAD`zV2kyhuW&q4`%x9DJO+J! zO=(Y-p?{$#cBq`r9k`la$Luvm_Z9PXCE5p~5=?fl#OzH*(Ujgb69amLI+r{in*M~L OWQoBCUZ1qT6Z{1Q;7C&d literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/user_models.cpython-310.pyc b/backend/app/models/__pycache__/user_models.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9283a831bfcb21e8c1ca01f41c5c77781a16431e GIT binary patch literal 1829 zcmaJ>%WmXE6!qI~ce~T+ya*3z5CfyJCb?i8QAX)tc zK=$F$0t|DQD;(((S9-*gKJjHh0vVD}MkJCkiCx^|i9`;_z+zvda!7_22Vx{MlDW>; z4hvcIlfxpOz4FNzc+3*uiREi-@Z6cDe?mlO7MtivEjn45_*vER6Ru43yj5j&V&cbj zEjX=AdQdA~o>brPE0a8>Ek7zHH^C8q+0I-uQe4ostSfz5Ho$}9t}5{NG1a`lAgiAU z$UZ#UfZ+~tnL|A05}yY=WFCC|8=pkLV#@*+-UK9B@(5N3mIW-n@kqK@K|EZ+BfuG) zkbr+|abWQp;0gE#H!fd)J^k5XDeTy|=N*D~lW(yR%h;H$LDX$vlO@{$whn9x``!S& zv0T|@o6z1Z6a1jLB4GzsAKiLp*r-xcb(Nn%8^f|<{N=&SH&IuW=N(sT&<~SToe=be z;4@E6z@gT`+`5Y&6El8HJJH^{CTZaguIq{e9z%!=uJV=Dy6PGRH)V#H#aE0euC)m; znd>PP;-d$Q3*PcawQ5djHS^5)1uf3! zr66H*3T2t3p;}+oijnlVRJyeh%s|iuR3ZLnQ0kl(ZFvEIK*(fSDG-{YPPwRr zibF{(r>#l*wjfpmdjRcw0~MzQ6q=V%sWuS=hI=!yvPJ4!M}3B;qWSB`IW|?#ugkOf z)4J#+uUb9-PSq!hN;~b5*t|D zi)sKOiD0--wQ0X;#>#+)QZsM8?QG(Yy&uP>z)@SxXpLi5aot~ zVq=idntBULAYo}I}c|Q zlj?IpMRCgI)k>i#H=C5Lw$BE~4&Efo?R&M7HXX9+g!Nuo2@LUG0aLApc$jT9ruScY Yo8*iBrSPQ-J2dWn=-70% zK3CPsB-Qh?%lXduSCPs-S<3`x8nZ5rvynXi@HxXtNfaKwhXdPbpfD=b!kx5(f)i3D xhgTNV$y!p>k%i#W+Ma@gHbxMNtUk@26cByzBjP6BU(4;lZo1JfpXwr^G+&Z^Fs}dr literal 0 HcmV?d00001 diff --git a/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/catalog_repo.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..634dca22e9d31074415d44979c1dc73ac3fa9a80 GIT binary patch literal 10624 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 literal 0 HcmV?d00001 diff --git a/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/content_repo.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..768ed39ecc36f6ebdeaaa71c7e7593b9bca96042 GIT binary patch literal 6106 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 DB-H{S literal 0 HcmV?d00001 diff --git a/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc b/backend/app/repositories/__pycache__/review_repo.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de70c3330c11658e2ab2b8150d72e303e777c2ad GIT binary patch literal 5591 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 S)nTUhk%AYs+Wu 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 bhMY3!TQw$)sY8>;_D@XZsJ_3t|M33-c<8~! literal 0 HcmV?d00001 diff --git a/backend/app/repositories/catalog_repo.py b/backend/app/repositories/catalog_repo.py new file mode 100644 index 0000000..9b16d4f --- /dev/null +++ b/backend/app/repositories/catalog_repo.py @@ -0,0 +1,542 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +import re +from datetime import datetime + +from app.models.catalog_models import Category, Product, ProductVariant, ProductImage +from app.schemas.catalog_schemas import ( + CategoryCreate, CategoryUpdate, + ProductCreate, ProductUpdate, + ProductVariantCreate, ProductVariantUpdate, + ProductImageCreate, ProductImageUpdate +) + + +# Вспомогательная функция для генерации slug +def generate_slug(name: str) -> str: + # Преобразуем в нижний регистр + slug = name.lower() + # Заменяем пробелы на дефисы + slug = re.sub(r'\s+', '-', slug) + # Удаляем все символы, кроме букв, цифр и дефисов + slug = re.sub(r'[^a-z0-9-]', '', slug) + # Удаляем повторяющиеся дефисы + slug = re.sub(r'-+', '-', slug) + # Удаляем дефисы в начале и конце + slug = slug.strip('-') + + return slug + + +# Функции для работы с категориями +def get_category(db: Session, category_id: int) -> Optional[Category]: + return db.query(Category).filter(Category.id == category_id).first() + + +def get_category_by_slug(db: Session, slug: str) -> Optional[Category]: + return db.query(Category).filter(Category.slug == slug).first() + + +def get_categories( + db: Session, + skip: int = 0, + limit: int = 100, + parent_id: Optional[int] = None +) -> List[Category]: + query = db.query(Category) + + if parent_id is not None: + query = query.filter(Category.parent_id == parent_id) + else: + query = query.filter(Category.parent_id == None) + + return query.offset(skip).limit(limit).all() + + +def create_category(db: Session, category: CategoryCreate) -> Category: + # Если slug не предоставлен, генерируем его из имени + if not category.slug: + category.slug = generate_slug(category.name) + + # Проверяем, что категория с таким slug не существует + if get_category_by_slug(db, category.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Категория с таким slug уже существует" + ) + + # Проверяем, что родительская категория существует, если указана + if category.parent_id and not get_category(db, category.parent_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Родительская категория не найдена" + ) + + # Создаем новую категорию + db_category = Category( + name=category.name, + slug=category.slug, + description=category.description, + parent_id=category.parent_id, + is_active=category.is_active + ) + + try: + db.add(db_category) + db.commit() + db.refresh(db_category) + return db_category + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании категории" + ) + + +def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Category: + db_category = get_category(db, category_id) + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Обновляем только предоставленные поля + update_data = category.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_category.slug: + if get_category_by_slug(db, update_data["slug"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Категория с таким slug уже существует" + ) + + # Если имя изменяется и slug не предоставлен, генерируем новый slug + if "name" in update_data and "slug" not in update_data: + update_data["slug"] = generate_slug(update_data["name"]) + # Проверяем уникальность сгенерированного slug + if get_category_by_slug(db, update_data["slug"]) and get_category_by_slug(db, update_data["slug"]).id != category_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Категория с таким slug уже существует" + ) + + # Проверяем, что родительская категория существует, если указана + if "parent_id" in update_data and update_data["parent_id"] and not get_category(db, update_data["parent_id"]): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Родительская категория не найдена" + ) + + # Проверяем, что категория не становится своим собственным родителем + if "parent_id" in update_data and update_data["parent_id"] == category_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Категория не может быть своим собственным родителем" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_category, key, value) + + try: + db.commit() + db.refresh(db_category) + return db_category + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении категории" + ) + + +def delete_category(db: Session, category_id: int) -> bool: + db_category = get_category(db, category_id) + if not db_category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Проверяем, есть ли у категории подкатегории + if db.query(Category).filter(Category.parent_id == category_id).count() > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Нельзя удалить категорию, у которой есть подкатегории" + ) + + # Проверяем, есть ли у категории продукты + if db.query(Product).filter(Product.category_id == category_id).count() > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Нельзя удалить категорию, у которой есть продукты" + ) + + try: + db.delete(db_category) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении категории" + ) + + +# Функции для работы с продуктами +def get_product(db: Session, product_id: int) -> Optional[Product]: + return db.query(Product).filter(Product.id == product_id).first() + + +def get_product_by_slug(db: Session, slug: str) -> Optional[Product]: + return db.query(Product).filter(Product.slug == slug).first() + + +def get_products( + db: Session, + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + search: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + is_active: Optional[bool] = True +) -> List[Product]: + query = db.query(Product) + + # Применяем фильтры + if category_id: + query = query.filter(Product.category_id == category_id) + + if search: + query = query.filter(Product.name.ilike(f"%{search}%")) + + if min_price is not None: + query = query.filter(Product.price >= min_price) + + if max_price is not None: + query = query.filter(Product.price <= max_price) + + if is_active is not None: + query = query.filter(Product.is_active == is_active) + + return query.offset(skip).limit(limit).all() + + +def create_product(db: Session, product: ProductCreate) -> Product: + # Если slug не предоставлен, генерируем его из имени + if not product.slug: + product.slug = generate_slug(product.name) + + # Проверяем, что продукт с таким slug не существует + if get_product_by_slug(db, product.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт с таким slug уже существует" + ) + + # Проверяем, что категория существует + if not get_category(db, product.category_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Создаем новый продукт + db_product = Product( + name=product.name, + slug=product.slug, + description=product.description, + price=product.price, + discount_price=product.discount_price, + stock=product.stock, + is_active=product.is_active, + category_id=product.category_id + ) + + try: + db.add(db_product) + db.commit() + db.refresh(db_product) + return db_product + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании продукта" + ) + + +def update_product(db: Session, product_id: int, product: ProductUpdate) -> Product: + db_product = get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Обновляем только предоставленные поля + update_data = product.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_product.slug: + if get_product_by_slug(db, update_data["slug"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт с таким slug уже существует" + ) + + # Если имя изменяется и slug не предоставлен, генерируем новый slug + if "name" in update_data and "slug" not in update_data: + update_data["slug"] = generate_slug(update_data["name"]) + # Проверяем уникальность сгенерированного slug + if get_product_by_slug(db, update_data["slug"]) and get_product_by_slug(db, update_data["slug"]).id != product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт с таким slug уже существует" + ) + + # Проверяем, что категория существует, если указана + if "category_id" in update_data and not get_category(db, update_data["category_id"]): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Категория не найдена" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_product, key, value) + + try: + db.commit() + db.refresh(db_product) + return db_product + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении продукта" + ) + + +def delete_product(db: Session, product_id: int) -> bool: + db_product = get_product(db, product_id) + if not db_product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + try: + db.delete(db_product) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении продукта" + ) + + +# Функции для работы с вариантами продуктов +def get_product_variant(db: Session, variant_id: int) -> Optional[ProductVariant]: + return db.query(ProductVariant).filter(ProductVariant.id == variant_id).first() + + +def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]: + return db.query(ProductVariant).filter(ProductVariant.product_id == product_id).all() + + +def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant: + # Проверяем, что продукт существует + if not get_product(db, variant.product_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем, что вариант с таким SKU не существует + if db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вариант с таким SKU уже существует" + ) + + # Создаем новый вариант продукта + db_variant = ProductVariant( + product_id=variant.product_id, + name=variant.name, + sku=variant.sku, + price_adjustment=variant.price_adjustment, + stock=variant.stock, + is_active=variant.is_active + ) + + try: + db.add(db_variant) + db.commit() + db.refresh(db_variant) + return db_variant + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании варианта продукта" + ) + + +def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> ProductVariant: + db_variant = get_product_variant(db, variant_id) + if not db_variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + # Обновляем только предоставленные поля + update_data = variant.dict(exclude_unset=True) + + # Если SKU изменяется, проверяем его уникальность + if "sku" in update_data and update_data["sku"] != db_variant.sku: + if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вариант с таким SKU уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_variant, key, value) + + try: + db.commit() + db.refresh(db_variant) + return db_variant + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении варианта продукта" + ) + + +def delete_product_variant(db: Session, variant_id: int) -> bool: + db_variant = get_product_variant(db, variant_id) + if not db_variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + try: + db.delete(db_variant) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении варианта продукта" + ) + + +# Функции для работы с изображениями продуктов +def get_product_image(db: Session, image_id: int) -> Optional[ProductImage]: + return db.query(ProductImage).filter(ProductImage.id == image_id).first() + + +def get_product_images(db: Session, product_id: int) -> List[ProductImage]: + return db.query(ProductImage).filter(ProductImage.product_id == product_id).all() + + +def create_product_image(db: Session, image: ProductImageCreate) -> ProductImage: + # Проверяем, что продукт существует + if not get_product(db, image.product_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Если изображение отмечено как основное, сбрасываем флаг у других изображений + if image.is_primary: + db.query(ProductImage).filter( + ProductImage.product_id == image.product_id, + ProductImage.is_primary == True + ).update({"is_primary": False}) + + # Создаем новое изображение продукта + db_image = ProductImage( + product_id=image.product_id, + image_url=image.image_url, + alt_text=image.alt_text, + is_primary=image.is_primary + ) + + try: + db.add(db_image) + db.commit() + db.refresh(db_image) + return db_image + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании изображения продукта" + ) + + +def update_product_image(db: Session, image_id: int, is_primary: bool) -> ProductImage: + db_image = get_product_image(db, image_id) + if not db_image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение продукта не найдено" + ) + + # Если изображение отмечается как основное, сбрасываем флаг у других изображений + if is_primary and not db_image.is_primary: + db.query(ProductImage).filter( + ProductImage.product_id == db_image.product_id, + ProductImage.is_primary == True + ).update({"is_primary": False}) + + # Обновляем флаг + db_image.is_primary = is_primary + + try: + db.commit() + db.refresh(db_image) + return db_image + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении изображения продукта" + ) + + +def delete_product_image(db: Session, image_id: int) -> bool: + db_image = get_product_image(db, image_id) + if not db_image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение продукта не найдено" + ) + + try: + db.delete(db_image) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении изображения продукта" + ) \ No newline at end of file diff --git a/backend/app/repositories/content_repo.py b/backend/app/repositories/content_repo.py new file mode 100644 index 0000000..7465731 --- /dev/null +++ b/backend/app/repositories/content_repo.py @@ -0,0 +1,285 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +import re +from datetime import datetime, timedelta + +from app.models.content_models import Page, AnalyticsLog +from app.schemas.content_schemas import PageCreate, PageUpdate, AnalyticsLogCreate + + +# Вспомогательная функция для генерации slug +def generate_slug(title: str) -> str: + # Преобразуем в нижний регистр + slug = title.lower() + # Заменяем пробелы на дефисы + slug = re.sub(r'\s+', '-', slug) + # Удаляем все символы, кроме букв, цифр и дефисов + slug = re.sub(r'[^a-z0-9-]', '', slug) + # Удаляем повторяющиеся дефисы + slug = re.sub(r'-+', '-', slug) + # Удаляем дефисы в начале и конце + slug = slug.strip('-') + + return slug + + +# Функции для работы со страницами +def get_page(db: Session, page_id: int) -> Optional[Page]: + return db.query(Page).filter(Page.id == page_id).first() + + +def get_page_by_slug(db: Session, slug: str) -> Optional[Page]: + return db.query(Page).filter(Page.slug == slug).first() + + +def get_pages( + db: Session, + skip: int = 0, + limit: int = 100, + published_only: bool = True +) -> List[Page]: + query = db.query(Page) + + if published_only: + query = query.filter(Page.is_published == True) + + return query.order_by(Page.title).offset(skip).limit(limit).all() + + +def create_page(db: Session, page: PageCreate) -> Page: + # Если slug не предоставлен, генерируем его из заголовка + if not page.slug: + page.slug = generate_slug(page.title) + + # Проверяем, что страница с таким slug не существует + if get_page_by_slug(db, page.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Создаем новую страницу + db_page = Page( + title=page.title, + slug=page.slug, + content=page.content, + meta_title=page.meta_title, + meta_description=page.meta_description, + is_published=page.is_published + ) + + try: + db.add(db_page) + db.commit() + db.refresh(db_page) + return db_page + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании страницы" + ) + + +def update_page(db: Session, page_id: int, page: PageUpdate) -> Page: + db_page = get_page(db, page_id) + if not db_page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + # Обновляем только предоставленные поля + update_data = page.dict(exclude_unset=True) + + # Если slug изменяется, проверяем его уникальность + if "slug" in update_data and update_data["slug"] != db_page.slug: + if get_page_by_slug(db, update_data["slug"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Если заголовок изменяется и slug не предоставлен, генерируем новый slug + if "title" in update_data and "slug" not in update_data: + update_data["slug"] = generate_slug(update_data["title"]) + # Проверяем уникальность сгенерированного slug + if get_page_by_slug(db, update_data["slug"]) and get_page_by_slug(db, update_data["slug"]).id != page_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Страница с таким slug уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_page, key, value) + + try: + db.commit() + db.refresh(db_page) + return db_page + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении страницы" + ) + + +def delete_page(db: Session, page_id: int) -> bool: + db_page = get_page(db, page_id) + if not db_page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + try: + db.delete(db_page) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении страницы" + ) + + +# Функции для работы с аналитикой +def log_analytics_event(db: Session, log: AnalyticsLogCreate) -> AnalyticsLog: + # Создаем новую запись аналитики + db_log = AnalyticsLog( + user_id=log.user_id, + event_type=log.event_type, + page_url=log.page_url, + product_id=log.product_id, + category_id=log.category_id, + ip_address=log.ip_address, + user_agent=log.user_agent, + referrer=log.referrer, + additional_data=log.additional_data + ) + + try: + db.add(db_log) + db.commit() + db.refresh(db_log) + return db_log + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при логировании события" + ) + + +def get_analytics_logs( + db: Session, + skip: int = 0, + limit: int = 100, + event_type: Optional[str] = None, + user_id: Optional[int] = None, + product_id: Optional[int] = None, + category_id: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> List[AnalyticsLog]: + query = db.query(AnalyticsLog) + + # Применяем фильтры + if event_type: + query = query.filter(AnalyticsLog.event_type == event_type) + + if user_id: + query = query.filter(AnalyticsLog.user_id == user_id) + + if product_id: + query = query.filter(AnalyticsLog.product_id == product_id) + + if category_id: + query = query.filter(AnalyticsLog.category_id == category_id) + + if start_date: + query = query.filter(AnalyticsLog.created_at >= start_date) + + if end_date: + query = query.filter(AnalyticsLog.created_at <= end_date) + + return query.order_by(AnalyticsLog.created_at.desc()).offset(skip).limit(limit).all() + + +def get_analytics_report( + db: Session, + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> Dict[str, Any]: + # Устанавливаем период по умолчанию, если не указан + if not start_date: + if period == "day": + start_date = datetime.utcnow() - timedelta(days=1) + elif period == "week": + start_date = datetime.utcnow() - timedelta(weeks=1) + elif period == "month": + start_date = datetime.utcnow() - timedelta(days=30) + elif period == "year": + start_date = datetime.utcnow() - timedelta(days=365) + else: + start_date = datetime.utcnow() - timedelta(days=30) # По умолчанию 30 дней + + if not end_date: + end_date = datetime.utcnow() + + # Получаем все события за указанный период + logs = db.query(AnalyticsLog).filter( + AnalyticsLog.created_at >= start_date, + AnalyticsLog.created_at <= end_date + ).all() + + # Подсчитываем статистику + total_visits = len(logs) + unique_visitors = len(set([log.ip_address for log in logs if log.ip_address])) + + # Подсчитываем просмотры страниц + page_views = {} + for log in logs: + if log.event_type == "page_view" and log.page_url: + page_views[log.page_url] = page_views.get(log.page_url, 0) + 1 + + # Подсчитываем просмотры продуктов + product_views = {} + for log in logs: + if log.event_type == "product_view" and log.product_id: + product_id = str(log.product_id) + product_views[product_id] = product_views.get(product_id, 0) + 1 + + # Подсчитываем добавления в корзину + cart_additions = sum(1 for log in logs if log.event_type == "add_to_cart") + + # Подсчитываем заказы и выручку + orders_count = sum(1 for log in logs if log.event_type == "order_created") + + # Для расчета выручки и среднего чека нам нужны данные о заказах + # В данном примере мы просто используем заглушки + revenue = 0 + average_order_value = 0 + + # Формируем отчет + report = { + "period": period, + "start_date": start_date, + "end_date": end_date, + "total_visits": total_visits, + "unique_visitors": unique_visitors, + "page_views": page_views, + "product_views": product_views, + "cart_additions": cart_additions, + "orders_count": orders_count, + "revenue": revenue, + "average_order_value": average_order_value + } + + return report \ No newline at end of file diff --git a/backend/app/repositories/order_repo.py b/backend/app/repositories/order_repo.py new file mode 100644 index 0000000..1253c6d --- /dev/null +++ b/backend/app/repositories/order_repo.py @@ -0,0 +1,533 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any +from datetime import datetime + +from app.models.order_models import CartItem, Order, OrderItem, OrderStatus, PaymentMethod +from app.models.catalog_models import Product, ProductImage, ProductVariant +from app.models.user_models import User, UserAddress +from app.schemas.order_schemas import CartItemCreate, CartItemUpdate, OrderCreate, OrderUpdate + + +# Функции для работы с корзиной +def get_cart_item(db: Session, cart_item_id: int) -> Optional[CartItem]: + return db.query(CartItem).filter(CartItem.id == cart_item_id).first() + + +def get_user_cart(db: Session, user_id: int) -> List[CartItem]: + return db.query(CartItem).filter(CartItem.user_id == user_id).all() + + +def get_cart_item_by_variant(db: Session, user_id: int, variant_id: int) -> Optional[CartItem]: + return db.query(CartItem).filter( + CartItem.user_id == user_id, + CartItem.variant_id == variant_id + ).first() + + +def create_cart_item(db: Session, cart_item: CartItemCreate, user_id: int) -> CartItem: + # Проверяем, что вариант существует + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Вариант продукта не найден" + ) + + # Проверяем, что продукт активен + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product or not product.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Продукт не активен или не найден" + ) + + # Проверяем, есть ли уже такой товар в корзине + existing_item = get_cart_item_by_variant(db, user_id, cart_item.variant_id) + if existing_item: + # Обновляем количество + existing_item.quantity += cart_item.quantity + try: + db.commit() + db.refresh(existing_item) + return existing_item + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении элемента корзины" + ) + + # Создаем новый элемент корзины + db_cart_item = CartItem( + user_id=user_id, + variant_id=cart_item.variant_id, + quantity=cart_item.quantity + ) + + try: + db.add(db_cart_item) + db.commit() + db.refresh(db_cart_item) + return db_cart_item + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при добавлении товара в корзину" + ) + + +def update_cart_item(db: Session, cart_item_id: int, cart_item: CartItemUpdate, user_id: int) -> CartItem: + db_cart_item = db.query(CartItem).filter( + CartItem.id == cart_item_id, + CartItem.user_id == user_id + ).first() + + if not db_cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Элемент корзины не найден или не принадлежит пользователю" + ) + + # Обновляем только предоставленные поля + update_data = cart_item.dict(exclude_unset=True) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_cart_item, key, value) + + try: + db.commit() + db.refresh(db_cart_item) + return db_cart_item + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении элемента корзины" + ) + + +def delete_cart_item(db: Session, cart_item_id: int, user_id: int) -> bool: + db_cart_item = db.query(CartItem).filter( + CartItem.id == cart_item_id, + CartItem.user_id == user_id + ).first() + + if not db_cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Элемент корзины не найден или не принадлежит пользователю" + ) + + try: + db.delete(db_cart_item) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении элемента корзины" + ) + + +def clear_cart(db: Session, user_id: int) -> bool: + try: + db.query(CartItem).filter(CartItem.user_id == user_id).delete() + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при очистке корзины" + ) + + +# Функции для работы с заказами +def get_order(db: Session, order_id: int) -> Optional[Order]: + return db.query(Order).filter(Order.id == order_id).first() + + +def get_user_orders(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]: + return db.query(Order).filter(Order.user_id == user_id).order_by(Order.created_at.desc()).offset(skip).limit(limit).all() + + +def get_all_orders( + db: Session, + skip: int = 0, + limit: int = 100, + status: Optional[OrderStatus] = None +) -> List[Order]: + query = db.query(Order) + + if status: + query = query.filter(Order.status == status) + + return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all() + + +def create_order(db: Session, order: OrderCreate, user_id: int) -> Order: + # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан + if order.shipping_address_id: + address = db.query(UserAddress).filter( + UserAddress.id == order.shipping_address_id, + UserAddress.user_id == user_id + ).first() + if not address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес доставки не найден или не принадлежит пользователю" + ) + + # Получаем элементы заказа + order_items = [] + total_amount = 0 + + # Если указаны элементы корзины, используем их + if order.cart_items: + cart_items = db.query(CartItem).filter( + CartItem.id.in_(order.cart_items), + CartItem.user_id == user_id + ).all() + + if len(cart_items) != len(order.cart_items): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Некоторые элементы корзины не найдены или не принадлежат пользователю" + ) + + for cart_item in cart_items: + # Получаем вариант и продукт + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {cart_item.variant_id} не найден" + ) + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден" + ) + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=cart_item.variant_id, + quantity=cart_item.quantity, + price=price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += price * cart_item.quantity + + # Если указаны прямые элементы заказа, используем их + elif order.items: + for item in order.items: + # Проверяем, что вариант существует + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {item.variant_id} не найден" + ) + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=item.variant_id, + quantity=item.quantity, + price=item.price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += item.price * item.quantity + + else: + # Если не указаны ни элементы корзины, ни прямые элементы заказа, используем всю корзину пользователя + cart_items = get_user_cart(db, user_id) + + if not cart_items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Корзина пуста" + ) + + for cart_item in cart_items: + # Получаем вариант и продукт + variant = db.query(ProductVariant).filter(ProductVariant.id == cart_item.variant_id).first() + if not variant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Вариант продукта с ID {cart_item.variant_id} не найден" + ) + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Продукт для варианта с ID {cart_item.variant_id} не найден" + ) + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Создаем элемент заказа + order_item = OrderItem( + variant_id=cart_item.variant_id, + quantity=cart_item.quantity, + price=price + ) + order_items.append(order_item) + + # Обновляем общую сумму + total_amount += price * cart_item.quantity + + # Создаем заказ + db_order = Order( + user_id=user_id, + status=OrderStatus.PENDING, + total_amount=total_amount, + shipping_address_id=order.shipping_address_id, + payment_method=order.payment_method, + notes=order.notes + ) + + try: + # Добавляем заказ + db.add(db_order) + db.flush() # Получаем ID заказа, не фиксируя транзакцию + + # Добавляем элементы заказа + for item in order_items: + item.order_id = db_order.id + db.add(item) + + # Очищаем корзину, если заказ создан из корзины + if not order.items: + db.query(CartItem).filter(CartItem.user_id == user_id).delete() + + db.commit() + db.refresh(db_order) + return db_order + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании заказа" + ) + + +def update_order(db: Session, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Order: + db_order = get_order(db, order_id) + if not db_order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Обычные пользователи могут только отменить заказ + if not is_admin and order.status and order.status != OrderStatus.CANCELLED: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для изменения статуса заказа" + ) + + # Нельзя изменить статус заказа с CANCELLED или REFUNDED + if db_order.status in [OrderStatus.CANCELLED, OrderStatus.REFUNDED] and order.status: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Нельзя изменить статус заказа, который уже {db_order.status}" + ) + + # Обновляем только предоставленные поля + update_data = order.dict(exclude_unset=True) + + # Проверяем, что адрес доставки существует и принадлежит пользователю, если указан + if "shipping_address_id" in update_data and update_data["shipping_address_id"]: + address = db.query(UserAddress).filter( + UserAddress.id == update_data["shipping_address_id"], + UserAddress.user_id == db_order.user_id + ).first() + if not address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес доставки не найден или не принадлежит пользователю" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_order, key, value) + + try: + db.commit() + db.refresh(db_order) + return db_order + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении заказа" + ) + + +def delete_order(db: Session, order_id: int, is_admin: bool = False) -> bool: + db_order = get_order(db, order_id) + if not db_order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Только администраторы могут удалять заказы + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для удаления заказа" + ) + + try: + db.delete(db_order) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении заказа" + ) + + +# Функции для получения детальной информации +def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, Any]]: + cart_items = get_user_cart(db, user_id) + result = [] + + for item in cart_items: + # Получаем информацию о варианте и продукте + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + continue + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + continue + + # Получаем основное изображение продукта + image = db.query(ProductImage).filter( + ProductImage.product_id == product.id, + ProductImage.is_primary == True + ).first() + + # Если нет основного изображения, берем первое доступное + if not image: + image = db.query(ProductImage).filter( + ProductImage.product_id == product.id + ).first() + + # Рассчитываем цену + price = product.discount_price if product.discount_price else product.price + price += variant.price_adjustment + + # Формируем результат + result.append({ + "id": item.id, + "user_id": item.user_id, + "variant_id": item.variant_id, + "quantity": item.quantity, + "created_at": item.created_at, + "updated_at": item.updated_at, + "product_id": product.id, + "product_name": product.name, + "product_price": price, + "product_image": image.image_url if image else None, + "variant_name": variant.name, + "variant_price_adjustment": variant.price_adjustment, + "total_price": price * item.quantity + }) + + return result + + +def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]: + order = get_order(db, order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Заказ не найден" + ) + + # Получаем пользователя + user = db.query(User).filter(User.id == order.user_id).first() + + # Получаем адрес доставки + shipping_address = None + if order.shipping_address_id: + address = db.query(UserAddress).filter(UserAddress.id == order.shipping_address_id).first() + if address: + shipping_address = { + "id": address.id, + "address_line1": address.address_line1, + "address_line2": address.address_line2, + "city": address.city, + "state": address.state, + "postal_code": address.postal_code, + "country": address.country + } + + # Получаем элементы заказа с деталями + items = [] + for item in order.items: + # Получаем информацию о варианте и продукте + variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first() + if not variant: + continue + + product = db.query(Product).filter(Product.id == variant.product_id).first() + if not product: + continue + + # Формируем элемент заказа + items.append({ + "id": item.id, + "order_id": item.order_id, + "variant_id": item.variant_id, + "quantity": item.quantity, + "price": item.price, + "created_at": item.created_at, + "product_id": product.id, + "product_name": product.name, + "variant_name": variant.name, + "total_price": item.price * item.quantity + }) + + # Формируем результат + result = { + "id": order.id, + "user_id": order.user_id, + "status": order.status, + "total_amount": order.total_amount, + "shipping_address_id": order.shipping_address_id, + "payment_method": order.payment_method, + "payment_details": order.payment_details, + "tracking_number": order.tracking_number, + "notes": order.notes, + "created_at": order.created_at, + "updated_at": order.updated_at, + "user_email": user.email if user else None, + "shipping_address": shipping_address, + "items": items + } + + return result \ No newline at end of file diff --git a/backend/app/repositories/review_repo.py b/backend/app/repositories/review_repo.py new file mode 100644 index 0000000..51c895d --- /dev/null +++ b/backend/app/repositories/review_repo.py @@ -0,0 +1,264 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional, Dict, Any + +from app.models.review_models import Review +from app.models.user_models import User +from app.models.catalog_models import Product +from app.schemas.review_schemas import ReviewCreate, ReviewUpdate + + +def get_review(db: Session, review_id: int) -> Optional[Review]: + return db.query(Review).filter(Review.id == review_id).first() + + +def get_product_reviews( + db: Session, + product_id: int, + skip: int = 0, + limit: int = 100, + approved_only: bool = True +) -> List[Review]: + query = db.query(Review).filter(Review.product_id == product_id) + + if approved_only: + query = query.filter(Review.is_approved == True) + + return query.order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def get_user_reviews(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[Review]: + return db.query(Review).filter(Review.user_id == user_id).order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def get_all_reviews( + db: Session, + skip: int = 0, + limit: int = 100, + approved_only: bool = False +) -> List[Review]: + query = db.query(Review) + + if approved_only: + query = query.filter(Review.is_approved == True) + + return query.order_by(Review.created_at.desc()).offset(skip).limit(limit).all() + + +def create_review(db: Session, review: ReviewCreate, user_id: int) -> Review: + # Проверяем, что продукт существует + product = db.query(Product).filter(Product.id == review.product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем, не оставлял ли пользователь уже отзыв на этот продукт + existing_review = db.query(Review).filter( + Review.user_id == user_id, + Review.product_id == review.product_id + ).first() + + if existing_review: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Вы уже оставили отзыв на этот продукт" + ) + + # Проверяем, покупал ли пользователь этот продукт (для verified_purchase) + # Это можно реализовать, проверив наличие завершенных заказов с этим продуктом + # Для простоты пока устанавливаем is_verified_purchase = False + is_verified_purchase = False + + # Создаем отзыв + db_review = Review( + user_id=user_id, + product_id=review.product_id, + rating=review.rating, + title=review.title, + comment=review.comment, + is_verified_purchase=is_verified_purchase, + is_approved=False # По умолчанию отзыв требует одобрения + ) + + try: + db.add(db_review) + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании отзыва" + ) + + +def update_review(db: Session, review_id: int, review: ReviewUpdate, user_id: int, is_admin: bool = False) -> Review: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Проверяем права на редактирование отзыва + if not is_admin and db_review.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для редактирования этого отзыва" + ) + + # Обновляем только предоставленные поля + update_data = review.dict(exclude_unset=True) + + # Обычные пользователи не могут менять статус одобрения + if not is_admin and "is_approved" in update_data: + del update_data["is_approved"] + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_review, key, value) + + try: + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении отзыва" + ) + + +def delete_review(db: Session, review_id: int, user_id: int, is_admin: bool = False) -> bool: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Проверяем права на удаление отзыва + if not is_admin and db_review.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для удаления этого отзыва" + ) + + try: + db.delete(db_review) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении отзыва" + ) + + +def approve_review(db: Session, review_id: int) -> Review: + db_review = get_review(db, review_id) + if not db_review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + db_review.is_approved = True + + try: + db.commit() + db.refresh(db_review) + return db_review + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при одобрении отзыва" + ) + + +def get_review_with_user(db: Session, review_id: int) -> Dict[str, Any]: + review = get_review(db, review_id) + if not review: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Отзыв не найден" + ) + + # Получаем пользователя + user = db.query(User).filter(User.id == review.user_id).first() + + return { + "id": review.id, + "user_id": review.user_id, + "product_id": review.product_id, + "rating": review.rating, + "title": review.title, + "comment": review.comment, + "is_verified_purchase": review.is_verified_purchase, + "is_approved": review.is_approved, + "created_at": review.created_at, + "updated_at": review.updated_at, + "user_username": user.username if user else "Неизвестный пользователь" + } + + +def get_product_rating(db: Session, product_id: int) -> Dict[str, Any]: + # Проверяем, что продукт существует + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Получаем все одобренные отзывы для продукта + reviews = db.query(Review).filter( + Review.product_id == product_id, + Review.is_approved == True + ).all() + + # Рассчитываем средний рейтинг и количество отзывов + total_reviews = len(reviews) + if total_reviews == 0: + return { + "product_id": product_id, + "average_rating": 0, + "total_reviews": 0, + "rating_distribution": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + } + } + + # Рассчитываем распределение рейтингов + rating_distribution = { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + } + + total_rating = 0 + for review in reviews: + total_rating += review.rating + rating_distribution[str(review.rating)] += 1 + + average_rating = total_rating / total_reviews + + return { + "product_id": product_id, + "average_rating": round(average_rating, 1), + "total_reviews": total_reviews, + "rating_distribution": rating_distribution + } \ No newline at end of file diff --git a/backend/app/repositories/user_repo.py b/backend/app/repositories/user_repo.py new file mode 100644 index 0000000..df14ce7 --- /dev/null +++ b/backend/app/repositories/user_repo.py @@ -0,0 +1,244 @@ +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from fastapi import HTTPException, status +from typing import List, Optional + +from app.models.user_models import User, UserAddress +from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, AddressUpdate +from app.core import get_password_hash, verify_password + + +# Функции для работы с пользователями +def get_user(db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +def get_user_by_username(db: Session, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + +def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).offset(skip).limit(limit).all() + + +def create_user(db: Session, user: UserCreate) -> User: + # Проверяем, что пользователь с таким email или username не существует + if get_user_by_email(db, user.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким email уже существует" + ) + + if get_user_by_username(db, user.username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким username уже существует" + ) + + # Создаем нового пользователя + hashed_password = get_password_hash(user.password) + db_user = User( + email=user.email, + username=user.username, + hashed_password=hashed_password, + is_active=user.is_active, + is_admin=user.is_admin + ) + + try: + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании пользователя" + ) + + +def update_user(db: Session, user_id: int, user: UserUpdate) -> User: + db_user = get_user(db, user_id) + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Обновляем только предоставленные поля + update_data = user.dict(exclude_unset=True) + + # Если предоставлен новый пароль, хешируем его + if "password" in update_data and update_data["password"]: + update_data["hashed_password"] = get_password_hash(update_data.pop("password")) + + # Удаляем поле password_confirm, если оно есть + update_data.pop("password_confirm", None) + + # Проверяем уникальность email и username, если они изменяются + if "email" in update_data and update_data["email"] != db_user.email: + if get_user_by_email(db, update_data["email"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким email уже существует" + ) + + if "username" in update_data and update_data["username"] != db_user.username: + if get_user_by_username(db, update_data["username"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь с таким username уже существует" + ) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_user, key, value) + + try: + db.commit() + db.refresh(db_user) + return db_user + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении пользователя" + ) + + +def delete_user(db: Session, user_id: int) -> bool: + db_user = get_user(db, user_id) + if not db_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + try: + db.delete(db_user) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении пользователя" + ) + + +def authenticate_user(db: Session, username: str, password: str) -> Optional[User]: + user = get_user_by_username(db, username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +# Функции для работы с адресами пользователей +def get_address(db: Session, address_id: int) -> Optional[UserAddress]: + return db.query(UserAddress).filter(UserAddress.id == address_id).first() + + +def get_user_addresses(db: Session, user_id: int) -> List[UserAddress]: + return db.query(UserAddress).filter(UserAddress.user_id == user_id).all() + + +def create_address(db: Session, address: AddressCreate, user_id: int) -> UserAddress: + # Если новый адрес помечен как дефолтный, сбрасываем дефолтный статус у других адресов пользователя + if address.is_default: + db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.is_default == True + ).update({"is_default": False}) + + db_address = UserAddress( + user_id=user_id, + address_line1=address.address_line1, + address_line2=address.address_line2, + city=address.city, + state=address.state, + postal_code=address.postal_code, + country=address.country, + is_default=address.is_default + ) + + try: + db.add(db_address) + db.commit() + db.refresh(db_address) + return db_address + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при создании адреса" + ) + + +def update_address(db: Session, address_id: int, address: AddressUpdate, user_id: int) -> UserAddress: + db_address = db.query(UserAddress).filter( + UserAddress.id == address_id, + UserAddress.user_id == user_id + ).first() + + if not db_address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес не найден или не принадлежит пользователю" + ) + + # Обновляем только предоставленные поля + update_data = address.dict(exclude_unset=True) + + # Если адрес становится дефолтным, сбрасываем дефолтный статус у других адресов пользователя + if "is_default" in update_data and update_data["is_default"]: + db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.id != address_id, + UserAddress.is_default == True + ).update({"is_default": False}) + + # Применяем обновления + for key, value in update_data.items(): + setattr(db_address, key, value) + + try: + db.commit() + db.refresh(db_address) + return db_address + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при обновлении адреса" + ) + + +def delete_address(db: Session, address_id: int, user_id: int) -> bool: + db_address = db.query(UserAddress).filter( + UserAddress.id == address_id, + UserAddress.user_id == user_id + ).first() + + if not db_address: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Адрес не найден или не принадлежит пользователю" + ) + + try: + db.delete(db_address) + db.commit() + return True + except Exception: + db.rollback() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Ошибка при удалении адреса" + ) \ No newline at end of file diff --git a/backend/app/routers.py b/backend/app/routers.py new file mode 100644 index 0000000..c6203b2 --- /dev/null +++ b/backend/app/routers.py @@ -0,0 +1,354 @@ +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query, Body, Request +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from typing import List, Optional, Dict, Any +from datetime import datetime + +from app.core import get_db, get_current_user, get_current_active_user, get_current_admin_user +from app.services import ( + register_user, login_user, get_user_profile, update_user_profile, + add_user_address, update_user_address, delete_user_address, + create_category, update_category, delete_category, get_category_tree, + create_product, update_product, delete_product, get_product_details, + add_product_variant, update_product_variant, delete_product_variant, + upload_product_image, update_product_image, delete_product_image, + add_to_cart, update_cart_item, remove_from_cart, clear_cart, get_cart, + create_order, get_order, update_order, cancel_order, + create_review, update_review, delete_review, approve_review, get_product_reviews, + create_page, update_page, delete_page, get_page_by_slug, + log_event, get_analytics_report +) +from app.schemas.user_schemas import ( + UserCreate, UserUpdate, User, AddressCreate, AddressUpdate, Address, Token +) +from app.schemas.catalog_schemas import ( + CategoryCreate, CategoryUpdate, Category, + ProductCreate, ProductUpdate, Product, + ProductVariantCreate, ProductVariantUpdate, ProductVariant, + ProductImageCreate, ProductImageUpdate, ProductImage +) +from app.schemas.order_schemas import ( + CartItemCreate, CartItemUpdate, CartItem, CartItemWithProduct, + OrderCreate, OrderUpdate, Order, OrderWithDetails +) +from app.schemas.review_schemas import ( + ReviewCreate, ReviewUpdate, Review, ReviewWithUser +) +from app.schemas.content_schemas import ( + PageCreate, PageUpdate, Page, AnalyticsLogCreate, AnalyticsLog, AnalyticsReport +) +from app.models.user_models import User as UserModel + + +# Создаем основной роутер +router = APIRouter() + + +# Роутеры для аутентификации и пользователей +auth_router = APIRouter(prefix="/auth", tags=["Аутентификация"]) + +@auth_router.post("/register", response_model=Dict[str, Any]) +async def register(user: UserCreate, db: Session = Depends(get_db)): + return register_user(db, user) + + +@auth_router.post("/token", response_model=Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + result = login_user(db, form_data.username, form_data.password) + return result + + +user_router = APIRouter(prefix="/users", tags=["Пользователи"]) + +@user_router.get("/me", response_model=Dict[str, Any]) +async def read_users_me(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_user_profile(db, current_user.id) + + +@user_router.put("/me", response_model=Dict[str, Any]) +async def update_user_me(user_data: UserUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_user_profile(db, current_user.id, user_data) + + +@user_router.post("/me/addresses", response_model=Dict[str, Any]) +async def create_user_address(address: AddressCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return add_user_address(db, current_user.id, address) + + +@user_router.put("/me/addresses/{address_id}", response_model=Dict[str, Any]) +async def update_user_address_endpoint(address_id: int, address: AddressUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_user_address(db, current_user.id, address_id, address) + + +@user_router.delete("/me/addresses/{address_id}", response_model=Dict[str, Any]) +async def delete_user_address_endpoint(address_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return delete_user_address(db, current_user.id, address_id) + + +@user_router.get("/{user_id}", response_model=Dict[str, Any]) +async def read_user(user_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return get_user_profile(db, user_id) + + +# Роутеры для каталога +catalog_router = APIRouter(prefix="/catalog", tags=["Каталог"]) + +@catalog_router.post("/categories", response_model=Dict[str, Any]) +async def create_category_endpoint(category: CategoryCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return create_category(db, category) + + +@catalog_router.put("/categories/{category_id}", response_model=Dict[str, Any]) +async def update_category_endpoint(category_id: int, category: CategoryUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_category(db, category_id, category) + + +@catalog_router.delete("/categories/{category_id}", response_model=Dict[str, Any]) +async def delete_category_endpoint(category_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_category(db, category_id) + + +@catalog_router.get("/categories", response_model=List[Dict[str, Any]]) +async def get_categories_tree(db: Session = Depends(get_db)): + return get_category_tree(db) + + +@catalog_router.post("/products", response_model=Dict[str, Any]) +async def create_product_endpoint(product: ProductCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return create_product(db, product) + + +@catalog_router.put("/products/{product_id}", response_model=Dict[str, Any]) +async def update_product_endpoint(product_id: int, product: ProductUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_product(db, product_id, product) + + +@catalog_router.delete("/products/{product_id}", response_model=Dict[str, Any]) +async def delete_product_endpoint(product_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_product(db, product_id) + + +@catalog_router.get("/products/{product_id}", response_model=Dict[str, Any]) +async def get_product_details_endpoint(product_id: int, db: Session = Depends(get_db)): + return get_product_details(db, product_id) + + +@catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any]) +async def add_product_variant_endpoint(product_id: int, variant: ProductVariantCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + variant.product_id = product_id + return add_product_variant(db, variant) + + +@catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any]) +async def update_product_variant_endpoint(variant_id: int, variant: ProductVariantUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_product_variant(db, variant_id, variant) + + +@catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any]) +async def delete_product_variant_endpoint(variant_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_product_variant(db, variant_id) + + +@catalog_router.post("/products/{product_id}/images", response_model=Dict[str, Any]) +async def upload_product_image_endpoint( + product_id: int, + file: UploadFile = File(...), + is_primary: bool = Form(False), + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + return upload_product_image(db, product_id, file, is_primary) + + +@catalog_router.put("/images/{image_id}", response_model=Dict[str, Any]) +async def update_product_image_endpoint(image_id: int, image: ProductImageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_product_image(db, image_id, image) + + +@catalog_router.delete("/images/{image_id}", response_model=Dict[str, Any]) +async def delete_product_image_endpoint(image_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_product_image(db, image_id) + + +@catalog_router.get("/products", response_model=List[Product]) +async def get_products( + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + search: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + is_active: Optional[bool] = True, + db: Session = Depends(get_db) +): + from app.repositories.catalog_repo import get_products + return get_products(db, skip, limit, category_id, search, min_price, max_price, is_active) + + +# Роутеры для корзины и заказов +cart_router = APIRouter(prefix="/cart", tags=["Корзина"]) + +@cart_router.post("/items", response_model=Dict[str, Any]) +async def add_to_cart_endpoint(cart_item: CartItemCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return add_to_cart(db, current_user.id, cart_item) + + +@cart_router.put("/items/{cart_item_id}", response_model=Dict[str, Any]) +async def update_cart_item_endpoint(cart_item_id: int, cart_item: CartItemUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_cart_item(db, current_user.id, cart_item_id, cart_item) + + +@cart_router.delete("/items/{cart_item_id}", response_model=Dict[str, Any]) +async def remove_from_cart_endpoint(cart_item_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return remove_from_cart(db, current_user.id, cart_item_id) + + +@cart_router.delete("/clear", response_model=Dict[str, Any]) +async def clear_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return clear_cart(db, current_user.id) + + +@cart_router.get("/", response_model=Dict[str, Any]) +async def get_cart_endpoint(current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_cart(db, current_user.id) + + +order_router = APIRouter(prefix="/orders", tags=["Заказы"]) + +@order_router.post("/", response_model=Dict[str, Any]) +async def create_order_endpoint(order: OrderCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return create_order(db, current_user.id, order) + + +@order_router.get("/{order_id}", response_model=Dict[str, Any]) +async def get_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return get_order(db, current_user.id, order_id, current_user.is_admin) + + +@order_router.put("/{order_id}", response_model=Dict[str, Any]) +async def update_order_endpoint(order_id: int, order: OrderUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_order(db, current_user.id, order_id, order, current_user.is_admin) + + +@order_router.post("/{order_id}/cancel", response_model=Dict[str, Any]) +async def cancel_order_endpoint(order_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return cancel_order(db, current_user.id, order_id) + + +@order_router.get("/", response_model=List[Order]) +async def get_orders( + skip: int = 0, + limit: int = 100, + status: Optional[str] = None, + current_user: UserModel = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + if current_user.is_admin: + from app.repositories.order_repo import get_all_orders + return get_all_orders(db, skip, limit, status) + else: + from app.repositories.order_repo import get_user_orders + return get_user_orders(db, current_user.id, skip, limit) + + +# Роутеры для отзывов +review_router = APIRouter(prefix="/reviews", tags=["Отзывы"]) + +@review_router.post("/", response_model=Dict[str, Any]) +async def create_review_endpoint(review: ReviewCreate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return create_review(db, current_user.id, review) + + +@review_router.put("/{review_id}", response_model=Dict[str, Any]) +async def update_review_endpoint(review_id: int, review: ReviewUpdate, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return update_review(db, current_user.id, review_id, review, current_user.is_admin) + + +@review_router.delete("/{review_id}", response_model=Dict[str, Any]) +async def delete_review_endpoint(review_id: int, current_user: UserModel = Depends(get_current_active_user), db: Session = Depends(get_db)): + return delete_review(db, current_user.id, review_id, current_user.is_admin) + + +@review_router.post("/{review_id}/approve", response_model=Dict[str, Any]) +async def approve_review_endpoint(review_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return approve_review(db, review_id) + + +@review_router.get("/products/{product_id}", response_model=Dict[str, Any]) +async def get_product_reviews_endpoint(product_id: int, skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + return get_product_reviews(db, product_id, skip, limit) + + +# Роутеры для информационных страниц +content_router = APIRouter(prefix="/content", tags=["Контент"]) + +@content_router.post("/pages", response_model=Dict[str, Any]) +async def create_page_endpoint(page: PageCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return create_page(db, page) + + +@content_router.put("/pages/{page_id}", response_model=Dict[str, Any]) +async def update_page_endpoint(page_id: int, page: PageUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return update_page(db, page_id, page) + + +@content_router.delete("/pages/{page_id}", response_model=Dict[str, Any]) +async def delete_page_endpoint(page_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)): + return delete_page(db, page_id) + + +@content_router.get("/pages/{slug}", response_model=Dict[str, Any]) +async def get_page_endpoint(slug: str, db: Session = Depends(get_db)): + return get_page_by_slug(db, slug) + + +@content_router.get("/pages", response_model=List[Page]) +async def get_pages( + skip: int = 0, + limit: int = 100, + published_only: bool = True, + current_user: Optional[UserModel] = Depends(get_current_user), + db: Session = Depends(get_db) +): + # Если пользователь не админ, показываем только опубликованные страницы + is_admin = current_user and current_user.is_admin + from app.repositories.content_repo import get_pages + return get_pages(db, skip, limit, published_only=(not is_admin) and published_only) + + +# Роутеры для аналитики +analytics_router = APIRouter(prefix="/analytics", tags=["Аналитика"]) + +@analytics_router.post("/events", response_model=Dict[str, Any]) +async def log_event_endpoint(log: AnalyticsLogCreate, request: Request, db: Session = Depends(get_db)): + # Добавляем IP-адрес и User-Agent, если они не указаны + if not log.ip_address: + log.ip_address = request.client.host + if not log.user_agent: + user_agent = request.headers.get("user-agent") + if user_agent: + log.user_agent = user_agent + + return log_event(db, log) + + +@analytics_router.get("/reports", response_model=Dict[str, Any]) +async def get_analytics_report_endpoint( + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + current_user: UserModel = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + return get_analytics_report(db, period, start_date, end_date) + + +# Включаем все роутеры в основной роутер +router.include_router(auth_router) +router.include_router(user_router) +router.include_router(catalog_router) +router.include_router(cart_router) +router.include_router(order_router) +router.include_router(review_router) +router.include_router(content_router) +router.include_router(analytics_router) \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/__pycache__/__init__.cpython-310.pyc b/backend/app/schemas/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..adb1322c70233b217bf0123e8141cea7dceadd07 GIT binary patch literal 179 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 Dict[str, Any]: + # Создаем пользователя + db_user = user_repo.create_user(db, user) + + # Создаем токен доступа + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": db_user.username}, expires_delta=access_token_expires + ) + + return { + "user": db_user, + "access_token": access_token, + "token_type": "bearer" + } + + +def login_user(db: Session, username: str, password: str) -> Dict[str, Any]: + # Аутентифицируем пользователя + user = user_repo.authenticate_user(db, username, password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверное имя пользователя или пароль", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Проверяем, что пользователь активен + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Неактивный пользователь" + ) + + # Создаем токен доступа + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": user.username}, expires_delta=access_token_expires + ) + + return { + "access_token": access_token, + "token_type": "bearer" + } + + +def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]: + user = user_repo.get_user(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Получаем адреса пользователя + addresses = user_repo.get_user_addresses(db, user_id) + + # Получаем заказы пользователя + orders = order_repo.get_user_orders(db, user_id) + + # Получаем отзывы пользователя + reviews = review_repo.get_user_reviews(db, user_id) + + return { + "user": user, + "addresses": addresses, + "orders": orders, + "reviews": reviews + } + + +def update_user_profile(db: Session, user_id: int, user_data: UserUpdate) -> Dict[str, Any]: + updated_user = user_repo.update_user(db, user_id, user_data) + return {"user": updated_user} + + +def add_user_address(db: Session, user_id: int, address: AddressCreate) -> Dict[str, Any]: + new_address = user_repo.create_address(db, address, user_id) + return {"address": new_address} + + +def update_user_address(db: Session, user_id: int, address_id: int, address: AddressUpdate) -> Dict[str, Any]: + updated_address = user_repo.update_address(db, address_id, address, user_id) + return {"address": updated_address} + + +def delete_user_address(db: Session, user_id: int, address_id: int) -> Dict[str, Any]: + success = user_repo.delete_address(db, address_id, user_id) + return {"success": success} + + +# Сервисы каталога +def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]: + new_category = catalog_repo.create_category(db, category) + return {"category": new_category} + + +def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]: + updated_category = catalog_repo.update_category(db, category_id, category) + return {"category": updated_category} + + +def delete_category(db: Session, category_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_category(db, category_id) + return {"success": success} + + +def get_category_tree(db: Session) -> List[Dict[str, Any]]: + # Получаем все категории верхнего уровня + root_categories = catalog_repo.get_categories(db, parent_id=None) + + result = [] + for category in root_categories: + # Рекурсивно получаем подкатегории + category_dict = { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "is_active": category.is_active, + "subcategories": _get_subcategories(db, category.id) + } + result.append(category_dict) + + return result + + +def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]: + subcategories = catalog_repo.get_categories(db, parent_id=parent_id) + + result = [] + for category in subcategories: + category_dict = { + "id": category.id, + "name": category.name, + "slug": category.slug, + "description": category.description, + "is_active": category.is_active, + "subcategories": _get_subcategories(db, category.id) + } + result.append(category_dict) + + return result + + +def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]: + new_product = catalog_repo.create_product(db, product) + return {"product": new_product} + + +def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict[str, Any]: + updated_product = catalog_repo.update_product(db, product_id, product) + return {"product": updated_product} + + +def delete_product(db: Session, product_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_product(db, product_id) + return {"success": success} + + +def get_product_details(db: Session, product_id: int) -> Dict[str, Any]: + product = catalog_repo.get_product(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Получаем варианты продукта + variants = catalog_repo.get_product_variants(db, product_id) + + # Получаем изображения продукта + images = catalog_repo.get_product_images(db, product_id) + + # Получаем рейтинг продукта + rating = review_repo.get_product_rating(db, product_id) + + # Получаем отзывы продукта + reviews = review_repo.get_product_reviews(db, product_id, limit=5) + + return { + "product": product, + "variants": variants, + "images": images, + "rating": rating, + "reviews": reviews + } + + +def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, Any]: + new_variant = catalog_repo.create_variant(db, variant) + return {"variant": new_variant} + + +def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> Dict[str, Any]: + updated_variant = catalog_repo.update_variant(db, variant_id, variant) + return {"variant": updated_variant} + + +def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]: + success = catalog_repo.delete_variant(db, variant_id) + return {"success": success} + + +def upload_product_image(db: Session, product_id: int, file: UploadFile, is_primary: bool = False) -> Dict[str, Any]: + # Проверяем, что продукт существует + product = catalog_repo.get_product(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Продукт не найден" + ) + + # Проверяем расширение файла + file_extension = file.filename.split(".")[-1].lower() + if file_extension not in settings.ALLOWED_UPLOAD_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Неподдерживаемый формат файла. Разрешены: {', '.join(settings.ALLOWED_UPLOAD_EXTENSIONS)}" + ) + + # Создаем директорию для загрузок, если она не существует + upload_dir = Path(settings.UPLOAD_DIRECTORY) / "products" / str(product_id) + upload_dir.mkdir(parents=True, exist_ok=True) + + # Генерируем уникальное имя файла + unique_filename = f"{uuid.uuid4()}.{file_extension}" + file_path = upload_dir / unique_filename + + # Сохраняем файл + with file_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + # Создаем запись об изображении в БД + image_data = ProductImageCreate( + product_id=product_id, + image_url=f"/uploads/products/{product_id}/{unique_filename}", + alt_text=file.filename, + is_primary=is_primary + ) + + new_image = catalog_repo.create_image(db, image_data) + + return {"image": new_image} + + +def update_product_image(db: Session, image_id: int, image: ProductImageUpdate) -> Dict[str, Any]: + updated_image = catalog_repo.update_image(db, image_id, image) + return {"image": updated_image} + + +def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]: + # Получаем информацию об изображении перед удалением + image = catalog_repo.get_image(db, image_id) + if not image: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Изображение не найдено" + ) + + # Удаляем запись из БД + success = catalog_repo.delete_image(db, image_id) + + # Удаляем файл с диска + if success: + try: + # Получаем путь к файлу из URL + file_path = Path(settings.UPLOAD_DIRECTORY) / image.image_url.lstrip("/uploads/") + if file_path.exists(): + file_path.unlink() + except Exception: + # Если не удалось удалить файл, просто логируем ошибку + # В реальном приложении здесь должно быть логирование + pass + + return {"success": success} + + +# Сервисы корзины и заказов +def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]: + new_cart_item = order_repo.create_cart_item(db, cart_item, user_id) + + # Логируем событие добавления в корзину + log_data = AnalyticsLogCreate( + user_id=user_id, + event_type="add_to_cart", + product_id=cart_item.product_id, + additional_data={"quantity": cart_item.quantity} + ) + content_repo.log_analytics_event(db, log_data) + + return {"cart_item": new_cart_item} + + +def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]: + updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id) + return {"cart_item": updated_cart_item} + + +def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]: + success = order_repo.delete_cart_item(db, cart_item_id, user_id) + return {"success": success} + + +def clear_cart(db: Session, user_id: int) -> Dict[str, Any]: + success = order_repo.clear_cart(db, user_id) + return {"success": success} + + +def get_cart(db: Session, user_id: int) -> Dict[str, Any]: + cart_items = order_repo.get_cart_with_product_details(db, user_id) + + # Рассчитываем общую сумму корзины + total_amount = sum(item["total_price"] for item in cart_items) + + return { + "items": cart_items, + "total_amount": total_amount, + "items_count": len(cart_items) + } + + +def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]: + new_order = order_repo.create_order(db, order, user_id) + + # Логируем событие создания заказа + log_data = AnalyticsLogCreate( + user_id=user_id, + event_type="order_created", + additional_data={"order_id": new_order.id, "total_amount": new_order.total_amount} + ) + content_repo.log_analytics_event(db, log_data) + + return {"order": new_order} + + +def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]: + # Получаем заказ с деталями + order_details = order_repo.get_order_with_details(db, order_id) + + # Проверяем права доступа + if not is_admin and order_details["user_id"] != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для просмотра этого заказа" + ) + + return {"order": order_details} + + +def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]: + updated_order = order_repo.update_order(db, order_id, order, is_admin) + + # Проверяем права доступа + if not is_admin and updated_order.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для обновления этого заказа" + ) + + return {"order": updated_order} + + +def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]: + # Отменяем заказ (обычный пользователь может только отменить заказ) + order_update = OrderUpdate(status="cancelled") + updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False) + + # Проверяем права доступа + if updated_order.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Недостаточно прав для отмены этого заказа" + ) + + return {"order": updated_order} + + +# Сервисы отзывов +def create_review(db: Session, user_id: int, review: ReviewCreate) -> Dict[str, Any]: + new_review = review_repo.create_review(db, review, user_id) + return {"review": new_review} + + +def update_review(db: Session, user_id: int, review_id: int, review: ReviewUpdate, is_admin: bool = False) -> Dict[str, Any]: + updated_review = review_repo.update_review(db, review_id, review, user_id, is_admin) + return {"review": updated_review} + + +def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = False) -> Dict[str, Any]: + success = review_repo.delete_review(db, review_id, user_id, is_admin) + return {"success": success} + + +def approve_review(db: Session, review_id: int) -> Dict[str, Any]: + approved_review = review_repo.approve_review(db, review_id) + return {"review": approved_review} + + +def get_product_reviews(db: Session, product_id: int, skip: int = 0, limit: int = 10) -> Dict[str, Any]: + reviews = review_repo.get_product_reviews(db, product_id, skip, limit) + + # Получаем рейтинг продукта + rating = review_repo.get_product_rating(db, product_id) + + return { + "reviews": reviews, + "rating": rating, + "total": rating["total_reviews"], + "skip": skip, + "limit": limit + } + + +# Сервисы информационных страниц +def create_page(db: Session, page: PageCreate) -> Dict[str, Any]: + new_page = content_repo.create_page(db, page) + return {"page": new_page} + + +def update_page(db: Session, page_id: int, page: PageUpdate) -> Dict[str, Any]: + updated_page = content_repo.update_page(db, page_id, page) + return {"page": updated_page} + + +def delete_page(db: Session, page_id: int) -> Dict[str, Any]: + success = content_repo.delete_page(db, page_id) + return {"success": success} + + +def get_page_by_slug(db: Session, slug: str) -> Dict[str, Any]: + page = content_repo.get_page_by_slug(db, slug) + if not page: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Страница не найдена" + ) + + return {"page": page} + + +# Сервисы аналитики +def log_event(db: Session, log: AnalyticsLogCreate) -> Dict[str, Any]: + new_log = content_repo.log_analytics_event(db, log) + return {"log": new_log} + + +def get_analytics_report( + db: Session, + period: str = "day", + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None +) -> Dict[str, Any]: + report = content_repo.get_analytics_report(db, period, start_date, end_date) + return {"report": report} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2d486eb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.95.1 +uvicorn==0.22.0 +sqlalchemy==2.0.12 +pydantic==1.10.7 +python-jose==3.3.0 +passlib==1.7.4 +python-multipart==0.0.6 +email-validator==2.0.0 +psycopg2-binary==2.9.6 +alembic==1.10.4 +python-dotenv==1.0.0 +bcrypt==4.0.1 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b2bcab --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,129 @@ + +version: '3.8' +services: + # backend: + # build: + # context: ./backend + # dockerfile: Dockerfile + # container_name: backend + # expose: + # - "8000" + # restart: always + + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # container_name: frontend + # expose: + # - "3000" + # environment: + # - NODE_ENV=production + # restart: always + # networks: + # - sta_network + + # nginx: + # image: nginx:latest + # container_name: nginx + # ports: + # - "80:80" + # volumes: + # - ./nginx/sta_test.conf:/etc/nginx/conf.d/sta_test.conf:ro + # depends_on: + # - backend + # - frontend + # restart: always + # networks: + # - sta_network + + + # backend: + # build: + # context: ./backend + # dockerfile: Dockerfile + # container_name: backend + # ports: + # - "8000:8000" + # expose: + # - "8000" + # environment: + # - DEBUG=1 + # - DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5433/sta + # - IS_DOCKER=1 + # depends_on: + # postgres: + # condition: service_healthy + # redis: + # condition: service_healthy + # networks: + # - sta_network + # # dns: + # # - 8.8.8.8 + # # - 8.8.4.4 + # # dns_opt: + # # - ndots:1 + # # - timeout:3 + # # - attempts:5 + # # sysctls: + # # - net.ipv4.tcp_keepalive_time=60 + # # - net.ipv4.tcp_keepalive_intvl=10 + # # - net.ipv4.tcp_keepalive_probes=6 + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + + # redis: + # image: redis:7 + # ports: + # - "6380:6379" + # healthcheck: + # test: ["CMD", "redis-cli", "ping"] + # interval: 5s + # timeout: 5s + # retries: 5 + # networks: + # - sta_network + + # elasticsearch: + # image: elasticsearch:8.17.2 + # environment: + # - discovery.type=single-node + # - xpack.security.enabled=false + # - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + # ports: + # - "9200:9200" + # volumes: + # - elasticsearch_data:/usr/share/elasticsearch/data + # healthcheck: + # test: ["CMD", "curl", "-f", "http://localhost:9200"] + # interval: 10s + # timeout: 5s + # retries: 5 + # networks: + # - sta_network + +# networks: +# sta_network: +# name: sta_network +# driver: bridge + +volumes: + postgres_data: + driver: local + # elasticsearch_data: + # driver: local \ No newline at end of file diff --git a/.dockerignore b/frontend/.dockerignore similarity index 100% rename from .dockerignore rename to frontend/.dockerignore diff --git a/.gitignore b/frontend/.gitignore similarity index 100% rename from .gitignore rename to frontend/.gitignore diff --git a/Dockerfile b/frontend/Dockerfile similarity index 100% rename from Dockerfile rename to frontend/Dockerfile diff --git a/README.md b/frontend/README.md similarity index 100% rename from README.md rename to frontend/README.md diff --git a/app.json b/frontend/app.json similarity index 100% rename from app.json rename to frontend/app.json diff --git a/components/Collections.tsx b/frontend/components/Collections.tsx similarity index 100% rename from components/Collections.tsx rename to frontend/components/Collections.tsx diff --git a/components/CookieNotification.tsx b/frontend/components/CookieNotification.tsx similarity index 100% rename from components/CookieNotification.tsx rename to frontend/components/CookieNotification.tsx diff --git a/components/Footer.tsx b/frontend/components/Footer.tsx similarity index 100% rename from components/Footer.tsx rename to frontend/components/Footer.tsx diff --git a/components/Header.tsx b/frontend/components/Header.tsx similarity index 100% rename from components/Header.tsx rename to frontend/components/Header.tsx diff --git a/components/Hero.tsx b/frontend/components/Hero.tsx similarity index 100% rename from components/Hero.tsx rename to frontend/components/Hero.tsx diff --git a/components/NewArrivals.tsx b/frontend/components/NewArrivals.tsx similarity index 100% rename from components/NewArrivals.tsx rename to frontend/components/NewArrivals.tsx diff --git a/components/PopularCategories.tsx b/frontend/components/PopularCategories.tsx similarity index 100% rename from components/PopularCategories.tsx rename to frontend/components/PopularCategories.tsx diff --git a/components/TabSelector.tsx b/frontend/components/TabSelector.tsx similarity index 100% rename from components/TabSelector.tsx rename to frontend/components/TabSelector.tsx diff --git a/data/categories.ts b/frontend/data/categories.ts similarity index 100% rename from data/categories.ts rename to frontend/data/categories.ts diff --git a/data/collections.ts b/frontend/data/collections.ts similarity index 100% rename from data/collections.ts rename to frontend/data/collections.ts diff --git a/data/products.ts b/frontend/data/products.ts similarity index 100% rename from data/products.ts rename to frontend/data/products.ts diff --git a/next.config.js b/frontend/next.config.js similarity index 100% rename from next.config.js rename to frontend/next.config.js diff --git a/package-lock.json b/frontend/package-lock.json similarity index 100% rename from package-lock.json rename to frontend/package-lock.json diff --git a/package.json b/frontend/package.json similarity index 100% rename from package.json rename to frontend/package.json diff --git a/pages/_app.tsx b/frontend/pages/_app.tsx similarity index 100% rename from pages/_app.tsx rename to frontend/pages/_app.tsx diff --git a/pages/api/hello.js b/frontend/pages/api/hello.js similarity index 100% rename from pages/api/hello.js rename to frontend/pages/api/hello.js diff --git a/pages/category/[slug].tsx b/frontend/pages/category/[slug].tsx similarity index 100% rename from pages/category/[slug].tsx rename to frontend/pages/category/[slug].tsx diff --git a/pages/category/index.tsx b/frontend/pages/category/index.tsx similarity index 100% rename from pages/category/index.tsx rename to frontend/pages/category/index.tsx diff --git a/pages/collections/[slug].tsx b/frontend/pages/collections/[slug].tsx similarity index 100% rename from pages/collections/[slug].tsx rename to frontend/pages/collections/[slug].tsx diff --git a/pages/collections/index.tsx b/frontend/pages/collections/index.tsx similarity index 100% rename from pages/collections/index.tsx rename to frontend/pages/collections/index.tsx diff --git a/pages/index.tsx b/frontend/pages/index.tsx similarity index 100% rename from pages/index.tsx rename to frontend/pages/index.tsx diff --git a/pages/new-arrivals/index.tsx b/frontend/pages/new-arrivals/index.tsx similarity index 100% rename from pages/new-arrivals/index.tsx rename to frontend/pages/new-arrivals/index.tsx diff --git a/pages/product/[slug].tsx b/frontend/pages/product/[slug].tsx similarity index 100% rename from pages/product/[slug].tsx rename to frontend/pages/product/[slug].tsx diff --git a/postcss.config.js b/frontend/postcss.config.js similarity index 100% rename from postcss.config.js rename to frontend/postcss.config.js diff --git a/public/category/dress.jpg b/frontend/public/category/dress.jpg similarity index 100% rename from public/category/dress.jpg rename to frontend/public/category/dress.jpg diff --git a/public/category/hat.jpg b/frontend/public/category/hat.jpg similarity index 100% rename from public/category/hat.jpg rename to frontend/public/category/hat.jpg diff --git a/public/category/jacket.jpg b/frontend/public/category/jacket.jpg similarity index 100% rename from public/category/jacket.jpg rename to frontend/public/category/jacket.jpg diff --git a/public/category/pants.jpg b/frontend/public/category/pants.jpg similarity index 100% rename from public/category/pants.jpg rename to frontend/public/category/pants.jpg diff --git a/public/category/scarf.jpg b/frontend/public/category/scarf.jpg similarity index 100% rename from public/category/scarf.jpg rename to frontend/public/category/scarf.jpg diff --git a/public/category/shoes.jpg b/frontend/public/category/shoes.jpg similarity index 100% rename from public/category/shoes.jpg rename to frontend/public/category/shoes.jpg diff --git a/public/category/silk.jpg b/frontend/public/category/silk.jpg similarity index 100% rename from public/category/silk.jpg rename to frontend/public/category/silk.jpg diff --git a/public/category/sweaters.jpg b/frontend/public/category/sweaters.jpg similarity index 100% rename from public/category/sweaters.jpg rename to frontend/public/category/sweaters.jpg diff --git a/public/favicon.ico b/frontend/public/favicon.ico similarity index 100% rename from public/favicon.ico rename to frontend/public/favicon.ico diff --git a/public/hero_photos/hero1.png b/frontend/public/hero_photos/hero1.png similarity index 100% rename from public/hero_photos/hero1.png rename to frontend/public/hero_photos/hero1.png diff --git a/public/hero_photos/photo_main_main_1.png b/frontend/public/hero_photos/photo_main_main_1.png similarity index 100% rename from public/hero_photos/photo_main_main_1.png rename to frontend/public/hero_photos/photo_main_main_1.png diff --git a/public/hero_photos/photo_main_main_3.png b/frontend/public/hero_photos/photo_main_main_3.png similarity index 100% rename from public/hero_photos/photo_main_main_3.png rename to frontend/public/hero_photos/photo_main_main_3.png diff --git a/public/logo.png b/frontend/public/logo.png similarity index 100% rename from public/logo.png rename to frontend/public/logo.png diff --git a/public/logotip.png b/frontend/public/logotip.png similarity index 100% rename from public/logotip.png rename to frontend/public/logotip.png diff --git a/public/photos/autumn_winter.jpg b/frontend/public/photos/autumn_winter.jpg similarity index 100% rename from public/photos/autumn_winter.jpg rename to frontend/public/photos/autumn_winter.jpg diff --git a/public/photos/based_outfit.jpg b/frontend/public/photos/based_outfit.jpg similarity index 100% rename from public/photos/based_outfit.jpg rename to frontend/public/photos/based_outfit.jpg diff --git a/public/photos/business_outfit.jpg b/frontend/public/photos/business_outfit.jpg similarity index 100% rename from public/photos/business_outfit.jpg rename to frontend/public/photos/business_outfit.jpg diff --git a/public/photos/head_photo.png b/frontend/public/photos/head_photo.png similarity index 100% rename from public/photos/head_photo.png rename to frontend/public/photos/head_photo.png diff --git a/public/photos/night_dress.jpg b/frontend/public/photos/night_dress.jpg similarity index 100% rename from public/photos/night_dress.jpg rename to frontend/public/photos/night_dress.jpg diff --git a/public/photos/photo1.jpg b/frontend/public/photos/photo1.jpg similarity index 100% rename from public/photos/photo1.jpg rename to frontend/public/photos/photo1.jpg diff --git a/public/photos/photo2.jpg b/frontend/public/photos/photo2.jpg similarity index 100% rename from public/photos/photo2.jpg rename to frontend/public/photos/photo2.jpg diff --git a/public/vercel.svg b/frontend/public/vercel.svg similarity index 100% rename from public/vercel.svg rename to frontend/public/vercel.svg diff --git a/public/wear/bag1.jpg b/frontend/public/wear/bag1.jpg similarity index 100% rename from public/wear/bag1.jpg rename to frontend/public/wear/bag1.jpg diff --git a/public/wear/bag2.jpg b/frontend/public/wear/bag2.jpg similarity index 100% rename from public/wear/bag2.jpg rename to frontend/public/wear/bag2.jpg diff --git a/public/wear/classic_bruk1.jpg b/frontend/public/wear/classic_bruk1.jpg similarity index 100% rename from public/wear/classic_bruk1.jpg rename to frontend/public/wear/classic_bruk1.jpg diff --git a/public/wear/classic_bruk2.jpg b/frontend/public/wear/classic_bruk2.jpg similarity index 100% rename from public/wear/classic_bruk2.jpg rename to frontend/public/wear/classic_bruk2.jpg diff --git a/public/wear/coat1.jpg b/frontend/public/wear/coat1.jpg similarity index 100% rename from public/wear/coat1.jpg rename to frontend/public/wear/coat1.jpg diff --git a/public/wear/coat2.jpg b/frontend/public/wear/coat2.jpg similarity index 100% rename from public/wear/coat2.jpg rename to frontend/public/wear/coat2.jpg diff --git a/public/wear/hat1.jpg b/frontend/public/wear/hat1.jpg similarity index 100% rename from public/wear/hat1.jpg rename to frontend/public/wear/hat1.jpg diff --git a/public/wear/jumpsuit_1.jpg b/frontend/public/wear/jumpsuit_1.jpg similarity index 100% rename from public/wear/jumpsuit_1.jpg rename to frontend/public/wear/jumpsuit_1.jpg diff --git a/public/wear/jumpsuit_2.jpg b/frontend/public/wear/jumpsuit_2.jpg similarity index 100% rename from public/wear/jumpsuit_2.jpg rename to frontend/public/wear/jumpsuit_2.jpg diff --git a/public/wear/kozh_boots1.jpg b/frontend/public/wear/kozh_boots1.jpg similarity index 100% rename from public/wear/kozh_boots1.jpg rename to frontend/public/wear/kozh_boots1.jpg diff --git a/public/wear/kozh_boots2.jpg b/frontend/public/wear/kozh_boots2.jpg similarity index 100% rename from public/wear/kozh_boots2.jpg rename to frontend/public/wear/kozh_boots2.jpg diff --git a/public/wear/palto1.jpg b/frontend/public/wear/palto1.jpg similarity index 100% rename from public/wear/palto1.jpg rename to frontend/public/wear/palto1.jpg diff --git a/public/wear/palto2.jpg b/frontend/public/wear/palto2.jpg similarity index 100% rename from public/wear/palto2.jpg rename to frontend/public/wear/palto2.jpg diff --git a/public/wear/pidzak1.jpg b/frontend/public/wear/pidzak1.jpg similarity index 100% rename from public/wear/pidzak1.jpg rename to frontend/public/wear/pidzak1.jpg diff --git a/public/wear/pidzak2.jpg b/frontend/public/wear/pidzak2.jpg similarity index 100% rename from public/wear/pidzak2.jpg rename to frontend/public/wear/pidzak2.jpg diff --git a/public/wear/sherst_sweater1.jpg b/frontend/public/wear/sherst_sweater1.jpg similarity index 100% rename from public/wear/sherst_sweater1.jpg rename to frontend/public/wear/sherst_sweater1.jpg diff --git a/public/wear/sherst_sweater2.jpg b/frontend/public/wear/sherst_sweater2.jpg similarity index 100% rename from public/wear/sherst_sweater2.jpg rename to frontend/public/wear/sherst_sweater2.jpg diff --git a/public/wear/silk1.jpg b/frontend/public/wear/silk1.jpg similarity index 100% rename from public/wear/silk1.jpg rename to frontend/public/wear/silk1.jpg diff --git a/public/wear/silk2.jpg b/frontend/public/wear/silk2.jpg similarity index 100% rename from public/wear/silk2.jpg rename to frontend/public/wear/silk2.jpg diff --git a/public/wear/silk_scarf1.jpg b/frontend/public/wear/silk_scarf1.jpg similarity index 100% rename from public/wear/silk_scarf1.jpg rename to frontend/public/wear/silk_scarf1.jpg diff --git a/public/wear/silk_scarf2.jpg b/frontend/public/wear/silk_scarf2.jpg similarity index 100% rename from public/wear/silk_scarf2.jpg rename to frontend/public/wear/silk_scarf2.jpg diff --git a/public/wear/sorochka1.jpg b/frontend/public/wear/sorochka1.jpg similarity index 100% rename from public/wear/sorochka1.jpg rename to frontend/public/wear/sorochka1.jpg diff --git a/public/wear/sorochka2.jpg b/frontend/public/wear/sorochka2.jpg similarity index 100% rename from public/wear/sorochka2.jpg rename to frontend/public/wear/sorochka2.jpg diff --git a/styles/Home.module.css b/frontend/styles/Home.module.css similarity index 100% rename from styles/Home.module.css rename to frontend/styles/Home.module.css diff --git a/styles/globals.css b/frontend/styles/globals.css similarity index 100% rename from styles/globals.css rename to frontend/styles/globals.css diff --git a/tailwind.config.js b/frontend/tailwind.config.js similarity index 100% rename from tailwind.config.js rename to frontend/tailwind.config.js diff --git a/tsconfig.json b/frontend/tsconfig.json similarity index 100% rename from tsconfig.json rename to frontend/tsconfig.json