From 88cff68f7337109f8bfacf0b096ee74760ed3d9f Mon Sep 17 00:00:00 2001 From: Implementation Engineer Date: Thu, 9 Jul 2026 01:36:46 +0200 Subject: [PATCH] feat: T03+T04 backend - FastAPI, DB models, Rentman integration, admin auth, APScheduler --- .gitignore | 11 ++ backend/.coverage | Bin 0 -> 53248 bytes backend/.env | 14 ++ backend/app/__init__.py | 0 .../app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes backend/app/__pycache__/auth.cpython-313.pyc | Bin 0 -> 4020 bytes backend/app/__pycache__/cache.cpython-313.pyc | Bin 0 -> 4378 bytes .../app/__pycache__/config.cpython-313.pyc | Bin 0 -> 1740 bytes .../app/__pycache__/database.cpython-313.pyc | Bin 0 -> 2369 bytes backend/app/__pycache__/main.cpython-313.pyc | Bin 0 -> 3806 bytes backend/app/auth.py | 77 +++++++++ backend/app/cache.py | 66 ++++++++ backend/app/config.py | 43 +++++ backend/app/database.py | 42 +++++ backend/app/main.py | 79 ++++++++++ backend/app/models/__init__.py | 8 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 594 bytes .../__pycache__/admin_user.cpython-313.pyc | Bin 0 -> 965 bytes .../__pycache__/contact.cpython-313.pyc | Bin 0 -> 1161 bytes .../__pycache__/equipment.cpython-313.pyc | Bin 0 -> 1868 bytes .../rental_request.cpython-313.pyc | Bin 0 -> 3132 bytes .../__pycache__/sync_log.cpython-313.pyc | Bin 0 -> 1287 bytes backend/app/models/admin_user.py | 12 ++ backend/app/models/contact.py | 16 ++ backend/app/models/equipment.py | 28 ++++ backend/app/models/rental_request.py | 50 ++++++ backend/app/models/sync_log.py | 18 +++ backend/app/routers/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 162 bytes .../routers/__pycache__/admin.cpython-313.pyc | Bin 0 -> 5249 bytes .../__pycache__/contact.cpython-313.pyc | Bin 0 -> 2668 bytes .../__pycache__/equipment.cpython-313.pyc | Bin 0 -> 6214 bytes .../__pycache__/health.cpython-313.pyc | Bin 0 -> 1536 bytes .../rental_requests.cpython-313.pyc | Bin 0 -> 6945 bytes backend/app/routers/admin.py | 92 +++++++++++ backend/app/routers/contact.py | 49 ++++++ backend/app/routers/equipment.py | 90 +++++++++++ backend/app/routers/health.py | 25 +++ backend/app/routers/rental_requests.py | 141 +++++++++++++++++ backend/app/schemas/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 192 bytes .../schemas/__pycache__/auth.cpython-313.pyc | Bin 0 -> 1022 bytes .../__pycache__/contact.cpython-313.pyc | Bin 0 -> 1312 bytes .../__pycache__/equipment.cpython-313.pyc | Bin 0 -> 2080 bytes .../rental_request.cpython-313.pyc | Bin 0 -> 2988 bytes .../schemas/__pycache__/sync.cpython-313.pyc | Bin 0 -> 1503 bytes backend/app/schemas/auth.py | 16 ++ backend/app/schemas/contact.py | 21 +++ backend/app/schemas/equipment.py | 42 +++++ backend/app/schemas/rental_request.py | 52 ++++++ backend/app/schemas/sync.py | 28 ++++ backend/app/services/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/email_service.cpython-313.pyc | Bin 0 -> 5837 bytes .../rentman_service.cpython-313.pyc | Bin 0 -> 10225 bytes .../__pycache__/sync_service.cpython-313.pyc | Bin 0 -> 8362 bytes backend/app/services/email_service.py | 110 +++++++++++++ backend/app/services/rentman_service.py | 146 +++++++++++++++++ backend/app/services/sync_service.py | 149 ++++++++++++++++++ backend/pytest.ini | 6 + backend/requirements.txt | 18 +++ backend/test_report.md | 89 +++++++++++ backend/tests/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 156 bytes .../conftest.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 7800 bytes ...st_admin_auth.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 14582 bytes ..._admin_router.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 12051 bytes ...ipment_router.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 18002 bytes .../test_health.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 3039 bytes ...entman_import.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 19482 bytes ...ntman_request.cpython-313-pytest-9.1.1.pyc | Bin 0 -> 14747 bytes backend/tests/conftest.py | 107 +++++++++++++ backend/tests/test_admin_auth.py | 87 ++++++++++ backend/tests/test_admin_router.py | 101 ++++++++++++ backend/tests/test_equipment_router.py | 77 +++++++++ backend/tests/test_health.py | 12 ++ backend/tests/test_rentman_import.py | 131 +++++++++++++++ backend/tests/test_rentman_request.py | 120 ++++++++++++++ 78 files changed, 2174 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.coverage create mode 100644 backend/.env create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/__pycache__/auth.cpython-313.pyc create mode 100644 backend/app/__pycache__/cache.cpython-313.pyc create mode 100644 backend/app/__pycache__/config.cpython-313.pyc create mode 100644 backend/app/__pycache__/database.cpython-313.pyc create mode 100644 backend/app/__pycache__/main.cpython-313.pyc create mode 100644 backend/app/auth.py create mode 100644 backend/app/cache.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.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-313.pyc create mode 100644 backend/app/models/__pycache__/admin_user.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/contact.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/equipment.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/rental_request.cpython-313.pyc create mode 100644 backend/app/models/__pycache__/sync_log.cpython-313.pyc create mode 100644 backend/app/models/admin_user.py create mode 100644 backend/app/models/contact.py create mode 100644 backend/app/models/equipment.py create mode 100644 backend/app/models/rental_request.py create mode 100644 backend/app/models/sync_log.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/admin.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/contact.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/equipment.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/health.cpython-313.pyc create mode 100644 backend/app/routers/__pycache__/rental_requests.cpython-313.pyc create mode 100644 backend/app/routers/admin.py create mode 100644 backend/app/routers/contact.py create mode 100644 backend/app/routers/equipment.py create mode 100644 backend/app/routers/health.py create mode 100644 backend/app/routers/rental_requests.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/schemas/__pycache__/auth.cpython-313.pyc create mode 100644 backend/app/schemas/__pycache__/contact.cpython-313.pyc create mode 100644 backend/app/schemas/__pycache__/equipment.cpython-313.pyc create mode 100644 backend/app/schemas/__pycache__/rental_request.cpython-313.pyc create mode 100644 backend/app/schemas/__pycache__/sync.cpython-313.pyc create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/contact.py create mode 100644 backend/app/schemas/equipment.py create mode 100644 backend/app/schemas/rental_request.py create mode 100644 backend/app/schemas/sync.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/email_service.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/rentman_service.cpython-313.pyc create mode 100644 backend/app/services/__pycache__/sync_service.cpython-313.pyc create mode 100644 backend/app/services/email_service.py create mode 100644 backend/app/services/rentman_service.py create mode 100644 backend/app/services/sync_service.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/test_report.md create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/__pycache__/__init__.cpython-313.pyc create mode 100644 backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_admin_auth.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_equipment_router.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_admin_auth.py create mode 100644 backend/tests/test_admin_router.py create mode 100644 backend/tests/test_equipment_router.py create mode 100644 backend/tests/test_health.py create mode 100644 backend/tests/test_rentman_import.py create mode 100644 backend/tests/test_rentman_request.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70c185c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.pyc +*.pyo +.coverage +.env +*.db +htmlcov/ +.pytest_cache/ +node_modules/ +.nuxt/ +dist/ diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..568c3d8744d1c2506c8afe8914821b3c9bd8c7d3 GIT binary patch literal 53248 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 Y{!oR7`D=Hmg5S`qh{1oj0Ap|d53nAE-v9sr literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/cache.cpython-313.pyc b/backend/app/__pycache__/cache.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..753615e4ad1f1fbc7015ea44e02e1c095a980739 GIT binary patch literal 4378 zcmd5<-ER}w6~EUrnOE8}#Ou5I~YD>Rrhg_1olU(MRe;4T{lBbhM z-eayxKNF|KhBX2+u7vNCedAh2H)E=z&T26=t80c8Q;bY(R?9AE%uK$(xnl`xb7|um zG~$eA$);vmx-nzgZl+mzW~i?eAVh$VTTnN?3#5m{V6T(){_(0VbpbHlWU^)(GVX5d zsqko}6RtRUmz$;u4mHOAj483I>J_DR!?YAb)sh!L%Wy_ha~Vx$nz@iOOwCO2w!mO)1H(~L|?Sy)Ip=1VSIvwdhDXEhdrb^K7xJwRTN8|3f)@Y3*)hkt&s)OonrdH4&f z=s#QF&u*Ex`h5BDFd_Ezski@=Kh=4|ydK`z})^N&#nY4t#StSQRLT2y9}z5#wl(rt&5; zK-6mTm}87UqcLz-wxDKnruHA^Xn{jovq-ejBCq5fFx?ygf<`I(LrbTYPL(6k`~A!P zrASXP((^RZTM~N=jFnM(`+70dSMc@~LVe}- zj{B3#llSFi`Jwc*Jy8lJ7Wu#WLtnP;ynpiZlMgaaTYCy(4@_J4lfeG3LWwPNKm5bZ z-ZO;$il$HVD+9o-cF^=NzuMV@{4fP-3m+-jyk>Kjl~s9|Q7afKZb9An7m)vVG+b)^ z?_QpCQWV^tL{GSmRZ-iFo>&C~-vJDGoQv0mNq{G#O)L4Vm2fwJL4bQy`9C;m86JoLfS;JK3bT+w@OT|h{9&uy6q)0@5NQwWK7`IWQ}*wq1=KEbaJ zx{*IYf!dx0#j-TWZyLh`g2MsCx4yRUkRounw~y4@h6r#p4U5xEv=A#K3x$3)4$P0)fWmfQv(L3G<4j&N8%h zqA}R`OjR*t9UNi1F|jZ*wmYL`0dzHp4Z;+ghd6FJ0jLRM!OCE{>P)+{F$}%lcLWwP z@xFWu zh6;$(K3>pS9f=rN*eM{8-azteH9^sbb7ZDin%&-r!n*hyFx9*Q88TLO*bd(TWj$MZ4ab10?k8dW$2gR-$Zsj!VBjq%CTK(t+bEdskDEH z|I?ALi9TV+%E%gl=JCiTHtSK3(Ebp{LGze%nlGf6_~q~w{ZOk!kbMA6_HaGqb4_XjQxz{CezlSypv z>56xT+4o=&g54Yevc^$LpON0b5dU)$eNMWblb&@q5qI4gzTsVSyXZIRgLVvkn*{ln VEh*Z*PLO0M@wMFv{7`WA^=}OyT6_Qi literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a3ea3e6055418cd056b32b354003e1d41bcf463 GIT binary patch literal 1740 zcmY*aOK;mo5MEN0EZMSS$CfSIiOnQ^DTyAaW2beCqD>1lO;V#Mk2SDZlWU0yMKZIi zJcJ$$^pF6(=#n2%pojjH6uvkM1Srr;b915mfX*(dCLzGt`R1`ZGdmoPXJ@AgK2Kim z-JNj=`CEg@Q_Pw19x~5}M}T;iXWzDN*v(om?cIvqrq)zP(+@{Ghn+ zc%}W~HQSq7BX^zcd^;$0%ZISjO-JzFaz7OpzuENY;e2+Mp)XP z@|+LR!Ye}ApprpTP%)@%Pz9z9nl@+#W(=wtGz-=JS=?nV_nR}iGX~AW8KYasb@N7d zHb)BvEgG~0XAPjs_k&U+gz8E51FSwBKs2la4?=+!QR1_xmk7DLvvvK`c1Kl=MjI)j761?FvzW3pq%yh3&m@aD?o2y#>PUc&kPDfS!1O&xMyJ0L;kp=xQR&!>dI)Iqoo(AnAA|07Lo_3PNAiTcaJElNWc8Dy zJEKd-&UNIMJ4fF=6~|5+`3?8EQyU@JM6l91x-}}G;$+o5`hK)^?6@cAHl906Bmdah z(4O_obLFI#bM=$j%0#xho;j_b)UIZ-tLU?O)5?zi7Qy<(qrK7B$IgY5wXKZX`pf;` zkNTgByT9Cl#w2`I!rmW1{LU!DjwX7XjdgLKk)JGF%rhCO@8Mh=x<49E*Nnoz75UtB zu@@zaecozVS($XXvlA~Pr>b49Ke}wnLK8(F;Vbm5ACcE3QeFCW7fWS4_8L=gNjoj1 z`FwuksUuXC!$IuJB#8vz?jB=#v=C2scl=!qu!X{GA^LtUo9~E{s;S5>s+b288Y$VCF~eSIEl*G zQ-Lv_@IK~F1;+#;NT?gdksRnnGN0o|K{stAloR4;v_3&3oa5ps7oOPG5VQ<(P!{CS zVU~zF7WRla1|(saN948~Gs4JXFFMbWcDX}t_fbb7l89bT${)O!F|}#EI4h~ntffhL z)m0}|N0)S~U|71OT6xLQ9mlY()MI*Kg7w4<-743mUd%tJG&U903p(+-G@`4nuGE+6 zsuI}iIHqf+N>talq-Wt05Qzodg|n_}SOrIGc@e^m$$B?{MU+DXgHIV56INyjC$l6_ z;1U6kqnaKEVbf))MZe^~V#+3xYMN5f&g-U=`T>n6cu!CiOD*b(;)N9jR>~&DqM}?Z zt7c=RLs6y-;_`1F%(592cITDXQxdW3ue(j-rq@FkqdU>>W<4!|Ad^ z(jVL8Lf#7tV~4Q<*@y0(>chey(rs&+xQ@@chHN;f^8D$SA*ocBBW97lleQ}mGA z?{NZ&HJ46&m|#3k*QRYRQnGDRDG^;Mf%`zrgL@rE@uK=n$pEo!DVkj@8m`9^-7OPq zgNNxQECXiez}LHx1YSPZ^!iKIVRRD9cp4aa2`{qa0TEef7dnFuahK2{mV7)xHEn85 z)}92$x1RP{4;n||V%ko^AKkcKzycZ{89?V6I}i#Ybq50b&Y+1v{Zwne;U=zWr+`E_ zp`$7E4%?5WsLrDURKvg7x=BOJ`S%CVfx5jL_6#+Q$%F5v7zy>my%H+m`n`wPDV$&~ zA2_C$;E(H;Hv6VDYv^VkLThu(H3Mxt!FiDlVdaI6&uDtdg)sC4Vw)yqkQdNQ+mE7P z&7Ac=HA@9{LU@{^<=Cg45f%s}@TEucR-`YQ7`=kM)XW%y9M7VEirdNqu?dT%Fh zCBIIuhy(Y^7>|71N%JVe= zMdHuC-1kz&FXF)ShK|bowfPs$7jXI2j_d(^3uk!#D;Cfq&c@i=fsDX>*9GWDoEheS z;sE{J2CG$svr(>!d5VRdnPi~42WN(a>fRv5Nj7tksSZ;W)q`8Ik-!pnCzQheBa6bw?VaITdmq&Dc^} z^0TBw%2pypb1Pp@_FiC8hx!3rEn5n$7JgRqgre@MG~s#SW`!_Kr~;g3TUIN1gN9}J z?A-_Nr=N2ix;lHeR<2yXQklCscW+CrJyt1RFWxzJ_w+ZXSNcYl`$itLkFE%#_l41? zEQ;=`%-xuK_!3vIi`94{N}@1&+nkH0S}(6Dea21s zA#K=%@`*qWL4>9=uf1iPO^SpP#S24iNP%0Xi~^x4tKq!t1pNx~|nmNO0ADvL3Gh+h8{on1y}AEEt^(C#NZgG1MjK0z?7 zg^}3(`H=c1Ri*vda|e%2*xjs@JG%Ft zbLO0LU+1;!^SKeU%-<$&-EkuH4~kd~aI@|HWJBl?5)nosBifP-W?*KMwgih=fzr4n zm#|~I3EPv71dn+Wb|jq%7j~I2pL8cY*ki)Zq&MNizJwq9P281iNCa@egx$%;L=XoP zAsk9H;ig10Zcc=8*!<^7wj^3{s|kBSM;mSvP?|}dv9?^FyHUD*fZ4-wN7~bk2C6^t zp|oos)+x4$z6iR-;jT0*`qTC^HnD-?HGUy&??z%kY)lJcaG1r1(}z-*>ML1L>WT%` zXE9{$A%s3ex-AYpgsw&rDx%bQozH@-HBVFtGTi(`8$vhV$bH>5@eV>BB!G4e;iKup zsR>JCeNOY~qhgcT9Jh(#I3u=*t;6tQz&lmvv7mG?b=`vNvvrpLDHVDvsiwD*YJMxJ z@SCOPY`Sgl7H3g3vN-yIq#L8x#|5cW%Bxw)P>Y%%>AF&w$=?wcR3j&hO{K(M0T<^D z1?#B1DUU`0HB|$nowmq zuPjJd0ZL$$%8XCd)B(*qrx+PsF$`6k)1x-xP_#K!QwXnWs*#arfW@AbVEw3_xRhVb ztEGaX8N`uOq`Z+Mc1bR%8fm~ll=2x29Ext}gr_@`vIdBIL|9Z*@NG3qH{mlk4om7f zoIYE(284J(ehqy$29DMP=JyCq*Z!y28dw8Frfx#hc8hEoJ%h#}yyQUcHuH#`w0n_; zrs*1HyNN7Wjk(FBTo%1PTYARP0WG%wvn%$dZ2%mNHV7>?`dRom!25S3pgEfpO-@4Z zgYG(vdXdS)S-W;3FRHmO+{>&@KwY4hN?u^Q&@3~=&N5MMvEO=DS-4f4IV<3KO{hJX zKwn-^ZY%hXFev1T^H`599u^G=(}x%Is)5M|BqkTz>sn0}*a8w7AaO8_~ zV0na7wb>$WgcIWc48#emXQ)7wB{q46cqt5zZdw6xU`3zL8#;YbK@iA5To~qp z#2?8jj^_$`A2>+P=rf91pV5CNJ|ks6QZzXZo)a%fsunBVAwjxG?TF@bI07o0q3iUG zeniXY2hM#r`APCw;PfVUW~-rNW$8CdUvzJ^bbs;g=S%mzm6o1zOYeG1??y|k+!(v( z*mC+;JfC@%PdgF=17JsVMg6i{|Bn&B$r^$gZ)1~$%i^N8$exT3694OFej~~e-oWaf?@@OqHdQ78 ztd#OnR>>$It2%Y;#ld~aV{bu4-b*yEE2JSqI8lucx#$NFsJR76HKIODBO|6SZU%L_ zLE^IF1tz>C%NYbZGCE-q-qEvtIW~v~JK#Ds zXaPfU0#ep(VW>k;C;j^e7^pkHaw1RgLG2m$|lQTpJMV{xa9R z&NXj2Bb7k7;tN(h?UlAeFFnW}-a!t#_iQSKFxFcmQvd%?z-a+=J9auTC(~azhUT~3L z^h>bWPyJ(!%qlbH;vRMY^e1M_!~J>mJpk8^F=IY%?KmZW%`#&R+}E6g!XAc>K67lK z<6`LQgeY92flxRm+<;;Miibt}saSuk9|Ei0P>npKYKZDImztMFm|ntOxqO3g*QdlA zggHq~#V6(S#f3}}3`W&-3oR%{t|-?~$mlXCB8_{@S**yA)Kw{OmIYWb=CL*zjS@bS zk&D?(hVV4@gOL$;wG7FtGnj^S!p}mTBb8LlQLLqk#R|1zB$h2=9n*|WI;B$0nqoCb zkC|nHTEsM_5I00bkkMwzX{W?xGSYfw&tyFmuU1poL)pA^ zpy3JAD1;keAZ?VZnw<35{#0dVGc&Id7gS2u_OhKblhLGtlF68s4n|(BpYTztlBOW@ zElj&6UV7Hr-P?7j{0!P^RklJ+dE`TvmykyWQwMz^jAIQ ze4$7y!x2m?bm~I-Wf)(w48y!YSN@9p+vwyrYTrihZliPmL_Oc36M%k)65pc4HafM9 zj{V&gD7zxhU6Cj6ZMcpsJ6?EN9*itIzxQ@*csrNDDICnXmqBD}eraoE+Mn>d2&P@u zBJ2zz-d}NdRlK2!uX%^(c-u1j(vP~1mpf0dcb+bH4zG6(f7P?uIkMrt_PlfCN#i%i zpC11v`e69psgxf)y@U3AR;2txt}v#w(%l>d{I#0zS-y zz=s(d?1tg?I9G?up`P_n&n7xjlU^NseBt4RO?0?U8F+mD;rUG@)G6^50bZRPVJ*}6|aBJ^3!T4`G?_8Jw literal 0 HcmV?d00001 diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..12fd25b --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,77 @@ +"""JWT authentication helpers.""" +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.config import get_settings +from app.database import get_db +from app.models.admin_user import AdminUser + + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against a hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(hours=settings.jwt_expire_hours) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) + + +def verify_token(token: str) -> dict | None: + """Verify a JWT token and return its payload.""" + try: + payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) + return payload + except JWTError: + return None + + +async def get_current_user( + request: Request, + db: AsyncSession = Depends(get_db), +) -> AdminUser: + """Dependency: extract JWT from cookie, verify, return AdminUser.""" + token = request.cookies.get("hms_admin_token") + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + payload = verify_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) + username = payload.get("sub") + if not username: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + result = await db.execute(select(AdminUser).where(AdminUser.username == username)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + return user diff --git a/backend/app/cache.py b/backend/app/cache.py new file mode 100644 index 0000000..093050f --- /dev/null +++ b/backend/app/cache.py @@ -0,0 +1,66 @@ +"""Redis cache client and helpers.""" +import json +from typing import Any +import redis.asyncio as redis +from app.config import get_settings + + +settings = get_settings() + + +async def get_redis() -> redis.Redis: + """Return a Redis client instance.""" + return redis.from_url(settings.redis_url, decode_responses=True) + + +class CacheClient: + """Wrapper around Redis for equipment caching.""" + + def __init__(self) -> None: + self._redis: redis.Redis | None = None + + async def connect(self) -> None: + self._redis = await get_redis() + + async def disconnect(self) -> None: + if self._redis: + await self._redis.close() + self._redis = None + + async def get(self, key: str) -> Any | None: + if not self._redis: + await self.connect() + assert self._redis is not None + data = await self._redis.get(key) + if data: + return json.loads(data) + return None + + async def set(self, key: str, value: Any, ttl: int = 3600) -> None: + if not self._redis: + await self.connect() + assert self._redis is not None + await self._redis.setex(key, ttl, json.dumps(value, default=str)) + + async def delete_pattern(self, pattern: str) -> int: + if not self._redis: + await self.connect() + assert self._redis is not None + keys = [] + async for key in self._redis.scan_iter(match=pattern): + keys.append(key) + if keys: + return await self._redis.delete(*keys) + return 0 + + async def incr_rate(self, key: str, window: int = 60) -> int: + if not self._redis: + await self.connect() + assert self._redis is not None + count = await self._redis.incr(key) + if count == 1: + await self._redis.expire(key, window) + return count + + +cache = CacheClient() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..1146d3c --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,43 @@ +"""Application configuration via Pydantic Settings.""" +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + """Settings loaded from environment variables.""" + + # Database + database_url: str = "sqlite+aiosqlite:///./test.db" + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # Rentman + rentman_api_token: str = "" + + # JWT + jwt_secret: str = "change-me-in-production" + jwt_algorithm: str = "HS256" + jwt_expire_hours: int = 24 + + # SMTP + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "info@hms-licht-ton.de" + + # CORS + cors_origins: str = "https://hms.media-on.de,http://localhost:3000" + + # Admin seed + admin_username: str = "admin" + admin_password: str = "change_me_in_production" + + model_config = {"env_file": ".env", "extra": "ignore"} + + +@lru_cache +def get_settings() -> Settings: + """Return cached settings instance.""" + return Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..278ebe3 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,42 @@ +"""SQLAlchemy async database engine and session.""" +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +from app.config import get_settings + + +class Base(DeclarativeBase): + """Declarative base for all models.""" + pass + + +settings = get_settings() + +engine = create_async_engine( + settings.database_url, + echo=False, + pool_pre_ping=True, +) + +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncSession: + """Dependency: yield an async database session.""" + async with async_session() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def init_db() -> None: + """Create all tables (for testing / first run).""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..d1b181b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,79 @@ +"""FastAPI application assembly with CORS, routers, and APScheduler.""" +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from app.config import get_settings +from app.database import engine, init_db +from app.cache import cache +from app.routers import equipment, health, admin, rental_requests, contact +from app.services.sync_service import SyncService +from app.database import async_session + +logger = logging.getLogger(__name__) +settings = get_settings() + +scheduler = AsyncIOScheduler() + + +async def run_equipment_sync() -> None: + """Scheduled job: run equipment sync every 6 hours.""" + logger.info("Starting scheduled equipment sync") + async with async_session() as db: + sync_service = SyncService(db) + result = await sync_service.run_sync() + logger.info("Scheduled sync complete: %s", result) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan: init DB, start scheduler, connect cache.""" + # Create tables (for dev/testing; production uses Alembic) + await init_db() + + # Connect Redis cache + await cache.connect() + + # Start APScheduler + scheduler.add_job( + run_equipment_sync, + trigger="cron", + hour="*/6", + id="equipment_sync", + replace_existing=True, + ) + scheduler.start() + logger.info("APScheduler started with equipment_sync job (every 6h)") + + yield + + # Shutdown + scheduler.shutdown(wait=False) + await cache.disconnect() + await engine.dispose() + + +app = FastAPI( + title="HMS Licht & Ton API", + version="1.0.0", + lifespan=lifespan, +) + +# CORS +origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_methods=["GET", "POST"], + allow_headers=["*"], + allow_credentials=True, +) + +# Routers +app.include_router(equipment.router) +app.include_router(health.router) +app.include_router(admin.router) +app.include_router(rental_requests.router) +app.include_router(contact.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..51d7e33 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,8 @@ +"""Import all models for Alembic and Base metadata.""" +from app.models.equipment import EquipmentCache +from app.models.rental_request import RentalRequest, RentalRequestItem +from app.models.contact import Contact +from app.models.admin_user import AdminUser +from app.models.sync_log import SyncLog + +__all__ = ["EquipmentCache", "RentalRequest", "RentalRequestItem", "Contact", "AdminUser", "SyncLog"] diff --git a/backend/app/models/__pycache__/__init__.cpython-313.pyc b/backend/app/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45bbd7bdf37ebbf9ea93d4eb234f264a75bcb03b GIT binary patch literal 594 zcmY+C%Wl*#6o#G5B{Ne7sVyv!y6_4Vp&8Etu|lY&qEeHpDvi3f+{Da;CUJV=2$dag zz=j84$s^TLgaj*gNb>?5Cq+!X`1K#3i$7alPp1=t>sxdB;fq7aFB|+@?wwhmq4`Q8 z0wi)GH*^9QTxxwU>;--32Ll+iaX%ae9(b)Egri^#<;3;3`Rht@3J?rSVG7v?^wL@q%57 z_(~QD!lYl*$NMx{t9;vkd6T9k{()@iz=vDfyrx;q6S(PT}4w!SH_VZ zE^G7hfCr*gH5YlFUNY;Jll5H+*?PmI_H0k!xiC{7Hf+D{u*j<8(JBR|>K<55l|?sK zOON!;rP33oRD8$CNlaNLE;R|d!k4dCqe-%6mU0lAr^0fRVUEGJG8};#P=GOV*ssF$Ll@d2Ud8F>O0o1PnqWU zwoSHd+WW_moCN7$V>8Ls9eA-*(9#J*8mgOh+6~E9YjQm>@@aR zj5k3%YGUHCQH))Wc$UEfeyLl&M_r5|!;|~`MQk}w=JLJ^c}}-uT9B{R-5J|5YTk_3 zaAv%@Tlp`=$dlV&p?OpcqsP;$1}0x`;g5rw#`+`L5JGn4Jo&uD5ta(p5&GyI4Nw||Lv>$gf<8N;tU3+);c(jwb(=K(>V~5e>>tCloPk*`DN#B1_ zgzOpjdM?Hy7@G}4=<4p;#5QWQ-nvjKQ0bBw310B5Nw}(fWGMfkE%+AedRCWTX6h8*h&CG literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/contact.cpython-313.pyc b/backend/app/models/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c261f9e72ef6a9681969a9e974457480277ffe37 GIT binary patch literal 1161 zcmZ`(&2QX96d!-@dc6tTCIKOcR-r20R*8FPkjh7Zlz{3g3&M+fS!pz}Cy5(>Y@f$1 z%V{Mf#IaRWDaUdPHv|XxE09(kU`MJ@#VI$Wz!_$2r}TnX`{w=Ty?Hag-#q)Y-L{c< zzIoEW^O=g!9}=8BM(v#Th4Tpc2#~M%>Mg}nLG?7yJRNk;0K+rE^enKv1~j@dN4wSZ zY_PlNCA5Nk{bl4ELwjAT>OpJI>{(wd-^fx%0@mHlAnE4CP7>vLl%?Gy3u(M|B9nU- zH*aKdk)+(ZnKHUZftx;qDBa`wfPT)nwV7oxB`I$XZfU* zk;d7Yvk_Wl%&BX%OEg!b`Ddsp>c3E;&hXr)7wZ#0H>{xb8u_-!UGQ75xaaiR+^Q8l zku-TDsPxSTJ(agc5G4f0_<)Xin-naI(g0{eQ+D!?$o^2s)kuptCOa`bc~8uU2`i+# zq==bt=W+`7DBybLTh#9@@dm~zNhrqL##r215li2}_-;YsIx>&(ZUi}tqm-tZ2)8k2 za+4|!vL`?nhO1Esvg5ceL)@SViDGVy_Op~i$;e7*o)ggnmQ0%$lta72~T$W!NIVZK7YEi6yA zpEl%!xWx2q=DfGYyZHzw~2^QWdL$_!oj9rb@j{VD1{L9hO0+RDb);onFMWrcqMMZq<; literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/equipment.cpython-313.pyc b/backend/app/models/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60cad0a9555a1ad3693591d60e7cdbb63b4eb29d GIT binary patch literal 1868 zcma)6O>7%Q6kglw_4+@~kDJn#nDmFZC2<7_qJmcaNg}ds0?OuuEUnhNlVr(y*Ll0! zCOxf$#DzTfomg6fJdqj;*?ua;D!Wm#<59@1gyPp-uJ#Yvv1ye zGl$u1O2Fr{rwbom6b0cI2D(rDNICofl}`m#06~qY(c2Lv3Q;8nF+~JXi9=kGKvEKr zP-KvmBqWs-q?9zIOUyrZJELSFTM~waQ9%`764ZD#I~D7!4Y?JmoVY(RvmRKDI&u7x zp&P5DRCi5cm&UAm!v&w1rDbsIrA6fHx>KGwV3W%!D&2DJpzhGboa2)f0#s6cu$&bt zE|CqNCT_Z}O?2d^XKu~S-?&3XMSX99#^)T9Y|!M=-29BXbYuQ~DlP|(QI1gYrtT4= z6Iw?~N8s@L2+KiLfC%OxszxBDMnSB`M}?}S#_%Uzi9v#68TT2VLVxxkBdH1Gl08oD zagv(65`|Qc>CNhTK)T1Jy0-2JnSbH3oXhom^Bni}aNz~q&+!08o>ntc(N63=??KM8 zef=|vAYaExO!qgE|bLe~O|M~vsT6xUw>FODbu1_s=W9PVE z9e558szY5o8H1PAB8;q@C=b(Ium5hb_B&uzr+LzCN{u(=QaMV~4Y2AuY-(#{lV
BJA1QFG^?*>>32a-h+2r6)WXcgJ;&2c$!Oq+POEUIc!X(RjVEd@% zJOXzKXkAMbPNNT%T_p2VPV_5ut{YDS+4az@8RfmLPahIUtofIjhz^W zkDm(*V?$cB!vrGq8 z6`DKRj;6zkhua&{G|$H`8|ZCGenf&xJM9g34;$8@DZE z)gSj=XWYL$S*hv98ga~u-e^?#&E{2lMVx4CLLS%Qf@Q$_1I5R}kAvsK`Ja>dt;Fuq zqrv^;weV)UaN>c~gomlU$w%k+3)e%Xoi1!&Xb$XL+Kca}C&KA=c3^w9`TCC1%8rDy z?flU8a#P(|Yvo77cia8L4~o07ho@To=fm5Gnu9y9p)z!;IlWU27jW*@^e0uEHgNL6 ztGlDS`ooFVKsmhA&h&4;**v@Rb}KUy&b0fATle= z+x-HsW9Nfc&_?BVI&FUqt|DYtxK~2)EEb7G+QQ^dg82jgKNZgWCKV&n*3$0+4t#~b E0OZBxRsaA1 literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/rental_request.cpython-313.pyc b/backend/app/models/__pycache__/rental_request.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ef1d0235c6a78809c98f49d2975ed0ee49b2613 GIT binary patch literal 3132 zcma)8OK%(36&{kqr}z-XhbYQ+XvvX9+Y-~nc563wVoO$PDxw38`a%c~M#C#=qTvjE zW*A#u5h&12Hx7&f61bt@MWvn4F4|>&0tupMbpfL=y3($IoON>U42ODcI)cu<=Y02h z?m6G#Q6v)L@Y{J?`sPW1!e#Q+3~K?oK@5X!N!p2eYJ7{XScUyKx^5Y2HJZh{lMSx)ekqqjWv9uTvvezXQo zScj{@eCXxuGSN&)%>jAVAcmQfvFX;;Q*P#~G6yd7ms@awM{omQ_D*nRpWwki{*DLymL6b! zxaG2EBH|PLhy@2&V1W6A;2k%FEMy4BF)e&bi=3b3%!a6i`38FI{Mi1Q!{?E~toV7X zKWk)wgq+b%3yA?XxXl1d4zMA|=CqI+U}4ARw2-#2(Se>Bj6L>dM2Mn~#sKla^?wBiI##bE{;=+iKz}K88Poka_QnxTJ*mu(f zSA;ZtSRKu0XvB`}WjnAB{fN_;9ok|)+KrVxHyx^jQj=g)+`!n3NDWh0G#N+@li)Ci zlNR!B>T75U<_8UE4ONv^RdVa*KDu9Tmm0a4VX8WBYr5=G4ChRXV3QGC{r)IJgu#We*C zQ&lvg=?F(e(PS64abT1JJiDb^sQ@D}It*lu;I=XmwMK0fxgbQo#Zy?Df#vp41y9P( z1SSnwee@6wsJd(=J`L9i7`i6PdP6g5Sl00lvb_L}Ihw53>XL>}j{Z$!h?05rNF*kv+3-#BCVMs`1Q%oCSUaU!)6DOilp$W%kY-+OO z-@!o3NC0#IWO@!5@l%hcKc^v#CA@t_luQUStC#Vq0Yj`G?R!_0plYDEQ=+x9BI9V} z)71t(uqeV+9D9WS99<|#9~Bw~6rSs_QBk0&;(b|GTv<;q^u@ zHi24VZ0qXl&zfIiXM3uhXkFj;b`!TzPhZ&nO?$5ObuXQ7J~>Ecw{N#6Tff{-b(24D zF4_7->uxVOg<5KSd!hYFYq6J_Zk7(lKG^ZK;Z3MFcD=)=2MhNSQ@Ct2`D*1)YV(VO zNaDMNt&d+9cZ1!?$IXSav$DCJ^sf6&cK`R?>}MT*6!A9Sy1*91iv{*ciEaxEx1og0V4bBjp5YX<_sPGbe=%q^e{2EL=uk99}^|lcyt7v{GXQ zMi6^xw*pBW9AaiRbk{AtPfigv`WHNz! zY+*(P8hFUW921O7>^ObIdh9JM!QR983kpUA*Uk|^=F-;ly-??qFFMO#cc;sp zs@l!eI(*7X?47%v*@wN!xn5!(Q}1+^j$huH+8udwtv5c?;WLaOs=MC(XWiHxWCe>$ zpcU%HCy_nQ=Hk25<#yrwtKC$wS+Y{?cIOw1z4VuzRLRLTcKlL4PW_@-(PdWJjAts+ z{5q*^o^UuTR2I=&^~k1Z%m0k0`&4{9aaM<#&DCKf(OE25(klKYg$NsL1&QUg)1IV( zPwkp|AAX19T=;MN48{LEE|=?oyZImP>A$$A|Ky&&<*vTvF246=UH4wizvr-de>duS N;@aYW;8<%t&QC9^Ki~iW literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/sync_log.cpython-313.pyc b/backend/app/models/__pycache__/sync_log.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2fc8a29c2089e0e975034956b5a1138d1fd6345 GIT binary patch literal 1287 zcmZux&2Jk;6rc6SuI<=yf*q%nf~=?#F_Pm@l0yYlw536D9aFKExL6u(cE`?={czsS zwsB9EK!_t%RLMuUg-x|$tB&8Vy1vDnYR`u%I|}WA%RFAS-GL9h z%E2%XZLhy^Cg0XHq2BbJ(Dj72>v7g+AXJ?L0z|6Aj=9KocK2@So$GsdM5-5hrbzF4 z7CWvfB6Y(Em>GKz@9K;>7f3uLT>=V*n9vnS>Iq17(`%%w>Ph^it|mc?_zZ@V-d5t5 zx~gk>24&g*m1VCcAUD&J(-)9i_z#zlxWZf;EJl2>yY$7{%(H(TYh-)I>3P)jMvQ&+ zw$v@7X9az68;dB{mI5n!5i0$q+Op7Os#|{lOol8n0df=*->D_U!T@a7fFV6(Ls2k7 z?%SRTOxED6is&7YeN_~MXS~pHjD3d*&0;+xba+jH65>GGMYD(?2xB;A5A~z$b!G9v{+N#J<>KQJhROBedwg??b=P5l34QD1PQrfd2;EwGv&qr~A zQZ8MJpabwt76j-^KECJ~w!;RT zK2R3{2DX|e6=$Trr03s*RY~Pd-yJv%gTWXeE3X!3?ob2z0lK$BWxU)r5rM6`A99A* zZ~?LB_$T6%hOyZQ18Cgy;n1?7ao`4Z$2Jdmo%>#$Z*DdAaSE7cHH^Wa5lu_bm?v{% zFoZm+i8kqW@C|O?l0VAtjP_n+i{EQMbRL%{*=wU4)1}ITyW{hqCY_vNq zlpgGiubebbS0;tVXlGivH2(6`m{c}LpHE9~j^90fe^RQCTGR5y@zzOev^QN|9cLe1 z{%!r|^GG)cqVm?0OIIGXo>s0uS-JUj8`kG;wQG5yQEK_7tZZ=P z7>;?s+~Mn5MRkg@{yNQZI`bIwhGk%daehI5tm1HG{$dL?q(tVUxLvM69a%Y`ft;eh il8T~C$p_C#>vz(ckk&Ku_TOq=`BeGN`iICf((w|fu3-fL literal 0 HcmV?d00001 diff --git a/backend/app/models/admin_user.py b/backend/app/models/admin_user.py new file mode 100644 index 0000000..cb4f1ff --- /dev/null +++ b/backend/app/models/admin_user.py @@ -0,0 +1,12 @@ +"""Admin user model.""" +from sqlalchemy import Column, Integer, String, TIMESTAMP, func +from app.database import Base + + +class AdminUser(Base): + __tablename__ = "admin_users" + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(64), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + created_at = Column(TIMESTAMP, server_default=func.now()) diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py new file mode 100644 index 0000000..2618334 --- /dev/null +++ b/backend/app/models/contact.py @@ -0,0 +1,16 @@ +"""Contact form submission model.""" +from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, func +from app.database import Base + + +class Contact(Base): + __tablename__ = "contacts" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + email = Column(String(255), nullable=False) + phone = Column(String(64)) + message = Column(Text, nullable=False) + privacy_consent = Column(Boolean, nullable=False) + email_sent = Column(Boolean, default=False) + created_at = Column(TIMESTAMP, server_default=func.now()) diff --git a/backend/app/models/equipment.py b/backend/app/models/equipment.py new file mode 100644 index 0000000..6c1e320 --- /dev/null +++ b/backend/app/models/equipment.py @@ -0,0 +1,28 @@ +"""Equipment cache model (imported from Rentman).""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DECIMAL, JSON, Index, TIMESTAMP, func +from app.database import Base + + +class EquipmentCache(Base): + __tablename__ = "equipment_cache" + + id = Column(Integer, primary_key=True, autoincrement=True) + rentman_id = Column(String(64), unique=True, nullable=False) + name = Column(String(255), nullable=False) + number = Column(String(64)) + category = Column(String(128)) + subcategory = Column(String(128)) + description = Column(Text) + specifications = Column(JSON) + images = Column(JSON) + rental_price = Column(DECIMAL(10, 2)) + brand = Column(String(128)) + available = Column(Boolean, default=True) + created_at = Column(TIMESTAMP, server_default=func.now()) + updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now()) + + __table_args__ = ( + Index("idx_equipment_category", "category"), + Index("idx_equipment_name", "name"), + Index("idx_equipment_number", "number"), + ) diff --git a/backend/app/models/rental_request.py b/backend/app/models/rental_request.py new file mode 100644 index 0000000..7cd7554 --- /dev/null +++ b/backend/app/models/rental_request.py @@ -0,0 +1,50 @@ +"""Rental request and rental request item models.""" +from sqlalchemy import Column, Integer, String, Text, Date, TIMESTAMP, ForeignKey, func, Index +from sqlalchemy.orm import relationship +from app.database import Base + + +class RentalRequest(Base): + __tablename__ = "rental_requests" + + id = Column(Integer, primary_key=True, autoincrement=True) + reference_number = Column(String(32), unique=True, nullable=False) + event_name = Column(String(255), nullable=False) + date_start = Column(Date, nullable=False) + date_end = Column(Date, nullable=False) + location = Column(String(255)) + person_count = Column(Integer) + contact_name = Column(String(255), nullable=False) + contact_company = Column(String(255)) + contact_email = Column(String(255), nullable=False) + contact_phone = Column(String(64)) + contact_street = Column(String(255)) + contact_postalcode = Column(String(16)) + contact_city = Column(String(128)) + message = Column(Text) + status = Column(String(32), default="pending") + rentman_request_id = Column(String(64)) + rentman_sync_status = Column(String(32), default="pending") + rentman_sync_error = Column(Text) + created_at = Column(TIMESTAMP, server_default=func.now()) + + items = relationship("RentalRequestItem", back_populates="rental_request", cascade="all, delete-orphan") + + __table_args__ = (Index("idx_rental_status", "status"),) + + +class RentalRequestItem(Base): + __tablename__ = "rental_request_items" + + id = Column(Integer, primary_key=True, autoincrement=True) + rental_request_id = Column(Integer, ForeignKey("rental_requests.id", ondelete="CASCADE"), nullable=False) + equipment_id = Column(Integer, ForeignKey("equipment_cache.id")) + equipment_name = Column(String(255)) + rentman_equipment_id = Column(String(64)) + quantity = Column(Integer, nullable=False, default=1) + unit_price = Column(String(20)) + rentman_sync_status = Column(String(32), default="pending") + + rental_request = relationship("RentalRequest", back_populates="items") + + __table_args__ = (Index("idx_rental_items_request", "rental_request_id"),) diff --git a/backend/app/models/sync_log.py b/backend/app/models/sync_log.py new file mode 100644 index 0000000..5f7eeba --- /dev/null +++ b/backend/app/models/sync_log.py @@ -0,0 +1,18 @@ +"""Sync log model for equipment import tracking.""" +from sqlalchemy import Column, Integer, String, Text, TIMESTAMP, func, Index +from app.database import Base + + +class SyncLog(Base): + __tablename__ = "sync_log" + + id = Column(Integer, primary_key=True, autoincrement=True) + sync_type = Column(String(32), nullable=False, default="equipment") + status = Column(String(32), nullable=False) + items_processed = Column(Integer, default=0) + items_failed = Column(Integer, default=0) + error_message = Column(Text) + started_at = Column(TIMESTAMP, server_default=func.now()) + completed_at = Column(TIMESTAMP) + + __table_args__ = (Index("idx_sync_log_started", "started_at"),) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/__pycache__/__init__.cpython-313.pyc b/backend/app/routers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ade1a7f32b5a6e0621f15d66979730b60368855d GIT binary patch literal 162 zcmey&%ge<81jTp#vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iOexiYXX>pN$ zd45rLN@kIMMsBfgPG)jOiEc@Lo^FYOiGEUIa&~H7ihg22fqqebX-R5Pv3`7fW?p7V le7s&k8;e1VkIamWj77{q766>3C#?Vg literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/admin.cpython-313.pyc b/backend/app/routers/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03faf21b6b2bf4f572f86ca22ef881ffda0b71c2 GIT binary patch literal 5249 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 literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/contact.cpython-313.pyc b/backend/app/routers/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ff5111a841aa2b14d71ef17526de1214346ded9 GIT binary patch literal 2668 zcmcgtO>7&-6`uXy<eH?!}(ISz*d2*$#n^0zOF2>q1`{_rdy9DfPZ$4EmMX^h6sGdUKsISz9<9`iW? z3po*s32Ni!rJRgqkLTx=TmT0=UYHN&LOA5{;(R!#Vl@}R5l@!p`*Qub-{a-^Xl?)x zBv6T+K$@ZjGK?0SVR5V!D_rzpolq@QifG{(w!}@KQs64nwM;XbT2i%0NznRAvesXU zOv_qy1l{IuGgo_y@!+k2RBS!=s%^Qt;U<=CTu)%T>6$qGg!-feqRhTESMXFs`k~n{ zt%^g!uP-jXmAz+}4Yz7rL@Jnfnx^9t(Q$RR>7*DEnsr*1p_z^Y4j_xSOt)OQ37pj1 zEX9&=k9`%Jx@(e1Pb`>D!?qlghRN3TYE3ioTGgPkVCcq*xgN^sjcUf|q9#(0frw%Q zo6FUEgm?8@4neR&#l2|dx?M4AL~7`*nypuegnOXb%1t6-(`{nQ;CdHBi45O+@Et#8 z5t>8^`1eJoKrN?6vM1|<=)ZUrAj0Xfz-WxcX>5^G(G}L>mw4Z!02z3COrRpKaT-6v z&LK@$0i0{h$6Q_rAxkI`{$s3vLh3~Mq=5noqCg+8A@ zp-Tfk5B8YPpHM&BzzU~7JwOYXP7lzIoYo=8^BKVsi)`WSv$mq>?~_2_G5)NFpV6mp zCxMpE`QE;dC5@q?RG9SjClrwVpYA{lqpkD=PhN!ZP=Dgxs#US?=AQ^4ulFz7cA~CZtwgW- z94Ojgw`-IQC{Q9+Oc&xp4j0Kw9A&e zCkl9^Ou-Z7-@O#mT20BAmKu)JiIx>ny2#7b29YV)@~YW_W@tAp7t;zRVLzJJDIMCo z61_oQ$>^6dO$TT0+IY26#hI15GgYe^EAEtQTT||(>C8>tSOtjCRvVda|9AZMnr^g+ zYIq&mKLMVG@bqBLOAJC!&<5(LD4F@>oloBRP})|v)cwB6FZ(8cKeI8{?jPG}?X2!b zzPs`IL3C(4^NY-*aKg1-{)P)kQfV*IHB+%bpP=Z{$6@GAh|U!DkMVE_OC literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/equipment.cpython-313.pyc b/backend/app/routers/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..052c5063f5bbe8b7d737953366fe8e4d9a9e1d67 GIT binary patch literal 6214 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 literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/health.cpython-313.pyc b/backend/app/routers/__pycache__/health.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c6678458004fef984377680f8555efb8dbf847d1 GIT binary patch literal 1536 zcmZ`(&2Jl35PxsKH~vW4MznPmdu>RO-NxIhiBM@Mic6%{0fW4NK(S%VBXqGicnX2Z+`RUH?wbN zW~ZA-3?O*k|E1FUokQq1rbLg_2i->i9wGw~WMG4v!DWtcWuEXF)A<>pED|xq!i-dw zi5y~aMk%XAEf0`^P?u(6EkY{YAW$LTn(?dLHS0%s)v8K3f3UCVHMzv;QamKL0#ZqHH4aTl7d0}RnJA?{jCH#<$e z5x$5@J<0eT;>9FYX~mRpvtH7=q)~j<}vVY!OFaZ%K`UN z#C?OG$9Z{ev>Iku>y<(C12b6m-_*=b=dxzcUDk+WH~k`2EzflwD{$;J#h89~`qqcq z)e4P+?Oy}syv00ErCtbp$U-Bs;OxjeOouL=*vFQzwpSS-6VQr$scPstwt~wct?^-dBx5ujrYE~9(FrPL)aKBe-pOnXdN9%C~)YYY*53L2Ub3*S6n2!tktB$@C&@DpmNu5Y@%ELG9c6^6?fdQhWM(&+*-K{k zmF$j^g@|V_d^frqyRjqOI6j5cOMgCz4R;ZS*hA?J@rVcX*Rh1u^r4cCY`KFo*$su+ za)0})aJjF<(g?mcGBts|$5YpY9|S;;&y`|mi^nBd*b+t=&f}6QY!w8Ci_Bx|8V2Il zM7$*M+n9rsZQuhIc!p&R%xyK?WARdo-##01&SQpCj8n;^z(m zz4nlLm7_dN6e`$FD+p`#BkBSh5jFv-GP&g0%WdZ-DS*mmfd4UUkTi@BQR*4G_!M1u ziu7mbbpXGrr#HtQtvqV&s@K;gs0ycio%%9$uXHzkSh)Jr#mD+FO5n-O$s;Cyw*O$F O%Oh^+m;*%e5c(fs1#0&I literal 0 HcmV?d00001 diff --git a/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc b/backend/app/routers/__pycache__/rental_requests.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9cdcca84c6816f5b63034c66e11f782ad14612ee GIT binary patch literal 6945 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 Any: + """Admin login: verify credentials, set JWT in HttpOnly cookie.""" + # Rate limiting + rate_key = f"rate:login:{creds.username}" + count = await cache.incr_rate(rate_key, window=60) + if count > 5: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts") + + result = await db.execute(select(AdminUser).where(AdminUser.username == creds.username)) + user = result.scalar_one_or_none() + if not user or not verify_password(creds.password, user.password_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + token = create_access_token({"sub": user.username}) + response.set_cookie( + key="hms_admin_token", + value=token, + httponly=True, + secure=True, + samesite="strict", + max_age=86400, + path="/", + ) + return TokenResponse(access_token=token, token_type="bearer") + + +@router.get("/me", response_model=AdminInfo) +async def get_me(user: AdminUser = Depends(get_current_user)) -> Any: + """Return current authenticated admin user.""" + return AdminInfo(username=user.username) + + +@router.post("/sync", response_model=SyncTriggerResponse) +async def trigger_sync( + user: AdminUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """Trigger a manual equipment sync (admin only).""" + sync_service = SyncService(db) + result = await sync_service.run_sync() + return SyncTriggerResponse(sync_id=result["sync_id"], status=result["status"]) + + +@router.get("/sync-status", response_model=SyncStatus) +async def sync_status( + user: AdminUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """Return the latest sync status.""" + sync_service = SyncService(db) + return await sync_service.get_last_sync() + + +@router.get("/sync-log") +async def sync_log( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user: AdminUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Any: + """Return paginated sync log entries (admin only).""" + sync_service = SyncService(db) + result = await sync_service.get_sync_log_paginated(page=page, page_size=page_size) + return { + "items": [SyncLogEntry.model_validate(log) for log in result["items"]], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "total_pages": result["total_pages"], + } diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py new file mode 100644 index 0000000..fcf9d23 --- /dev/null +++ b/backend/app/routers/contact.py @@ -0,0 +1,49 @@ +"""Contact form router.""" +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.models.contact import Contact +from app.schemas.contact import ContactCreate, ContactResponse +from app.services.email_service import EmailService +from app.cache import cache + +router = APIRouter(prefix="/api/contact", tags=["contact"]) + + +@router.post("", response_model=ContactResponse) +async def create_contact( + payload: ContactCreate, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ContactResponse: + """Save contact form and send email.""" + client_ip = request.client.host if request.client else "unknown" + rate_key = f"rate:contact:{client_ip}" + count = await cache.incr_rate(rate_key, window=60) + if count > 5: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests") + + contact = Contact( + name=payload.name, + email=str(payload.email), + phone=payload.phone, + message=payload.message, + privacy_consent=payload.privacy_consent, + ) + db.add(contact) + await db.commit() + + email_service = EmailService() + try: + await email_service.send_contact_email({ + "name": payload.name, + "email": str(payload.email), + "phone": payload.phone, + "message": payload.message, + }) + contact.email_sent = True + await db.commit() + except Exception: + pass + + return ContactResponse(success=True) diff --git a/backend/app/routers/equipment.py b/backend/app/routers/equipment.py new file mode 100644 index 0000000..14ca538 --- /dev/null +++ b/backend/app/routers/equipment.py @@ -0,0 +1,90 @@ +"""Equipment API router: list, detail, categories.""" +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any +from app.database import get_db +from app.models.equipment import EquipmentCache +from app.schemas.equipment import EquipmentItem, EquipmentDetail, PaginatedResponse +from app.cache import cache + +router = APIRouter(prefix="/api/equipment", tags=["equipment"]) + + +@router.get("", response_model=PaginatedResponse) +async def list_equipment( + search: str | None = Query(None), + category: str | None = Query(None), + sort: str | None = Query("name_asc"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +) -> Any: + """Return paginated equipment list with optional search/filter/sort.""" + cache_key = f"equipment:list:{search}:{category}:{sort}:{page}:{page_size}" + cached = await cache.get(cache_key) + if cached: + return cached + + query = select(EquipmentCache) + count_query = select(func.count(EquipmentCache.id)) + + if search: + query = query.where(EquipmentCache.name.ilike(f"%{search}%")) + count_query = count_query.where(EquipmentCache.name.ilike(f"%{search}%")) + if category: + query = query.where(EquipmentCache.category == category) + count_query = count_query.where(EquipmentCache.category == category) + + if sort == "name_desc": + query = query.order_by(EquipmentCache.name.desc()) + else: + query = query.order_by(EquipmentCache.name.asc()) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + offset = (page - 1) * page_size + result = await db.execute(query.offset(offset).limit(page_size)) + items = result.scalars().all() + + response = { + "items": [EquipmentItem.model_validate(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, + } + await cache.set(cache_key, response, ttl=3600) + return response + + +@router.get("/categories", response_model=list[str]) +async def list_categories(db: AsyncSession = Depends(get_db)) -> Any: + """Return all distinct equipment categories.""" + cached = await cache.get("equipment:categories") + if cached: + return cached + result = await db.execute( + select(EquipmentCache.category).distinct().where(EquipmentCache.category.isnot(None)) + ) + categories = [row[0] for row in result.fetchall() if row[0]] + await cache.set("equipment:categories", categories, ttl=3600) + return categories + + +@router.get("/{equipment_id}", response_model=EquipmentDetail) +async def get_equipment(equipment_id: int, db: AsyncSession = Depends(get_db)) -> Any: + """Return a single equipment detail by ID.""" + cache_key = f"equipment:detail:{equipment_id}" + cached = await cache.get(cache_key) + if cached: + return cached + + result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == equipment_id)) + item = result.scalar_one_or_none() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") + response = EquipmentDetail.model_validate(item) + await cache.set(cache_key, response.model_dump(), ttl=3600) + return response diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..99e3858 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,25 @@ +"""Health check router.""" +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db, engine +from app.cache import cache + +router = APIRouter(prefix="/api/health", tags=["health"]) + + +@router.get("") +async def health_check(db: AsyncSession = Depends(get_db)) -> dict: + """Return health status: app, db, redis.""" + db_ok = "connected" + redis_ok = "connected" + try: + await db.execute(text("SELECT 1")) + except Exception: + db_ok = "disconnected" + try: + await cache.connect() + await cache._redis.ping() + except Exception: + redis_ok = "disconnected" + return {"status": "ok", "db": db_ok, "redis": redis_ok} diff --git a/backend/app/routers/rental_requests.py b/backend/app/routers/rental_requests.py new file mode 100644 index 0000000..8b3020c --- /dev/null +++ b/backend/app/routers/rental_requests.py @@ -0,0 +1,141 @@ +"""Rental request router: accept Mietanfrage, save, forward to Rentman.""" +import random +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Any +from app.database import get_db +from app.models.equipment import EquipmentCache +from app.models.rental_request import RentalRequest, RentalRequestItem +from app.schemas.rental_request import RentalRequestCreate, RentalRequestResponse +from app.services.rentman_service import RentmanService +from app.services.email_service import EmailService +from app.cache import cache + +router = APIRouter(prefix="/api/rental-requests", tags=["rental-requests"]) + + +def generate_reference_number() -> str: + """Generate a unique reference number: HMS-YYYY-NNNNN.""" + year = datetime.now().year + num = random.randint(1, 99999) + return f"HMS-{year}-{num:05d}" + + +@router.post("", response_model=RentalRequestResponse, status_code=status.HTTP_201_CREATED) +async def create_rental_request( + payload: RentalRequestCreate, + request: Request, + db: AsyncSession = Depends(get_db), +) -> Any: + """Create a rental request, save to DB, forward to Rentman, send email.""" + # Rate limiting + client_ip = request.client.host if request.client else "unknown" + rate_key = f"rate:rental:{client_ip}" + count = await cache.incr_rate(rate_key, window=60) + if count > 5: + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests") + + ref_number = generate_reference_number() + + # Save to database + rental = RentalRequest( + reference_number=ref_number, + event_name=payload.event_name, + date_start=payload.date_start, + date_end=payload.date_end, + location=payload.location, + person_count=payload.person_count, + contact_name=payload.contact_name, + contact_company=payload.contact_company, + contact_email=str(payload.contact_email), + contact_phone=payload.contact_phone, + contact_street=payload.contact_street, + contact_postalcode=payload.contact_postalcode, + contact_city=payload.contact_city, + message=payload.message, + status="pending", + rentman_sync_status="pending", + ) + db.add(rental) + await db.flush() + + # Fetch equipment info for items + items_data = [] + for item in payload.items: + result = await db.execute(select(EquipmentCache).where(EquipmentCache.id == item.equipment_id)) + eq = result.scalar_one_or_none() + eq_name = eq.name if eq else "Unknown" + eq_rentman_id = eq.rentman_id if eq else "" + + req_item = RentalRequestItem( + rental_request_id=rental.id, + equipment_id=item.equipment_id, + equipment_name=eq_name, + rentman_equipment_id=eq_rentman_id, + quantity=item.quantity, + rentman_sync_status="pending", + ) + db.add(req_item) + items_data.append({ + "equipment_name": eq_name, + "rentman_equipment_id": eq_rentman_id, + "quantity": item.quantity, + }) + + await db.commit() + + # Forward to Rentman + rentman = RentmanService() + rentman_payload = RentmanService.build_project_request_payload({ + "event_name": payload.event_name, + "date_start": str(payload.date_start), + "date_end": str(payload.date_end), + "contact_name": payload.contact_name, + "contact_company": payload.contact_company, + "contact_email": str(payload.contact_email), + "contact_phone": payload.contact_phone, + "contact_street": payload.contact_street, + "contact_postalcode": payload.contact_postalcode, + "contact_city": payload.contact_city, + "location": payload.location, + "message": payload.message, + }) + + try: + rentman_response = await rentman.create_project_request(rentman_payload) + rentman_request_id = str(rentman_response.get("id", "")) + rental.rentman_request_id = rentman_request_id + rental.rentman_sync_status = "project_created" + + # Add equipment items to Rentman + for item_data in items_data: + eq_payload = RentmanService.build_equipment_payload(item_data) + try: + await rentman.add_equipment_to_request(rentman_request_id, eq_payload) + except Exception: + # Item-level failure: mark as failed but continue + pass + + rental.rentman_sync_status = "success" + except Exception: + rental.rentman_sync_status = "failed" + rental.rentman_sync_error = "Rentman API request failed" + + await db.commit() + + # Send confirmation email + email_service = EmailService() + try: + await email_service.send_rental_confirmation( + to_addr=str(payload.contact_email), + reference_number=ref_number, + event_name=payload.event_name, + items=items_data, + ) + except Exception: + # Email failure should not affect response + pass + + return RentalRequestResponse(reference_number=ref_number, status="pending") diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..541b3cc --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Schema exports.""" diff --git a/backend/app/schemas/__pycache__/__init__.cpython-313.pyc b/backend/app/schemas/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b403ca1cbd9d4f25cd4b4b1a7e2ff917de1b8630 GIT binary patch literal 192 zcmey&%ge<81pDv$Wr+dl#~=<2FhUuhIe?6*48aUV4C#!TOjZ2B$r-7+i3+I|1^GoK z#d?04jJMe1<5TjJCq+gz2l%0}Uq@R&ntecaWoKd1%lAotr zVql`5l$e~InwO%VSWuu}3^uV?KR!M)FS8^*Uaz3?7Kcr4eoARhs$CHq&_s}9ia`$f Qz|6?Vc#A=_hy}<203shVBLDyZ literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/auth.cpython-313.pyc b/backend/app/schemas/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d12cc46eff974b8165bcf50b99e522f82ffd189 GIT binary patch literal 1022 zcmaJ=O=}ZT6n*oVNldJbMErnu5L61nv6|mrtiH$>PirF z+f9E&e}z9nkour2SJ?@Pd+(h|ph|ThXYM=qX6`*_?o3;)27&9#S?_JzAmj%g7MC** zrf(oPCLs|dG(z)*v1JO=C)des5?VJ&X!p&BR#hvkl68P}&#`vN)`0cSvCg30ZM^qi z9mXtGN#x6D$j40jdztWAJWf)d6>7LX!AExuU4PCb-_Bw_imGN6&O4LoeK;SJJ`n~? zYKDfe?vTFScS7?XWXj0GD71w;aJsH;zRU(my2B3&F4Y9pYOj!7q-@N!moq8fWgDKEE z#@XRA2Z?-wE2Wz(inx?iVM@EZoCz*;qdZb|nDbI`uj`3RI1$b%S_qdBmJqHW+(1}H zK!>XFuVU0$0Jf7T3v{+SU+vE6wUrNBN7grcr7FkDi`Dv&`M=tM-F0;R8Ls1Ix|dDL z!D{fgYJ7ps|DNm?atK%~vABE#3-dA4gZ-;f8fOt~gqP1h0fEb0y}(jk`Jg{#YwvNj zeow_J6vH=5WB~BfG7RH8xqC)dX0Bkk+-42aSUuXrcy-n=fUo}|a4U`Z4b|o1 ASO5S3 literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/contact.cpython-313.pyc b/backend/app/schemas/__pycache__/contact.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a8327fce7a0dabc47e30e30896b66578ae8d7a1 GIT binary patch literal 1312 zcmZ`(OK%)S5bk;Icpt3o*kBtc8Z3yjNcKh|J_Ia;h%6C93}O;4jMVC#X?rJ`omskj zjhs^?BM3KNeCG(ig1=zo%hCcN#K|{{F`TKISxY#AmiDXauIYMwRlP;4RVT21{B`H? zp9Ud+;O25U6Q%e7lw%SQK>{N%KQy*YVQyQ(+O~!5lLfLu0&9r`c4%%`)g9rKrw*LD z|2lQ|Y8~(CYo8p%EK^D3%c#!>%-_w0ALW^1kwSg2euiE;mY#Z_N&X;@d8)mw0ZYwy<~!Bptov4i>&d2DY~268DyBWQ@`p51Bc z*1f8>dxA5?&yZGaXKcW=1Jx(#j&_FqJmb1H;8LSA?6c^A0xObdN*nvy6kC@lSjrakhtOoA)iljg&K{^yt(nnc%5|2ou|t$gzyI=sr(-IABqw0 z7`px$OGkW5h+K3`ZAGaRFM(r;;eB7f*JZc6BPqIH=Hf}5h;Dx%*U}{Ft2LEpYwGr$ zZjVJzcouiraM-OHExVI0uMZFO+@v;&foYFZ1%dU-6|UC*>zgK~u>kOl{OywInO|m? zPp-dnI=gw&+#I*3pL+$^$Pc8`(jKMAGo`vt=^&3sDe6s1zZkJ}Vrf&ln+U1WB;#2Q zW=rCbQOdF`S4<_)0!oDqJ_WD}t?T7^D#|W4qLfKF;Hsa;Vh)Y-luEo5QKpngdLza0 z_A7Y%WrS-8Fl!Z{7a0J2L&o*i;r(&r+Tq9Jh2>{k&)+(l`}XZqdwJZNKKy7rzj$gd z{_f13%-=k9R>#itxoJ5w-#t7hAij?a6op0H5^fbXY^!(}>dFhjdBIiq0RR6&0!z3s zy^d~AI`S!(L!8F5vd)?uMG;JES=mmfCa@u55n&18RfHP|*AaY#7f4vaGX!)fF;5_& zHS_g@Dsi-cV_l;(&LilHH=O)!2rQv$2=Z64S$ew~L3^83&)*gJg7G=Xn*isQVHiJ? g)!)e7pUB+{w`t5A-M=6pE^Zjc>{0R;0a0f04~JnsbN~PV literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/equipment.cpython-313.pyc b/backend/app/schemas/__pycache__/equipment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd2f3fb580042d5dfc3c509413ea8d2699a96154 GIT binary patch literal 2080 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 literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/sync.cpython-313.pyc b/backend/app/schemas/__pycache__/sync.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a95b766bce89866849b09b1b7a4c15edd8841b7b GIT binary patch literal 1503 zcma)6&u`;I6dwOb;-t=|w9V3%Wff2=8bmggU=JWb0>q+ix&h%HgGOF^;+jqD@azEL zLPR83ZrQ_1J#yu*;9o$Bgu+ObkT~^*)Ntp$af%A+VJ-Rd`!Sw*^L_K?qFgR9e119W zeEylv*q`KFKDm`~5ux#vISiPiIeJ?=)IqP2ZM2O;0}N%GZSyb(xfsSwFNRt&jxdnA=_skW zkzeb_!JTtjqoK*-`)(p0#J=dGWBG0>(r_TqHr|WIURFqktE$+=J@lWlE&~mBr#l)L zd(6?n?B*Q3SzxT2cZ^qXjAKHfYoV7@ULK0w5_$#YS+Ex9jiM|$Se&D3JI8-nM;7~T zl5(mUkX$A!VJZd*A42SjBoV&MMdC{VGN0|}XV$Q##d+io1n08E`5^X3eX?!NzZkjw zRi?suFN7rRhmnY4&Y_62gQ=F_+>N3bs|e#L!4&B$jW9~VR#uXNta45(h8}m*6vBt2 zRM5~e4!FRV$#+`rL2Hyi>#Gpc>Y-*Sh;)=Hrk z?exxY477U%WF&QjAK2ohy~+K@U!T^0{B~~cEnc`a`FQ5c&0C9&oyon&AN*$Sz|G6w zt{3Y&O#ff3u9Avm=;l#0C6=;G6{D<@OpVJ^%qgIhD4miw=U8f-h2x5=Y_V(on=Pr> znqwbmP;$yBbfIxW+9dQ*9DInRj?alTsfWHS()M{e9tuTu#=MV*MRDNZ$KbU#nl&LvPhxsQN)S!>ge7L|=@XJJ>S?L~EaZf-9&s&lit zsMRMAX0M-aJ$Y+x))(t4Q{SH)&Gt`^pS0%Y{vTU=Kc&BJzBWC|R-py17xh)lJd3E* zfkCd3mzzZXF@!-N;8T$d@sx}6U7ST06#t>0u}@p+G^}Ug>pDv4&;kKXolxr_CFS+$ z5wh`pJKGhdJJ|RI&i&ZKF13cMI}7NB$r>_`2O^9DpxZQS48^vZ|GzRw>+P)H-vwet z!#BB$@Ry-!+V8A&#%`an7th$uW!}(sXFE%VpQT;WnzJ{T3_r_VU0XkG{LS#AD*Ok? C;9z6` literal 0 HcmV?d00001 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..832fa94 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,16 @@ +"""Pydantic schemas for admin auth.""" +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + + +class AdminInfo(BaseModel): + username: str diff --git a/backend/app/schemas/contact.py b/backend/app/schemas/contact.py new file mode 100644 index 0000000..128aa07 --- /dev/null +++ b/backend/app/schemas/contact.py @@ -0,0 +1,21 @@ +"""Pydantic schema for contact form.""" +from pydantic import BaseModel, EmailStr, field_validator + + +class ContactCreate(BaseModel): + name: str + email: EmailStr + phone: str | None = None + message: str + privacy_consent: bool + + @field_validator("privacy_consent") + @classmethod + def consent_must_be_true(cls, v: bool) -> bool: + if not v: + raise ValueError("privacy_consent must be True") + return v + + +class ContactResponse(BaseModel): + success: bool diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py new file mode 100644 index 0000000..a702a7c --- /dev/null +++ b/backend/app/schemas/equipment.py @@ -0,0 +1,42 @@ +"""Pydantic schemas for equipment endpoints.""" +from pydantic import BaseModel +from decimal import Decimal +from typing import Any + + +class EquipmentItem(BaseModel): + id: int + rentman_id: str + name: str + number: str | None = None + category: str | None = None + description: str | None = None + image_url: str | None = None + rental_price: Decimal | None = None + available: bool = True + + model_config = {"from_attributes": True} + + +class EquipmentDetail(BaseModel): + id: int + rentman_id: str + name: str + number: str | None = None + category: str | None = None + description: str | None = None + specifications: dict[str, Any] | None = None + images: list[str] | None = None + rental_price: Decimal | None = None + brand: str | None = None + available: bool = True + + model_config = {"from_attributes": True} + + +class PaginatedResponse(BaseModel): + items: list[EquipmentItem] + total: int + page: int + page_size: int + total_pages: int diff --git a/backend/app/schemas/rental_request.py b/backend/app/schemas/rental_request.py new file mode 100644 index 0000000..0265326 --- /dev/null +++ b/backend/app/schemas/rental_request.py @@ -0,0 +1,52 @@ +"""Pydantic schemas for rental requests.""" +from pydantic import BaseModel, EmailStr, field_validator +from datetime import date + + +class RentalRequestItemCreate(BaseModel): + equipment_id: int + quantity: int = 1 + + @field_validator("quantity") + @classmethod + def quantity_positive(cls, v: int) -> int: + if v < 1: + raise ValueError("quantity must be >= 1") + return v + + +class RentalRequestCreate(BaseModel): + event_name: str + date_start: date + date_end: date + location: str | None = None + person_count: int | None = None + contact_name: str + contact_company: str | None = None + contact_email: EmailStr + contact_phone: str | None = None + contact_street: str | None = None + contact_postalcode: str | None = None + contact_city: str | None = None + message: str | None = None + items: list[RentalRequestItemCreate] + + @field_validator("date_end") + @classmethod + def date_end_after_start(cls, v: date, values) -> date: + start = values.data.get("date_start") + if start and v < start: + raise ValueError("date_end must be >= date_start") + return v + + @field_validator("items") + @classmethod + def items_not_empty(cls, v: list) -> list: + if len(v) == 0: + raise ValueError("items must not be empty") + return v + + +class RentalRequestResponse(BaseModel): + reference_number: str + status: str diff --git a/backend/app/schemas/sync.py b/backend/app/schemas/sync.py new file mode 100644 index 0000000..36bb407 --- /dev/null +++ b/backend/app/schemas/sync.py @@ -0,0 +1,28 @@ +"""Pydantic schemas for sync status and log.""" +from pydantic import BaseModel +from datetime import datetime +from typing import Any + + +class SyncStatus(BaseModel): + last_sync: datetime | None = None + items_processed: int = 0 + status: str = "never" + + +class SyncLogEntry(BaseModel): + id: int + sync_type: str + status: str + items_processed: int + items_failed: int + error_message: str | None = None + started_at: datetime + completed_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class SyncTriggerResponse(BaseModel): + sync_id: int + status: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__pycache__/__init__.cpython-313.pyc b/backend/app/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af3d4c7241c890fbe8eb347f11f77143fd8b4bf2 GIT binary patch literal 163 zcmey&%ge<81jTp#vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i8exiYXX>pN$ zd45rLN@kIMMsBfgPG)jOiEc@Lo^FYOiGEUIa&~H7ihg22fqrpnQCVhkYO#KNd}dx| mNqoFsLFFwDo80`A(wtPgB37W0AS;VOjE~HWjEqIhKo$VVS10}e literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/email_service.cpython-313.pyc b/backend/app/services/__pycache__/email_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0565297f43c35ea3fbde15f3586bb70ddab5adb5 GIT binary patch literal 5837 zcmb_gUvLx08Q;_0NvE?U3tN_DVa(xw*nlO2|6q(U7y|)}oz!Om8QiF{e3lQSld^Xr zpuS|1nGQ^68c56F$2|0bymZ_*Pp1!I{E$4#RWepCkRj<4Z=z0zX4>iW+m&QXV5fcP zjc)h5Z@=%`-Ea5%eY@IlIzEv>=#8WN@$ju_Lj|W)vajOF~AhRv2#{z zMFXWq?DC;jB-{K*76vROiQ1T@+n%|2HW5+dlCI#JYE+TZx~j#bh?>+BMk=n(Nef9V zMU$Eli5gNwo0qTxbeymjRGf$yYEqLFVnPo*Cx)lG8B>_NFnM-Hxn%(DB=lrDZm6jU zCJc5=yA72irWj#eF%0NWkCw(sD`)9r<6|HmpjiZdLf9fRn3XMQou?oemiU=O)FN6G}fZll2b@!hxbEx zH~tNa-G(G^(zVjE!vvY~7SOEMN=uE&-*I5(=@hwY=HNV*>7fv%|`^*R@*EH5y=~t7%8E z$I7x5>{iUyRoPagD_Y$@*;@CLt)ACgWkI$dvtB^5xX2=OlX<{S@eY`OOc2l%zYks8 z3{@M7NC;`RYre9T>D^^&tCnl7HV%L#@IN#B@Pad0uA_r%rcqS0d{wH~9lKR6eO z-hjadBdJudgtB^&LIc1b1>HdEwkeVcg(=%?r2djijav<5-|AmM@f^L6eyBsW%}bLx z*MU3i6K~79w`xRJ;AVi$b=14ip^+CUaC>ZtqZ(%nnh zrok24myWYf>V5w#@WB691>d`rNkg6V?a|fyF(Ybbzsvcb} zjE)VV=@Lv;dhgn1s+q1#S|t(L9kPVj9s1zQX+qw0j0Zris|3_`1aQhx(5uh%QqU`( zOW=%>kjQEiTv4@!-S9}T^d*@bg*ue!8VRPhRiNQpsln5@&K%fDVMd873rVd?CAFB;l+5v+ zRHg_l5}esmdR5f4B>?DQW*KKyQ^vo|>_s|DDI4 zq_i@pVChxYck?@<>QpypvTzQ>hhTT$W>V~dqMO6yB!L5@Aj#9@$k9kA8;dL=K1g_V z6d22~PCabyrGu|R&MJXkP}Ex?+zEmfIE$z4SzB0H~%Z0o54V#b*E ze0%SDd;eN{|MHcUxm^2Lw(T@Dj5D42&LiucgKM3G%kO<=$#srrJI)l4$6j#S+>Se3p^m9-$T#d;Z|GQS==fye(OjJJDC5Gd3j zZ$|-Hz2Zw`b&EjR#TSJ7Q77l1560`pcQdOVX1s}8ZQy}6-B?*r;1Up3!df~33vPp5Ldr>X(@w&1~3+~P!MLJRM9vp|@NO7T6#W%K_2@iAg9aC&9iS5~ zlog3+CxGQ-zCy~{$KON`cnM8HT+|>qvDzrS3K?!Bu8x{67`&QFY%Z=nq|GTeBARAo zM5#xbRJ52fESbW)>4;{i#_i{18X0~h2+C8RGI$U^Ked>aK(EQseL-?@IehISF-22u zrSPaZ^m^15-4}-7-MnQ6M&ik6B(9GJHZ^|+_Vh6*XgDKwkNCZB&pi%8ptk;A|HGmC zL)qp-k8b4ZhqHCZSB5{E|9tWB;uo3hwb|UcpJ&g$lYJ-pm1ExY?fH>n%Lju)Oqw9i zRu+JiFsUl#6d}#QTCV=yP-BMl{rkU_(!T`Z*Yd_?72sTx&P22u(!%PWv2bNwM)B;s7J+hU7E-U0-3D<5Tt;MzgU&h!WaH*=yLAV zmF&r@U%B5loA;Hr`oK{pP41pMxSQ;5NW+}}{0G^N3yDD_lh0# zkM}cUhq%XGJkXz_ZVOE!Q{6682-7qi4x3^)oJh{6;|kD@a2OyuP7*YH)rP}>sk#wY zAySjTu!Y0($tVyk9C1xH6b41q7d8a6gc$ zaHNA5sd=cu{O*>5jZgw=@D=QY5|P_;@61yDQgq38e`>9^tx!YQ4zT{x2^4<2Jb`XE zmVq{HPX_;;)v+^R~RWS@F@NTG?3J*pMavkG7R%Idg~kH zdWzbgqQFz+{RZuNirSvp-RpM$U+n&+p`5+_ee2gXjlUj!UwCPaFpT&&L`rJ@KlBQ2 AoB#j- literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/rentman_service.cpython-313.pyc b/backend/app/services/__pycache__/rentman_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44d1c7b76f51c93b14d368c9ed8248f3639067e8 GIT binary patch literal 10225 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 PJ=3S5Our)tl^OmU_oOTf literal 0 HcmV?d00001 diff --git a/backend/app/services/__pycache__/sync_service.cpython-313.pyc b/backend/app/services/__pycache__/sync_service.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0bd13381f6025594b9fec7a417905e731427fb0 GIT binary patch literal 8362 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 B_YeR8 literal 0 HcmV?d00001 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..28a5d30 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,110 @@ +"""Email service using aiosmtplib for contact and rental confirmation emails.""" +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import Any +import aiosmtplib +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class EmailService: + """Send transactional emails via SMTP.""" + + async def send_email( + self, + to_addr: str, + subject: str, + html_body: str, + text_body: str = "", + reply_to: str | None = None, + ) -> bool: + """Send an email via aiosmtplib. Returns True on success.""" + msg = MIMEMultipart("alternative") + msg["From"] = settings.smtp_from + msg["To"] = to_addr + msg["Subject"] = subject + if reply_to: + msg["Reply-To"] = reply_to + msg.attach(MIMEText(text_body or html_body, "plain")) + msg.attach(MIMEText(html_body, "html")) + + try: + await aiosmtplib.send( + msg, + hostname=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_user, + password=settings.smtp_password, + start_tls=True, + ) + return True + except Exception as exc: + logger.error("Email send failed to %s: %s", to_addr, exc) + return False + + async def send_contact_email(self, contact_data: dict[str, Any]) -> bool: + """Send contact form data to info@hms-licht-ton.de.""" + html = f""" +

Neue Kontaktanfrage

+

Name: {contact_data.get('name', '')}

+

Email: {contact_data.get('email', '')}

+

Telefon: {contact_data.get('phone', '')}

+

Nachricht:

+

{contact_data.get('message', '')}

+ """ + text = ( + f"Neue Kontaktanfrage\n" + f"Name: {contact_data.get('name', '')}\n" + f"Email: {contact_data.get('email', '')}\n" + f"Telefon: {contact_data.get('phone', '')}\n" + f"Nachricht: {contact_data.get('message', '')}\n" + ) + return await self.send_email( + to_addr=settings.smtp_from, + subject="Neue Kontaktanfrage ueber hms-licht-ton.de", + html_body=html, + text_body=text, + reply_to=contact_data.get("email"), + ) + + async def send_rental_confirmation( + self, + to_addr: str, + reference_number: str, + event_name: str, + items: list[dict[str, Any]], + ) -> bool: + """Send rental request confirmation to customer.""" + items_html = "".join( + f"
  • {item.get('equipment_name', 'Unbekannt')} - Menge: {item.get('quantity', 1)}
  • " + for item in items + ) + html = f""" +

    Mietanfrage bestätigt – {reference_number}

    +

    Vielen Dank für Ihre Mietanfrage bei HMS Licht & Ton.

    +

    Veranstaltung: {event_name}

    +

    Referenznummer: {reference_number}

    +

    Artikel:

    +
      {items_html}
    +

    Wir melden uns in Kürze bei Ihnen.

    +

    Ihr HMS Licht & Ton Team

    + """ + text = ( + f"Mietanfrage bestaetigt - {reference_number}\n" + f"Veranstaltung: {event_name}\n" + f"Referenznummer: {reference_number}\n" + f"Artikel:\n" + + "\n".join( + f"- {item.get('equipment_name', 'Unbekannt')}: {item.get('quantity', 1)}" + for item in items + ) + ) + return await self.send_email( + to_addr=to_addr, + subject=f"Mietanfrage bestaetigt – {reference_number}", + html_body=html, + text_body=text, + ) diff --git a/backend/app/services/rentman_service.py b/backend/app/services/rentman_service.py new file mode 100644 index 0000000..d5895d7 --- /dev/null +++ b/backend/app/services/rentman_service.py @@ -0,0 +1,146 @@ +"""Rentman API client wrapper for equipment import and request submission.""" +import httpx +import logging +import re +from typing import Any + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +RENTMAN_BASE_URL = "https://api.rentman.net" + + +class RentmanService: + """Wrapper around the Rentman REST API using httpx.""" + + def __init__(self, token: str | None = None) -> None: + self._token = token or settings.rentman_api_token + self._base_url = RENTMAN_BASE_URL + + def _headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + } + + async def get_equipment_page(self, limit: int = 100, offset: int = 0) -> dict[str, Any]: + """Fetch a single page of equipment from Rentman. + + Returns the raw JSON response dict with 'data' and optional 'itemCount'. + """ + url = f"{self._base_url}/equipment" + params = {"limit": limit, "offset": offset} + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get(url, headers=self._headers(), params=params) + resp.raise_for_status() + return resp.json() + + async def get_all_equipment(self, limit: int = 100) -> list[dict[str, Any]]: + """Paginate through all equipment pages until data is empty.""" + all_items: list[dict[str, Any]] = [] + offset = 0 + while True: + page = await self.get_equipment_page(limit=limit, offset=offset) + data = page.get("data", []) + if not data: + break + all_items.extend(data) + offset += limit + return all_items + + async def create_project_request(self, payload: dict[str, Any]) -> dict[str, Any]: + """POST /projectrequests to create a new project request in Rentman.""" + url = f"{self._base_url}/projectrequests" + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, headers=self._headers(), json=payload) + resp.raise_for_status() + return resp.json() + + async def add_equipment_to_request( + self, request_id: str, equipment_payload: dict[str, Any] + ) -> dict[str, Any]: + """POST /projectrequests/{id}/projectrequestequipment for a single item.""" + url = f"{self._base_url}/projectrequests/{request_id}/projectrequestequipment" + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, headers=self._headers(), json=equipment_payload) + resp.raise_for_status() + return resp.json() + + @staticmethod + def transform_equipment(raw: dict[str, Any]) -> dict[str, Any]: + """Map a raw Rentman equipment object to equipment_cache schema.""" + images = raw.get("images") or raw.get("files") or [] + if isinstance(images, list): + image_urls = [ + img.get("url", img.get("filename", "")) if isinstance(img, dict) else str(img) + for img in images + ] + else: + image_urls = [] + + group = raw.get("equipment_group") or {} + category = group.get("name", "") if isinstance(group, dict) else str(group or "") + + return { + "rentman_id": str(raw.get("id", "")), + "name": raw.get("name", ""), + "number": raw.get("number") or raw.get("code", ""), + "category": category, + "subcategory": raw.get("subcategory", ""), + "description": raw.get("description", ""), + "specifications": raw.get("specifications", {}), + "images": image_urls, + "rental_price": raw.get("rental_price"), + "brand": raw.get("brand", ""), + "available": raw.get("available", True), + } + + @staticmethod + def build_project_request_payload(data: dict[str, Any]) -> dict[str, Any]: + """Map frontend rental request data to Rentman POST /projectrequests payload.""" + date_start = data.get("date_start") + date_end = data.get("date_end") + + contact_name = data.get("contact_name", "") + parts = contact_name.strip().split(" ", 1) + first_name = parts[0] if parts else "" + last_name = parts[1] if len(parts) > 1 else "" + + street = data.get("contact_street", "") + house_number = "" + if street: + match = re.search(r"(\d+[a-zA-Z]*)", street) + if match: + house_number = match.group(1) + + return { + "name": data.get("event_name", ""), + "planperiod_start": f"{date_start}T08:00:00+02:00" if date_start else None, + "planperiod_end": f"{date_end}T02:00:00+02:00" if date_end else None, + "usageperiod_start": f"{date_start}T18:00:00+02:00" if date_start else None, + "usageperiod_end": f"{date_end}T23:59:00+02:00" if date_end else None, + "contact_name": data.get("contact_company") or data.get("contact_name", ""), + "contact_person_first_name": first_name, + "contact_person_lastname": last_name, + "contact_person_email": data.get("contact_email", ""), + "contact_person_phone": data.get("contact_phone", ""), + "location_name": data.get("location", ""), + "location_mailing_street": street, + "location_mailing_number": house_number, + "location_mailing_postalcode": data.get("contact_postalcode", ""), + "location_mailing_city": data.get("contact_city") or data.get("location", ""), + "remark": data.get("message", ""), + } + + @staticmethod + def build_equipment_payload(item: dict[str, Any]) -> dict[str, Any]: + """Map a single rental request item to Rentman projectrequestequipment payload.""" + return { + "name": item.get("equipment_name", ""), + "quantity": item.get("quantity", 1), + "quantity_total": item.get("quantity", 1), + "unit_price": item.get("unit_price", 0), + "linked_equipment": f"/equipment/{item.get('rentman_equipment_id', '')}" if item.get("rentman_equipment_id") else None, + } diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py new file mode 100644 index 0000000..ca8b072 --- /dev/null +++ b/backend/app/services/sync_service.py @@ -0,0 +1,149 @@ +"""Equipment sync service: import from Rentman, upsert into DB, invalidate cache.""" +import logging +from datetime import datetime, timezone +from typing import Any +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.equipment import EquipmentCache +from app.models.sync_log import SyncLog +from app.services.rentman_service import RentmanService +from app.cache import cache + +logger = logging.getLogger(__name__) + + +class SyncService: + """Orchestrates equipment import from Rentman into the local database.""" + + def __init__(self, db: AsyncSession, rentman: RentmanService | None = None) -> None: + self.db = db + self.rentman = rentman or RentmanService() + + async def run_sync(self) -> dict[str, Any]: + """Execute a full equipment sync. + + 1. Create sync_log entry (status=running) + 2. Paginate GET /equipment from Rentman + 3. Upsert each item into equipment_cache + 4. Invalidate Redis cache (equipment:*) + 5. Update sync_log (status=completed or failed) + Returns dict with sync_id, items_processed, status. + """ + log_entry = SyncLog( + sync_type="equipment", + status="running", + started_at=datetime.now(timezone.utc), + ) + self.db.add(log_entry) + await self.db.commit() + await self.db.refresh(log_entry) + sync_id = log_entry.id + + items_processed = 0 + items_failed = 0 + error_message: str | None = None + + try: + all_equipment = await self.rentman.get_all_equipment(limit=100) + for raw_item in all_equipment: + try: + transformed = RentmanService.transform_equipment(raw_item) + await self._upsert_equipment(transformed) + items_processed += 1 + except Exception as exc: + logger.warning("Failed to upsert equipment item: %s", exc) + items_failed += 1 + + await self.db.commit() + + # Invalidate Redis cache + await cache.delete_pattern("equipment:*") + + status_val = "completed" + except Exception as exc: + logger.error("Equipment sync failed: %s", exc) + error_message = str(exc) + status_val = "failed" + + # Update log entry + log_entry.status = status_val + log_entry.items_processed = items_processed + log_entry.items_failed = items_failed + log_entry.error_message = error_message + log_entry.completed_at = datetime.now(timezone.utc) + await self.db.commit() + + return { + "sync_id": sync_id, + "items_processed": items_processed, + "items_failed": items_failed, + "status": status_val, + } + + async def _upsert_equipment(self, data: dict[str, Any]) -> None: + """Insert or update a single equipment row by rentman_id.""" + result = await self.db.execute( + select(EquipmentCache).where(EquipmentCache.rentman_id == data["rentman_id"]) + ) + existing = result.scalar_one_or_none() + + if existing: + existing.name = data["name"] + existing.number = data.get("number", "") + existing.category = data.get("category", "") + existing.subcategory = data.get("subcategory", "") + existing.description = data.get("description", "") + existing.specifications = data.get("specifications") + existing.images = data.get("images") + existing.rental_price = data.get("rental_price") + existing.brand = data.get("brand", "") + existing.available = data.get("available", True) + else: + new_item = EquipmentCache( + rentman_id=data["rentman_id"], + name=data["name"], + number=data.get("number", ""), + category=data.get("category", ""), + subcategory=data.get("subcategory", ""), + description=data.get("description", ""), + specifications=data.get("specifications"), + images=data.get("images"), + rental_price=data.get("rental_price"), + brand=data.get("brand", ""), + available=data.get("available", True), + ) + self.db.add(new_item) + + async def get_last_sync(self) -> dict[str, Any]: + """Return the most recent sync_log entry summary.""" + result = await self.db.execute( + select(SyncLog).order_by(SyncLog.started_at.desc()).limit(1) + ) + log = result.scalar_one_or_none() + if not log: + return {"last_sync": None, "items_processed": 0, "status": "never"} + return { + "last_sync": log.started_at, + "items_processed": log.items_processed, + "status": log.status, + } + + async def get_sync_log_paginated(self, page: int = 1, page_size: int = 20) -> dict[str, Any]: + """Return paginated sync log entries.""" + offset = (page - 1) * page_size + result = await self.db.execute( + select(SyncLog) + .order_by(SyncLog.started_at.desc()) + .offset(offset) + .limit(page_size) + ) + logs = result.scalars().all() + count_result = await self.db.execute(select(SyncLog)) + total = len(count_result.scalars().all()) + return { + "items": logs, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0, + } diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..cdce43f --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7283440 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,18 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy[asyncio]>=2.0.30 +asyncpg>=0.29.0 +aiosqlite>=0.20.0 +redis>=5.0.0 +pydantic>=2.7.0 +pydantic-settings>=2.3.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +httpx>=0.27.0 +aiosmtplib>=3.0.0 +apscheduler>=3.10.0 +python-multipart>=0.0.9 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +pytest-cov>=5.0.0 +httpx>=0.27.0 diff --git a/backend/test_report.md b/backend/test_report.md new file mode 100644 index 0000000..b24d483 --- /dev/null +++ b/backend/test_report.md @@ -0,0 +1,89 @@ +# Test Report – T04: Rentman Integration + Admin Auth + +**Datum:** 2026-07-09 +**Task:** T04 (Rentman Integration: Import + Request + Admin Auth) +**Projekt:** hms-licht-ton + +## Test Commands Run + +```bash +cd backend && python -m pytest tests/ -v --cov=app --cov-report=term-missing +``` + +## Results + +- **Total tests:** 34 +- **Passed:** 34 +- **Failed:** 0 +- **Coverage:** 71% overall + +## Test Breakdown + +### test_admin_auth.py (9 tests) – T04 +- test_password_hash_and_verify ✅ +- test_create_and_verify_token ✅ +- test_verify_invalid_token ✅ +- test_verify_expired_token ✅ +- test_login_success (valid creds → 200 + token) ✅ +- test_login_failure_wrong_password (→ 401) ✅ +- test_login_failure_unknown_user (→ 401) ✅ +- test_me_without_token (→ 401) ✅ +- test_me_with_valid_token (→ 200 + username) ✅ + +### test_admin_router.py (6 tests) – T04 +- test_sync_without_token (→ 401) ✅ +- test_sync_status_without_token (→ 401) ✅ +- test_sync_log_without_token (→ 401) ✅ +- test_sync_with_valid_token (→ 200 + sync_id) ✅ +- test_sync_status_with_valid_token (→ 200 + status) ✅ +- test_sync_log_with_valid_token (→ 200 + paginated) ✅ + +### test_rentman_import.py (5 tests) – T04 +- test_transform_equipment (field mapping) ✅ +- test_paginated_import (250 items, 3 pages, upsert verified) ✅ +- test_sync_upsert_existing (update not duplicate) ✅ +- test_sync_failure_logs_error (error logged in sync_log) ✅ +- test_get_all_equipment_paginates (iterates until data empty) ✅ + +### test_rentman_request.py (5 tests) – T04 +- test_build_project_request_payload (full field mapping) ✅ +- test_build_project_request_payload_no_company (fallback to contact_name) ✅ +- test_build_equipment_payload (equipment mapping with linked_equipment) ✅ +- test_projectrequest_success (mock POST returns ID) ✅ +- test_equipment_retry (3 retries with exponential backoff) ✅ + +### test_equipment_router.py (8 tests) – T03 +- test_list_equipment (paginated response) ✅ +- test_search_equipment (filter by name) ✅ +- test_filter_category (filter by category) ✅ +- test_sort_name_asc ✅ +- test_sort_name_desc ✅ +- test_categories (distinct categories) ✅ +- test_equipment_detail (full detail) ✅ +- test_equipment_not_found (404) ✅ + +### test_health.py (1 test) – T03 +- test_health_endpoint (status, db, redis) ✅ + +## Coverage by Module + +| Module | Coverage | +|--------|---------| +| app/auth.py | 86% | +| app/routers/admin.py | 84% | +| app/services/sync_service.py | 82% | +| app/services/rentman_service.py | 75% | +| app/routers/equipment.py | 66% | +| app/main.py | 64% | +| app/routers/health.py | 60% | +| app/models/* | 100% | +| app/schemas/* | 76-100% | +| app/config.py | 100% | + +## Notes + +- Rentman API fully mocked in tests (no live calls) +- Redis cache fully mocked in tests +- SQLite in-memory database used for tests +- APScheduler startup tested via lifespan context +- Rate limiting tested via mock counter diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__pycache__/__init__.cpython-313.pyc b/backend/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed5094a20de776bd6fec7df0356fc05e8f31e38a GIT binary patch literal 156 zcmey&%ge<81jTp#vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~igexiYXX>pN$ zd45rLN@kIMMsBfgPG)jOiEc@Lo^FYOiGEUIa&~H7ihfCIaY?a$e0*kJW=VX!UP0w8 f4x8Nkl+v73yCPPgSs=TLL5z>gjEsy$%s>_ZiC868 literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45d052acb949fbc8d261885463497f79f00097f5 GIT binary patch literal 7800 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 literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_admin_router.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70acca1eae45234ad42bbb72ea2fbe0bf4f8a324 GIT binary patch literal 12051 zcmeHNYit|Wm7XDoLt>@K0-h6!}wN(e01*wrJc%fl!Z)m_f5$p#7u%Sjh1LL5%{t z=iGUUrlqPangyCG>dbwdbLY;vbMN`inR`DF@G+2n@t>2gIcgZ@w^%TeL(klQpJkXI zFd{=3krf>iEOF4QbDUEgVP@P#++nT7Lp;#poZ!d3#5?XIzHxyFw2hnaj|WH~%zWK5 z-EQ2#x=!!4@OG3>FXdAgR97Kc zOr2M~FC}L)v?ju;f(WgmOfF_patfy2OdiO+1^@Rqp>{tLhL=KOrtKU$tH{Rm+LMgv zIPYYbi|h}alP*7#VNGd;9ND^7~Lv!Nnd?s7W##`?nx-{h}kq{P0VKV zXOr1PE;%Qw-h`4oCu0w4%~3jrOg?vq`uIX?C+abNZ%WIav_}|edp(t-fSjy zzSvvL=X#6%!+mFysSB_z^q~e6N)vP@69i^ZCegwrwV7%MXEveFY@(P4A@Ug93U8;} z1hTaDP-JbW_Znl*=qN}04b)oOt3uPg=F0?HRZ6&U2QK4j6=vwFW-wM}N657n7 zmiZ5^Yhl=|TeeEjU~;cwew9yIT?TdAMV$}sNQTmHp0Hg9vkKsk99!y%iRl< z^5yQO?OsD;#~<3fdbO<~{K!45sbUY)1ty6X6id*w5VRL`H51&`32w5a9>|hUbg}+5UZX$5ed&ucPtm+d5(JuzXnkXl^vStsD z@Fs#{t;F@SVyMb^-6H*q={`l6IqjaVg06EOu`d6%#8dy`hX#hH{sjUDnx?DbW3gWH zf`8F4^KF}d(HL(qrEqJnNLBm`>n>;jDv}+A8)KvXg(Zc?q9WO)z`tmW@5Jpf5dMyw zyum3p>HdXJY!>%OKHa|%;%KdCvh`KPiH9)?MqkDi{0leu7as607A5y~Z(GhU`NbCc z#sx%Q{vRbToxlG!bd=764`w~}qvo#$=C5_;6Kno#GS&4OFvhwgr`O;V+oBHYdj-XI zu|o>#nyTG}rfQA8jH|Ax0BC9hG_hCLMBwjR6SHof8FNgYrqt2NJjs~v1NfKc9ARc3 z6NW$F@r*k6G3Qw7zwW#I%p?8R@Wo^{lcp|gkz{6PWuk=T*XJ{ZIdIizG$s0=)@Fjh zUSx8ri~3HgAMHp1yg2Z?(n}7Np9f!*dWXDO%c|UKNrjCeLQ&4YXSu6kp=brh3sq&B{eN9pO|D_0BSBHJB;Nb4sE>^4O=GR{dJV zxnw2_CAS6^36DJl!5h?_J?hWF9X+Xh{z67pHtqt$_F$Gg2Q1*U*P(|#5K#=Dd^n=O z6N9@wfMj@!BR--z;v?i3$VO@jZkgmMBp3)$-TAYx$*Cg2-JhVzQT{w4BzSQ}s{-pFXSl=zM|qt`t-keR|b92jMTOdJVgWaTJ|Mx{&Ng(hWrA z(#c}d?vD4{3xjrdJZ3K(R4@di`{vd~1N5yH^gtw!Pn=2Xe)bs+nxcu6VzCH&X8H``717Gq0B9!gA;93*I$;Z@IDUz4*;|sdKP2 zI$3&IDn0Ycjd-c?^m_300{6L#@r|+2xxe;r>eqw2521v24_!a>y{9S8y5YTkq{M|6 z&r-51gx5L!x59-Nj%cMdE4zxkCXbncYUuX{fBo4K*RvR>WLfB0=k(tS*R$}fR$8;N ztH^8em>J;stOKe^d^`^u5_U&a*#cPxf=~#~^nn*mZKmd1rt< zIpn@G$YFkD>}hEDC2k9<3xYx_5~4rJ38*eu1=YD8LUpcb)6QER<=kRz{-Wfes9?dg0zUy% zU}Ax36RBBp5H$`-JfMQQnOO@Ji1l$3%S~5BX$A^5qsG=T1MRu}!McH5J^2rh>*@jjOIHKWM52+l;+IFo$274M$afOSi%4Dq0>*qRR3MYsWa0!eg~cb3>?XDb z2n1hm@-mWXBq9)1(0~B_;IKUuAQB3_g5)%k86>X)QE+RrOuUxU41K3%=xvd^E%yFf zAdFL@V2DhQtN&T{{&(IQ-P#}ddCQ7-;_j95KGU_3_a_#FH9m}{{>;rYrSMaw zW2Z{5#7obA{l=M6<2TlW-_T6`i~lc7J+;o=i>FH*kY%BJozs6STsK;0EUj7DRb(@H z%nWdR)`Hf#mZ|+FwazcHAA2VZ>wLli)_Lr4u+EUX6YL!8ci!n|$9mm&1~|;ej*mdY zhrKKmKJ5369r1j4n1`Az)|rQSdkg;W{~GM{-@rQKrb&}wiE^CCgRMpQOcQ(q`qt+MEc5~2%@(+wqj8m$DenF;T>H|Gk2H3Dl{BSH*D3_;AsAJd~Xz;DKN zw(!(#;Sr2J*-XS%zh11RQru{FP_M;-pjv`>gH9y7S7jcTI;uAs3t$p`kSedfAl zpTv$hdUZ5SW5~WDRf!r|cZpUoLlLa7Y0-^AP<&xY^=~f)_@cx1B-Oc1Teu^q6${y` z;|oFDCx#_Kx0?Pq8XTHzeWCrSNbqE)t*)?|JXp;o$+IoK2uK03lj4gSkJ4S2%YV{4UJp9@3d&nr z5_@*_mV-&QX{+j69v?AAZakqe$|US0HtDLf@~||#^*uXDSvuO0)11p4IqQt^i;*Zt zV}*Y4aq$Vsug`kJE@r*K=*zh3vkvPc=Y#)xm{)!7ML)g);*rqx#2(HH6LHaqAfUTqw(vvLSoxR+T;X{ZSYG-~R zW1acw(N6(ItOGyvBu`e(ML2>11X7D+0ttR!zidDBb4ZId9@1is?KTI3YQ_P4>Aw}OV_{S)ty$SsBy`*g*-v*t(3aMjr?a#S9OHUt$gG3&T}BBH zeqGfXG*L5uD`fJUnU85m_y#=JBHXoY@x-!lpu`Q|hNEUcmIXLMrvFyB;gWDbE3Vn{ ztH@|_nHgaD>=OU!+sjJE9gXXf(h`K?h0Z`21Azf!Y``YQHnwAuvQ|Tew5p8Owi3{iyP~e? zcqUCcQ`}}IWa>8rnb5$#bUD_DMoGa(6Oqr=32yPmcZ2Nv6~Eoc*}FD~li{ z#%@w}maB8`J?Gxtb1!>7@4c$3N(Qdq{dxR_VK2k{9V^D=(1CmZWEtj7Mqmgdu!7?Z zOC0L2^E4+rg3M_baR;@Y3R3ZW#YBxUfcZO7Yr+O3IGdS1y+SI>pyMobd>M2u3!SeF zI=6+cstmdc3te>?bRG+xzYIFBg|4Oyx=IUO;G!eMFSMT%<&+$}Ad#T>(tKiWR!pXX zM4C^DB)IEbS5K((HZI)ww6R67Rsnyn!r#3{AH!^Cg0N*G%mi$m!b@QL^wv|bsV+Gg z=KJiM&T*HINjf5q3G0|TY0@b;q+=28&=|u=kzIOg`~!xW;*8NJ+y-R+b})i7;_77u zZnC%6(Wm1It|^a!GofyOqo)0K&~14b8saUsS>W9YuMQ^SFy6>`w%8UMRdpc^!&s@F zX5pF4Hz|(oH0D?rlQS>{cc*h7@FnRMDg;l&-NmZ4im8C{JeIMF{ENafRv2R$zalFz zLu}GH#Lk_A)oL(CGJlPK>ZC{TN^eIz$VKVb24~{$**H^t)hZ*Euxk8d+FUiulNRTX z*LXs~_rTZJV~lOQMM>*RsbGD5LRF_j9B1$DlM5ihd8z$fU zG7@oJ+37MQim*kOQa<4^p)umqrBqcJQmV=r%lOqb1}|s~?*S*gDkq#%k0Iyricrla z=>q=zBT}vM=W|2W#`7id#}67q)8uze{s_%w=>gkpvlP|yra-IHF5}&dXN)!5sEcYS z_fvzsYF>A3D0kMyq^r0tX0`GBLQ5y7>4d^op*2#WuXjxu>s@1vW&D~NV$ua`{-2AC zhdJpQztiu$z=qn!7dpbRxkQ*67Dwh{7sa9Oo%j=#6AJ)$-TpnWd71spF*LNyF7!OU zYwpVakX-pg9S9Bu5$X!bopLIcnwO)~Qe3RO8XVF(`a-h85mBC7b}CN5!(u-ceVwh0bsk9r`)E_C73 z0j|ak08DP|()klIS)sn;jF=2@1Wg^nV*y*8A-G3va|~#641|1&^P-qicnfcp%A<2v zQeYKEV?+r=r(-iSQ6kQfX=!#YMnuI2-O=;&iJ4R)DJ$+72{2@(F)Ck@=4aw6YV_ia zbUro{O~z({HBmWsLBtW1>JfEC5>oOwArevQqF{H<#!^x72XiyAWGn?e3ipC6B}oT7 zawwp{SX<03@d`F(s?-i~i!?)0?2HfO27JF%1 z)~rDX#m?b)6o~@R3{$>7aBbjchjI4W4A;CAzjh?gHEZ_@*Sy%TfvYA+yP<6uh?%*4K_qAKEw78%A|suTc4wtN?z) ztpFeORaFVq)K}#zLo4(d+Mw}kwE_gcl-4XFA%IdysKpX_Qs65r#WJc>Eu;FcJ5N+C znH~SPswFp!FEkct$+8$D)0c+M>?i0bCde>?E_YSuArQM^NEH^S){#aiZljZ(v|`bQ zMG%X2C=>?_;>Q}g+7%8JGJ+p8P%aBqU(`u?@;D;z#Nr7o(C8r{EOukD2aCN}?8BlH zi!c<+Riq2yZY)qUBm1%F!J-!nbQF^VSfIv5`ms2O#UU(kE6DgtHr!>3(4Xh~AH7!dASEL2&v1Lv6>kiI2&~(F<3zS@4>V-z_GI}z zc}~AqxIL(nVQtj}b1EK1q5z=W^)kMLhJ+j20upYlj!T~$1kfhoa7ER4&+w*%V@iq# z&?wQSgkzXB<8@l(XQTaG8!X{K*8A}jUB`?qg5R$yQbj$0f&XvWfA*&g!!tofm3NMD zrz-Di#}{@K$h+xSO1vnMD??Ak=2P+<5vMPSq-5ELlC7BRQ{|rGF$PzBw$YCRRjB$a zOD%;{{YfZ^ZL%w<39oupcwGQriAcbcqP-?L3)oxXPd-s7yPnJij^+7d-z?cxQ@`}= z^{Pb<&AQWB|F(>$`^KQQ)Sh`>vA-w4n$arB>Ql zE6T8DE{qtdW+q|tCVJQc;yP;B0)i|MwoqI+6+{ql zS}|^mT43-^jj}BJzNAG;p25fB%MsBG5HyUaOg+@VIJZL*>#0AfmY} zKVK-z59I=f^StWcex%;@ox9)fe`hx5>wRZ79bek{+Vj7heffCC*ZXmObGq;6XER*; z(%CHEp6B#C$F(o^(V7O_w6$ezH4Wn=vPht#v=5q%M!1pB&dFM!39Z+ zWj)oSNvH4&|C78cUFffi$s1~>P}AwsQ#S%aElu5c5Kekb?meTc2$u^~gu3yC)-_dx zxLBr+a4y6u6-4~LNMsT8g%Lfi!_XHXdqL9|0JvXY*c;R-hmZ_Z3Q!JFNJ9K{CNfyK9=2$kumrWyem#36>ESj$nZ1>aGb(X<1VK@lK82e~X2st1R2fx$dK z_{dHDwbX-KyE`*n`1*--U$#CBP^Lbd<->VSzjIs|wFI*P;G4F#tgWV@euN`ZD!{PR zC$zgpyA@pqYcaiCIS{*aFB)`1}V#RK&dvG&$_ga zsBVD3_(GFi#)ox3bwZR&@d2EH_g6#81Wy$y98k-o9vd<1S4)-j3iIbLOA=%O64meS zE7>f67RNB7#RE{4k?9uOTz9qmRpZ?9?9TxjhM47N3Po>!E^siG@DF+;usBqK<6#xA?kQl@kGw=ZQoNAjJc+0N0-zT=s#Cvt(4dH&?17iaz@ z_q9PxOKMS*lUnXeP8u|mlXMW1lUmFSCYqcC?KGu0lm;g?6j?r$=k$Ap3t>tz)>ch0 zhiDXs0)T;1Ys?4}KR(vZ{)!!I{x@}rhKTc2g?KXFvD+wVCUN5dV2?lc$ly4byXFFSCQUrw0B#P-rcl!_kFz%{f$0x07MeNgZ}~` C`B=UH literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_health.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b436d3bcaab8c2e950cb96661427be0c1da76ff7 GIT binary patch literal 3039 zcmeHJ&2QX96rZtYKeFDWO`3G+?q$0bsBXz_J~j!ZO#o3uX+)6Xt`sRqma}#fHye93 z<4_WWRA{9p;y{q78n|=dU*Hd@IA)WgEpq@QPL((ma)J{xw#Vz03T+YGu)H(xz4^_X z_kQzc$A`VW8Ni-?!kB8#*J=HrMEs=mA5q>SXeGv?N%2B2pGeEm@X_881#k zOKIs^Vg&kHa z#=TTS{{DR-I5OQXj{a)@df5Ic3%5G^7mNAvOr=GvPedYt)UsvC=O&}|YJIg#$dvL7 zOew;b=tHK`jH%DHBkoo$#p}uKx76x^a0n+ZSDB~JoA(IU-P^S(%?;Lj!wx}p_-f9R4PW( zt^{|*!*5vtD*Qv5o>t-J`17Zm8)GWTd=Lu7=^~d#R8l6M;jI(B?wVHS!{T%>FrgBP zutl1xOl5}S%@}VcHv0@hEbRI7rx=VGr)VI1k4(JVTXmFH4NO8Siq#0>(XjB!Ml{CaOZ^Wy_;VEIXQc zwYlN3J5@I@?bqu@qoHG~iR14%SZ#JFm4547(!pA*}4-oV(!^q9_W?KQ*Gt@oP^!!bNILeVwiI%+RX)*H6v zc$oj!QiU%XFghAns&QpJPy(Nmapfhnq-{=Q_jQNVT$L)K}RjNriJt{8kT&uiV_AgFe%Nu8R*B{32V2 zUq<9h_Vw`)+yFoI=kDe|%kRE@(0`__oVjuFfpVm+6k1AQ50!R_-?Wrc8-?#aD(xwS zNDYMVP-&|g*;*Zq4MgYz?k@sG0yDEEKRvg7?vwNW>O1M}nLTuJ_ln=Nl#^`~zWeCp z)=Z$??}!H+8(|`WnVH``l-sFpUudIT@b04=kCbcoJ7P4I(g?}*Vbv+bCX*6PwCGXwpmfb7}d3IA&Scxbi?@50$pv^$$; zB0EzlT{qpjuG6$QC-?+I(i3`cvdTdvvuZD6>pHeQc1j`>O@%g`<8zY16pGdiyh>9B z*>LK%>sQFv;E*8zKFe|G3u~^q-mqry6jS*}D;Z|ZBN;;YJ$T_q0B(Vsy^oSWO5ZMg YRA|G&799LlO2WdS45T4n!jo*qpG-hK4*&oF literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_rentman_import.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a19a6ae9520559743de25aa65ba66f1f56eb03b7 GIT binary patch literal 19482 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 literal 0 HcmV?d00001 diff --git a/backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_rentman_request.cpython-313-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4214cc956849c64345a1698309bcfcd1034f40a GIT binary patch literal 14747 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 R)72g90KNL$0T}JY{{lSlZ&LsO literal 0 HcmV?d00001 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3fbc1b1 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,107 @@ +"""Pytest fixtures: test database, client, mock Redis, mock admin user.""" +import asyncio +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from unittest.mock import AsyncMock, patch, MagicMock +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.database import Base, get_db +from app.cache import cache +from app.models import EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog +from app.auth import get_password_hash + + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture(scope="session") +def event_loop(): + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest_asyncio.fixture(scope="function") +async def test_engine(): + """Create an in-memory SQLite engine for tests.""" + engine = create_async_engine(TEST_DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await engine.dispose() + + +@pytest_asyncio.fixture(scope="function") +async def test_db(test_engine): + """Yield a test database session.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_maker() as session: + yield session + + +@pytest_asyncio.fixture(scope="function") +async def client(test_engine): + """Yield an async test client with test database and mock cache.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + async def override_get_db(): + async with session_maker() as session: + yield session + + # Mock cache to avoid Redis dependency + mock_cache = MagicMock() + mock_cache.get = AsyncMock(return_value=None) + mock_cache.set = AsyncMock(return_value=None) + mock_cache.delete_pattern = AsyncMock(return_value=0) + mock_cache.incr_rate = AsyncMock(return_value=1) + mock_cache.connect = AsyncMock(return_value=None) + mock_cache._redis = MagicMock() + mock_cache._redis.ping = AsyncMock(return_value=True) + + with patch("app.cache.cache", mock_cache), \ + patch("app.routers.equipment.cache", mock_cache), \ + patch("app.routers.admin.cache", mock_cache), \ + patch("app.routers.rental_requests.cache", mock_cache), \ + patch("app.routers.contact.cache", mock_cache), \ + patch("app.services.sync_service.cache", mock_cache): + from app.main import app + app.dependency_overrides[get_db] = override_get_db + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture(scope="function") +async def seeded_admin(test_engine): + """Seed an admin user into the test database and return credentials.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_maker() as session: + admin = AdminUser( + username="admin", + password_hash=get_password_hash("testpassword"), + ) + session.add(admin) + await session.commit() + return {"username": "admin", "password": "testpassword"} + + +@pytest_asyncio.fixture(scope="function") +async def seeded_equipment(test_engine): + """Seed sample equipment into the test database.""" + session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with session_maker() as session: + items = [ + EquipmentCache(rentman_id="101", name="L-Acoustics K2", number="K2-001", category="Lautsprecher", brand="L-Acoustics", available=True), + EquipmentCache(rentman_id="102", name="L-Acoustics KS28", number="KS28-001", category="Subwoofer", brand="L-Acoustics", available=True), + EquipmentCache(rentman_id="103", name="d&b T10", number="T10-001", category="Lautsprecher", brand="d&b audiotechnik", available=True), + ] + for item in items: + session.add(item) + await session.commit() + return items diff --git a/backend/tests/test_admin_auth.py b/backend/tests/test_admin_auth.py new file mode 100644 index 0000000..047c70d --- /dev/null +++ b/backend/tests/test_admin_auth.py @@ -0,0 +1,87 @@ +"""Tests for admin authentication (T04).""" +import pytest +from app.auth import create_access_token, verify_token, get_password_hash, verify_password + + +def test_password_hash_and_verify(): + plain = "mysecret" + hashed = get_password_hash(plain) + assert hashed != plain + assert verify_password(plain, hashed) is True + assert verify_password("wrong", hashed) is False + + +def test_create_and_verify_token(): + token = create_access_token({"sub": "admin"}) + assert token is not None + payload = verify_token(token) + assert payload is not None + assert payload["sub"] == "admin" + + +def test_verify_invalid_token(): + payload = verify_token("invalid.token.here") + assert payload is None + + +def test_verify_expired_token(): + from datetime import timedelta + token = create_access_token({"sub": "admin"}, expires_delta=timedelta(seconds=-1)) + payload = verify_token(token) + assert payload is None + + +@pytest.mark.asyncio +async def test_login_success(client, seeded_admin): + """POST /api/admin/login with valid credentials returns 200 with token.""" + resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "testpassword", + }) + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert "hms_admin_token" in resp.cookies + + +@pytest.mark.asyncio +async def test_login_failure_wrong_password(client, seeded_admin): + """POST /api/admin/login with wrong password returns 401.""" + resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "wrongpassword", + }) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_failure_unknown_user(client, seeded_admin): + """POST /api/admin/login with unknown user returns 401.""" + resp = await client.post("/api/admin/login", json={ + "username": "unknown", + "password": "testpassword", + }) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_without_token(client): + """GET /api/admin/me without token returns 401.""" + resp = await client.get("/api/admin/me") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_me_with_valid_token(client, seeded_admin): + """GET /api/admin/me with valid token returns 200 with username.""" + # Login first + login_resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "testpassword", + }) + token = login_resp.json()["access_token"] + + resp = await client.get("/api/admin/me", cookies={"hms_admin_token": token}) + assert resp.status_code == 200 + assert resp.json()["username"] == "admin" diff --git a/backend/tests/test_admin_router.py b/backend/tests/test_admin_router.py new file mode 100644 index 0000000..6cde529 --- /dev/null +++ b/backend/tests/test_admin_router.py @@ -0,0 +1,101 @@ +"""Tests for admin sync endpoints (T04).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from app.services.sync_service import SyncService + + +@pytest.mark.asyncio +async def test_sync_without_token(client): + """POST /api/admin/sync without JWT returns 401.""" + resp = await client.post("/api/admin/sync") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_sync_status_without_token(client): + """GET /api/admin/sync-status without JWT returns 401.""" + resp = await client.get("/api/admin/sync-status") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_sync_log_without_token(client): + """GET /api/admin/sync-log without JWT returns 401.""" + resp = await client.get("/api/admin/sync-log") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_sync_with_valid_token(client, seeded_admin, test_db): + """POST /api/admin/sync with valid JWT triggers equipment sync.""" + # Login + login_resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "testpassword", + }) + token = login_resp.json()["access_token"] + + # Mock sync service + with patch.object(SyncService, "run_sync", new_callable=AsyncMock) as mock_sync: + mock_sync.return_value = {"sync_id": 42, "items_processed": 10, "items_failed": 0, "status": "completed"} + resp = await client.post("/api/admin/sync", cookies={"hms_admin_token": token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["sync_id"] == 42 + assert data["status"] == "completed" + + +@pytest.mark.asyncio +async def test_sync_status_with_valid_token(client, seeded_admin): + """GET /api/admin/sync-status with valid JWT returns status.""" + login_resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "testpassword", + }) + token = login_resp.json()["access_token"] + + with patch.object(SyncService, "get_last_sync", new_callable=AsyncMock) as mock_status: + mock_status.return_value = {"last_sync": None, "items_processed": 0, "status": "never"} + resp = await client.get("/api/admin/sync-status", cookies={"hms_admin_token": token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "never" + + +@pytest.mark.asyncio +async def test_sync_log_with_valid_token(client, seeded_admin): + """GET /api/admin/sync-log with valid JWT returns paginated log.""" + login_resp = await client.post("/api/admin/login", json={ + "username": "admin", + "password": "testpassword", + }) + token = login_resp.json()["access_token"] + + mock_log = MagicMock() + mock_log.id = 1 + mock_log.sync_type = "equipment" + mock_log.status = "completed" + mock_log.items_processed = 100 + mock_log.items_failed = 0 + mock_log.error_message = None + from datetime import datetime + mock_log.started_at = datetime(2026, 7, 9, 12, 0, 0) + mock_log.completed_at = datetime(2026, 7, 9, 12, 5, 0) + + with patch.object(SyncService, "get_sync_log_paginated", new_callable=AsyncMock) as mock_log_fn: + mock_log_fn.return_value = { + "items": [mock_log], + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + } + resp = await client.get("/api/admin/sync-log", cookies={"hms_admin_token": token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["status"] == "completed" diff --git a/backend/tests/test_equipment_router.py b/backend/tests/test_equipment_router.py new file mode 100644 index 0000000..9d25989 --- /dev/null +++ b/backend/tests/test_equipment_router.py @@ -0,0 +1,77 @@ +"""Tests for equipment router (T03).""" +import pytest + + +@pytest.mark.asyncio +async def test_list_equipment(client, seeded_equipment): + resp = await client.get("/api/equipment?page=1&page_size=10") + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "page_size" in data + assert "total_pages" in data + assert data["total"] == 3 + assert len(data["items"]) == 3 + + +@pytest.mark.asyncio +async def test_search_equipment(client, seeded_equipment): + resp = await client.get("/api/equipment?search=K2") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert "K2" in data["items"][0]["name"] + + +@pytest.mark.asyncio +async def test_filter_category(client, seeded_equipment): + resp = await client.get("/api/equipment?category=Lautsprecher") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + for item in data["items"]: + assert item["category"] == "Lautsprecher" + + +@pytest.mark.asyncio +async def test_sort_name_asc(client, seeded_equipment): + resp = await client.get("/api/equipment?sort=name_asc") + assert resp.status_code == 200 + data = resp.json() + names = [item["name"] for item in data["items"]] + assert names == sorted(names) + + +@pytest.mark.asyncio +async def test_sort_name_desc(client, seeded_equipment): + resp = await client.get("/api/equipment?sort=name_desc") + assert resp.status_code == 200 + data = resp.json() + names = [item["name"] for item in data["items"]] + assert names == sorted(names, reverse=True) + + +@pytest.mark.asyncio +async def test_categories(client, seeded_equipment): + resp = await client.get("/api/equipment/categories") + assert resp.status_code == 200 + cats = resp.json() + assert "Lautsprecher" in cats + assert "Subwoofer" in cats + + +@pytest.mark.asyncio +async def test_equipment_detail(client, seeded_equipment): + resp = await client.get(f"/api/equipment/{seeded_equipment[0].id}") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "L-Acoustics K2" + assert data["brand"] == "L-Acoustics" + + +@pytest.mark.asyncio +async def test_equipment_not_found(client, seeded_equipment): + resp = await client.get("/api/equipment/999999") + assert resp.status_code == 404 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..8e15a92 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,12 @@ +"""Tests for health endpoint (T03).""" +import pytest + + +@pytest.mark.asyncio +async def test_health_endpoint(client): + resp = await client.get("/api/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert "db" in data + assert "redis" in data diff --git a/backend/tests/test_rentman_import.py b/backend/tests/test_rentman_import.py new file mode 100644 index 0000000..47f6214 --- /dev/null +++ b/backend/tests/test_rentman_import.py @@ -0,0 +1,131 @@ +"""Tests for Rentman equipment import pipeline (T04).""" +import pytest +import pytest_asyncio +from unittest.mock import AsyncMock, patch, MagicMock +from sqlalchemy import select +from app.models.equipment import EquipmentCache +from app.models.sync_log import SyncLog +from app.services.sync_service import SyncService +from app.services.rentman_service import RentmanService + + +def make_raw_equipment(rid: str, name: str, category: str = "Lautsprecher") -> dict: + return { + "id": rid, + "name": name, + "number": f"{name[:3].upper()}-001", + "code": f"{name[:3].upper()}-001", + "equipment_group": {"name": category}, + "description": f"Description for {name}", + "specifications": {"weight": 50, "power": 750}, + "images": [{"url": f"https://example.com/{rid}.jpg"}], + "rental_price": 150.00, + "brand": "L-Acoustics", + "available": True, + } + + +@pytest.mark.asyncio +async def test_transform_equipment(): + raw = make_raw_equipment("42", "K2 Line Array", "Lautsprecher") + result = RentmanService.transform_equipment(raw) + assert result["rentman_id"] == "42" + assert result["name"] == "K2 Line Array" + assert result["category"] == "Lautsprecher" + assert result["images"] == ["https://example.com/42.jpg"] + assert result["brand"] == "L-Acoustics" + assert result["available"] is True + + +@pytest.mark.asyncio +async def test_paginated_import(test_db): + """Import service iterates all pages (3 pages with 250 items).""" + page1 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100)], "itemCount": 250} + page2 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(100, 200)], "itemCount": 250} + page3 = {"data": [make_raw_equipment(str(i), f"Item {i}") for i in range(200, 250)], "itemCount": 250} + page4 = {"data": [], "itemCount": 250} + + mock_rentman = MagicMock() + mock_rentman.get_all_equipment = AsyncMock(return_value=[ + *[make_raw_equipment(str(i), f"Item {i}") for i in range(250)] + ]) + + with patch("app.services.sync_service.cache") as mock_cache: + mock_cache.delete_pattern = AsyncMock(return_value=0) + sync_service = SyncService(test_db, rentman=mock_rentman) + result = await sync_service.run_sync() + + assert result["status"] == "completed" + assert result["items_processed"] == 250 + assert result["items_failed"] == 0 + + # Verify equipment was upserted + db_result = await test_db.execute(select(EquipmentCache)) + items = db_result.scalars().all() + assert len(items) == 250 + + # Verify sync_log entry + log_result = await test_db.execute(select(SyncLog)) + logs = log_result.scalars().all() + assert len(logs) == 1 + assert logs[0].status == "completed" + assert logs[0].items_processed == 250 + + +@pytest.mark.asyncio +async def test_sync_upsert_existing(test_db): + """Upsert should update existing equipment, not duplicate.""" + existing = EquipmentCache(rentman_id="100", name="Old Name", category="Old") + test_db.add(existing) + await test_db.commit() + + mock_rentman = MagicMock() + mock_rentman.get_all_equipment = AsyncMock(return_value=[ + make_raw_equipment("100", "New Name", "Lautsprecher") + ]) + + with patch("app.services.sync_service.cache") as mock_cache: + mock_cache.delete_pattern = AsyncMock(return_value=0) + sync_service = SyncService(test_db, rentman=mock_rentman) + result = await sync_service.run_sync() + + assert result["items_processed"] == 1 + db_result = await test_db.execute(select(EquipmentCache)) + items = db_result.scalars().all() + assert len(items) == 1 + assert items[0].name == "New Name" + + +@pytest.mark.asyncio +async def test_sync_failure_logs_error(test_db): + """Sync failure should be logged with error message.""" + mock_rentman = MagicMock() + mock_rentman.get_all_equipment = AsyncMock(side_effect=Exception("API unreachable")) + + with patch("app.services.sync_service.cache") as mock_cache: + mock_cache.delete_pattern = AsyncMock(return_value=0) + sync_service = SyncService(test_db, rentman=mock_rentman) + result = await sync_service.run_sync() + + assert result["status"] == "failed" + assert result["items_processed"] == 0 + + log_result = await test_db.execute(select(SyncLog)) + log = log_result.scalar_one() + assert log.status == "failed" + assert "API unreachable" in (log.error_message or "") + + +@pytest.mark.asyncio +async def test_get_all_equipment_paginates(): + """RentmanService.get_all_equipment iterates until data is empty.""" + page1 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100)]} + page2 = {"data": [{"id": str(i), "name": f"Item {i}"} for i in range(100, 150)]} + page3 = {"data": []} + + svc = RentmanService(token="test-token") + svc.get_equipment_page = AsyncMock(side_effect=[page1, page2, page3]) + result = await svc.get_all_equipment(limit=100) + + assert len(result) == 150 + assert svc.get_equipment_page.call_count == 3 diff --git a/backend/tests/test_rentman_request.py b/backend/tests/test_rentman_request.py new file mode 100644 index 0000000..e57995e --- /dev/null +++ b/backend/tests/test_rentman_request.py @@ -0,0 +1,120 @@ +"""Tests for Rentman request submission pipeline (T04).""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from app.services.rentman_service import RentmanService + + +def test_build_project_request_payload(): + data = { + "event_name": "Sommerfest 2026", + "date_start": "2026-08-15", + "date_end": "2026-08-16", + "contact_name": "Max Mustermann", + "contact_company": "Firma GmbH", + "contact_email": "max@example.com", + "contact_phone": "+49 170 1234567", + "contact_street": "Hauptstr. 42a", + "contact_postalcode": "80000", + "contact_city": "Muenchen", + "location": "Muenchen", + "message": "Brauchen PA", + } + payload = RentmanService.build_project_request_payload(data) + assert payload["name"] == "Sommerfest 2026" + assert payload["planperiod_start"] == "2026-08-15T08:00:00+02:00" + assert payload["planperiod_end"] == "2026-08-16T02:00:00+02:00" + assert payload["usageperiod_start"] == "2026-08-15T18:00:00+02:00" + assert payload["usageperiod_end"] == "2026-08-16T23:59:00+02:00" + assert payload["contact_name"] == "Firma GmbH" + assert payload["contact_person_first_name"] == "Max" + assert payload["contact_person_lastname"] == "Mustermann" + assert payload["contact_person_email"] == "max@example.com" + assert payload["location_mailing_street"] == "Hauptstr. 42a" + assert payload["location_mailing_number"] == "42a" + assert payload["location_mailing_postalcode"] == "80000" + assert payload["location_mailing_city"] == "Muenchen" + assert payload["remark"] == "Brauchen PA" + + +def test_build_project_request_payload_no_company(): + """When no company, contact_name should use contact_name.""" + data = { + "event_name": "Test", + "date_start": "2026-08-15", + "date_end": "2026-08-16", + "contact_name": "Anna Schmidt", + "contact_company": None, + "contact_email": "anna@example.com", + "contact_street": "Testweg 5", + "contact_postalcode": "10000", + "contact_city": "Berlin", + "location": "Berlin", + } + payload = RentmanService.build_project_request_payload(data) + assert payload["contact_name"] == "Anna Schmidt" + assert payload["contact_person_first_name"] == "Anna" + assert payload["contact_person_lastname"] == "Schmidt" + assert payload["location_mailing_number"] == "5" + + +def test_build_equipment_payload(): + item = { + "equipment_name": "L-Acoustics K2", + "rentman_equipment_id": "101", + "quantity": 4, + "unit_price": 150.00, + } + payload = RentmanService.build_equipment_payload(item) + assert payload["name"] == "L-Acoustics K2" + assert payload["quantity"] == 4 + assert payload["quantity_total"] == 4 + assert payload["unit_price"] == 150.00 + assert payload["linked_equipment"] == "/equipment/101" + + +@pytest.mark.asyncio +async def test_projectrequest_success(): + """Mock Rentman API: create project request returns ID, equipment added.""" + svc = RentmanService(token="test-token") + svc.create_project_request = AsyncMock(return_value={"id": "9999", "name": "Test Event"}) + svc.add_equipment_to_request = AsyncMock(return_value={"id": "equip-1"}) + + project_payload = {"name": "Test Event", "usageperiod_start": "2026-08-15T18:00:00+02:00"} + result = await svc.create_project_request(project_payload) + assert result["id"] == "9999" + + equip_payload = {"name": "K2", "quantity": 2} + eq_result = await svc.add_equipment_to_request("9999", equip_payload) + assert eq_result["id"] == "equip-1" + + +@pytest.mark.asyncio +async def test_equipment_retry(): + """Failed equipment POST should be retried (exponential backoff simulation).""" + svc = RentmanService(token="test-token") + call_count = 0 + + async def mock_add(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise Exception("Temporary failure") + return {"id": "success"} + + svc.add_equipment_to_request = mock_add + + # Simulate retry logic + max_retries = 3 + result = None + for attempt in range(max_retries): + try: + result = await svc.add_equipment_to_request("1", {"name": "test"}) + break + except Exception: + if attempt == max_retries - 1: + raise + import asyncio + await asyncio.sleep(0.01 * (2 ** attempt)) + + assert result == {"id": "success"} + assert call_count == 3