From 104d42201d60d5995656992c0d60c51915a44e5c Mon Sep 17 00:00:00 2001 From: Thastertyn Date: Mon, 20 Jan 2025 06:49:32 +0100 Subject: [PATCH] [main] Major changes for submit --- .env.example | 5 +- db/Model.mwb | Bin 15104 -> 11200 bytes src/assets/book_import_scheme.xsd | 20 ++- src/database/__init__.py | 4 + src/database/book.py | 100 ++++------- src/database/book_category.py | 11 +- src/database/book_category_statistics.py | 35 ++-- .../book_category_statistics_overview.py | 55 ++++++ ...gory_statistics_overview_model_overview.py | 0 src/database/book_overview.py | 4 +- src/database/manager.py | 11 +- src/database/member.py | 39 ++++- src/importer/__init__.py | 0 src/importer/book/__init__.py | 0 src/importer/book/book_importer.py | 71 -------- src/models/__init__.py | 4 - src/models/book_category_model.py | 4 - ...book_category_statistics_overview_model.py | 7 +- src/models/book_model.py | 4 +- src/models/book_overview_model.py | 8 +- src/models/librarian_model.py | 33 ---- src/models/loan_model.py | 39 ----- src/requirements.txt | 1 + .../book_category_statistics_service.py | 61 +++++++ src/services/book_service.py | 12 +- src/ui/editor/book_editor.py | 57 ++++++- src/ui/editor/member_editor.py | 16 +- src/ui/main_tabs/__init__.py | 3 +- .../main_tabs/book_overview_list/book_card.py | 158 ++++++++---------- .../book_overview_list/overview_list.py | 4 - .../__init__.py | 7 + .../category_overview_card.py | 4 +- .../category_overview_list.py | 33 ++-- src/ui/main_tabs/member_list/member_card.py | 74 ++++---- src/ui/menu_bar.py | 26 +-- src/ui/settings.py | 36 +--- src/ui/window.py | 5 +- src/utils/config.py | 26 ++- src/utils/errors/database.py | 2 +- src/utils/setup_logger.py | 16 +- 40 files changed, 511 insertions(+), 484 deletions(-) create mode 100644 src/database/book_category_statistics_overview.py delete mode 100644 src/database/book_category_statistics_overview_model_overview.py delete mode 100644 src/importer/__init__.py delete mode 100644 src/importer/book/__init__.py delete mode 100644 src/importer/book/book_importer.py delete mode 100644 src/models/librarian_model.py delete mode 100644 src/models/loan_model.py create mode 100644 src/services/book_category_statistics_service.py diff --git a/.env.example b/.env.example index 0fca286..29f7655 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,7 @@ DATABASE_HOST= DATABASE_PORT= DATABASE_NAME= DATABASE_USER= -DATABASE_PASSWORD= \ No newline at end of file +DATABASE_PASSWORD= + +TRANSACTION_LEVEL= +VERBOSITY= \ No newline at end of file diff --git a/db/Model.mwb b/db/Model.mwb index 59ba59123945da36578041bfea3a9584a0e2ecf6..9f3c0f97b42dc8f0e624185fd9cf9c9ed455e390 100644 GIT binary patch literal 11200 zcmZ{Kb8u$gvi6%yY&#R%wryu(XJXr&Boj_-+qP}nb~3Rhc)!f=eCM9Jx9(lL)?QtW zs{Kb-Kiz9TiZWlo&;XzSD8O3{lNK2%=w%Hl0KhX23P1t?048?EE;gpN&h$2JM)dAB z)|a}vUhC|2UHtFpp5F9xzfs%YK1SHL_E)5r)(0M?<=eeXg9s_>F{Ba;_FB3>0HkC} zO`^zPv_DxrXC)Ju@Bl#`kOX&o$$s95L!EY%@^6&heuM;4kU-9lT};e^9FFjLT!!#3 zzq$kzuMzn;0_OogPUZe^3SA*~@ge}D zv-0%xXdOQU8Tn4gEO>vM8bX1-T^o`go>aJx3QfCh6Rf}_=%V6;A_-n9CdIlz$pR3` zCWGsCu|mF|I4NBt^uC(lbO`+FO0T2l$+9uKDxNpMi}HpVk`VKewp>}TbWh8ZEc=Oj z^_8znfKv20&S~#mm) zLMZoVx}OY!?^aF-G?`sh$ay^GchDVHsr(%{TxodZy44PH5Q~a@2Er~0hnwGougVf` z*p+j9X$337*~!yDr0z{biNS0Xe1vGf7p~W!m+YAL>#ngB{=rkz8(zO%k+4L8D&xuS zH`!oJb#33vHg4*kLqqap_YL2>*?x4WpNq$q$=Q?XR++?Y7I%s!j!!8G!}>*|7lx0t zTpFu(xFHWJt%VNoCBu!sHJf(FmUQB|+ghfSu2bH)__+u3ArQyO@bN71X*cf5zGKyc zXhP|lH#C*gt{T|W-C24^ixa#v{XvtMAMNX%BuE_kR!C32lQWLgEDM04i@Sh*ls$Y> zd6<#i$P3bmXbVCctz|2rR9Gl6hnYT{K?^cZ`?IUuJWwd1&{il-AU5}WT3-Q^h=G9k zMaWOSmLR1x7ERyc{+CC?5Th4&RIE%*d_sl=D{vP_pkOXS=9M=@>6sH2G42gF3WIaT zaCr}hb6+#4;K1qD`dTq+U-&EbP_z|RTP5+CVZ~`k5f!sWu9VsVpeqVuw%-gbdam6J zc|Z%B+a^1ZFLp<^8DtlNb9JF4iJc`ROhq-j3osBp1;CPE?s zxk~jB2iTZRAV2o&j6$^_`wt_eFkVLd;_le(bS)*#ArD6;{1~hB`T6L2HN{J|q=cs- z1J!08jI%$ZVhKlDW0Tap^g3sT{kQHb!U6=%a$=}7>FWRHPMU9{GiXubee{;9!FZw51 zvWfC(t2>)eA5|$Tpp-m;mYBgVSdpCVMvH(xfSt|eeSIu-Q}XWr@@5%vh6F*pzJS(0 zp0o%T5U|-Jn6nD{Wj!pQ$9i@`A&C?u5DTn3ASH4`_lx0KvGD*xVB>%@6u^b|%;dmC zCc6mGV++X1F9QXvL-|Lag-aQXUo(hpyYa(1D}fpd@F)%-1fcbCg9X=x=2S^b0u0^! zXOww$0fw*vNNyR+UkMk$5PnbdULyxQe`j{7S==B(D9>}g#93fH{{uMfyV(o%;n`1N5Cc*f-6ngZlpU2L2i1jfuUq^ z7%ddHB}+knqj-TNMN%oViUk2sT#OX?mYf3^rS~TVw1I<3&IJ`TSV=a}os?C>ptz8W zsy+gcbaj#ek#6uI@hhQtj!c$6Pv?H(!h00aNnw4M$o1$$?kL3KZA5^6XhHe)__s+8 znQuojdT&6|ryUG|gFFiIa#~o9uL)3A$Z$Rtp!Olr{IqdQ5#YBaEwTqMW{vi)Z&=%M zstm#j8rGlPzpBm73#strMpW;NQHp$13R6q)V_lx8)O;D0D2$8B&! zJ#B$+e2|->XxH{KZ;aX+@;y%)+{z=-3^pQ!v>qJraEiFxFzkR|R1BYJ1)4je;?n*J zTR#57L|OYW@At7}sssB-h31T8K7J0XJY1dp-5omsgX7$YuIU-RS6(Zh^wox}CNY38 zoVU{33a4ie3Kk41%=x&zPBDRHgN}#0qi_*SKPxA~*bf#i8&+OAKrD#$^IZR(1tk6b z$J|U@Qiy7oo*Y+!9xvM6Z@Uai>W^d_*ih!c$Es11?@+e~PZL(6BGJn8Gp* zZ8&Tv?IsViA-_R<5Cd)~`*Ig`XY4z1+C$(|G0<#eU^f#gI|BPsvLNV>6_I0274XwKTa(HCHv4G3#OB3byJetssVES zjiu95c{TbzMwpWZD8Q?J&cG)TAj1HPBMNphcBQb2T-VFb>`C=Bl9exyQQrvVC97>P z(_eO)*20LNVSHr#v+f6?fAnX7vuWG>}a(_BG&G{4og5pClkuf9`1#x zD__6M+&uDfVpVI2l9iEQeL~j-Ik`nXd_^ag3wBr0@36+hd6oEQO&GQNzm12v(e%9V zd6uwGiCxiZ_ag?Q^%HeXfZP$VVQ2@dlfoqw3FH|N_eM)m}bm{A}=;B=8B5DD@* z0dnt%L^aZ3kXVFJL%+nv(8E!A0UU!a=W(!MVMK(`fNut(2$WLr!LpeU{HAI@qytwV zY&20~9JG^$4LDX^o=C&F?~E~!-|Y`VO)lBs^6L>N4^51-m6C~u^0Y2*-cm9r_fs}q zE@H$vZD~};x*a$zIFee`QXQb^@oZyi)Vk?l*d`k93kZWYN#A{_IPiepz`kI z=4t)SGWDyDZS)Q7Y%&8Q&jSj#-Q4(W6ax7aKio`rK?kl%rf7I{`2544ta)m>2!Z7+ zv04PeV&$1%^f!q}WlgZMsEarEi81U+=7T_%{yk*pQ)gXT=Xvi-vBLBAdhOK!)|*|7 z?YGBA8wRTaJzIs&@d&*~x8}~6A*aMx-BUa7+Yx~h$U1w;f(4u|Y*@d@N%2hq2W0pOo5B-uZTT@4 zk|cGB<>+E2TOm z1$ajzl2~wDXgg;5gVZzwlHAd!?f6$e`{C0jLXy(@swN2&(pN#zz99dD!Z3-mosTB2 z7TUocYC%V>RdhTZT_L_{w321TG-_tuB0yM5A5qHHca?<%@-8A)@T zq*JUU!9E0{T+@gbeV$mP6;Nn!6n3FL>xg(nq$P|+GQ8ku7&(O2Wh_%0;m%9rM2H3%)U>+xKz1_FwA#>4N)O}n>{w(~j zr2{TdCK%Sz8c;iKT+MMOs>8%;ZI_OtuhaY z?y|11sm58qS@cE6J{&kh;gM-J(JjtOdNxt_)ROFk4dB%!T9rgokVhKzjkx9%?ZE9g z7ZnvXyo-oB^wT`y>Pdx{yv%WQe8?6fTK2j)dGt}ot+^CJu|~1a2_M^3hSP{?Ny6$< z)O&SXN*?kL7}( zPsdrG0M@s%$d z0>Tj2kqMUA*OQ@8=paF48rRTCiJ=>rBq!F_FocX?g9f;<4PWs|m#|KWCDDY8mV*Xp z6D7cYf=A;JOIl0((pa}1oOIm#&eg>+PBab3YSFA(=NPZOWQa&V9QeoJP_M%l)^PNl zOQhCBCh1|>vtGv^6moFSwsqODc_ZyZ&!JuCBYDra)%x)QN4r~J9<~1Ty+yz3X9OC@ zMB_mSSEZDDK8<#pA;P(N*L*?dCS!7XSBr5~>-x$XozuviiI}=xKb=#SiI+5uZR$Lp zf&-h$PwgY_c&tmQo>(y>=U0?`sbQ|Yq}C)d@d)iBc2a@mp7%->X<^8+y2RWpq7%Wk z08d4fl4a?Ipg5A>Xy&*Vm(az`*!2-~ry6U`QHHI!wA3C}{`d^;cMZ`_# zl8wHuunVGrX?j7&1rkQ^2w`Y$Ha&$Aj>r3xHu@8mYgmQa5RcrFnvBs+*wL)5p@M0C z3gz`wjms}KF5+z?cBn5lfrRDqw%Y5^b&Mf6kLA-g#&9=0^1sMR=@aM@3!Fvj>1uOn zu{~jp`|~PY{zB94Z{vP$?Pf20+`rKDiF1a_t%xH)@CPA;{5G1te%Ihomr8X;0;r=e zT@#R5U?!T31kUL%{x-Xc|0sLC2~!Zunx!X~7zsFwj49Kev{Df>DBHRNINgW$Pn3`d zJ1{_l&usO3gKAU-XNZ1bH&(O-BIK&ESVucU!TGAx5IZNK_r_aGtfSBbN;z*3(e%wh zAss+X$eU>6L6bYePl`f3ls2+El8$7pB1D71PB+3Ci1fzJWIAJuDT}qq;?|2f4FBA~ z`c%(1EPS<-ym7Tk|9PZyEDpVLKe&xzX202HSugw9T&(XiTo% z$s*OJ5$G#-c}{IrO2 z_NK&Bc)HczC^N-v!Sx|oJFzc0=&4&`)nQ%%?aqBl90Sn9>RI>8at;|vCY~ygLfB5Q zG0sJu8c6^7xHob4bX+z4^*+mx)lHlV7};k{e;GCPMKDN9q?A#C}+JIv$$DJR4S0Z$dnMX;aeH(k}v4J+|<|4o&8Nr^t3XTmB3&s(#YCYiyU>N zRUAkm9=dRkq!N}v(ekW9SW8T8_|=h( zhUa00R#{qP9)CDFx07x7CGATl$#2pGzMA{B#^hK-|IYVJjeQ;T0YewFCs<5vg!-K? z|0(>0ZkmKB8IQ9ljV4B_l?j6)3kbUpqr3zm6&8g3$%a^gMi$ep2RALCdq2(j`?xw- zTdKzWb<8jU>3`arH`W$W&T0vq<21dWRAx**u69frre~ zMB;j2JWqV8PP%z3-{vAKL%Qk8bDCr`sYJRNUZ5T8OcQ5Y?xq-6?S6T6dc9>bf z$^^H%syt^?luvslHFrrogjw2r!Nta=TK51xZC&14*?Lj*U!eDY9Dkg;*nMyxkGO3o zb?tJRMq(h!Gcd?2RQZ8P{8O$=d)K$px6+&W>_vHv9}g&KJ0Ocrb3Xh?&o3- zzxm+k_;$8Y9b{)BR!=k3=ppc?$hOVaW1uv zX8R2o7}j|KZ}{TU(;29{cq~SMMOvD`s%Ys#*1q6L9~* zargMMo9Hqoh5=c=l2m$2E!&zOkI=d3if^ zwD*0VX1MCs%lK!InD%_>4ti=n#b?JtD~{Q{1a3x?@OrgI#N9V>ygiv&Cl%{E^a7_o z+8Fn0uYLgzAI*PoJ)y7;>A`7pf2_LIOB`%BuLey--+%stF?}mQ%C5JN;5|lg6p|%C&wngHd%*{+z4zI|>n%HiQfCcC9d!R4eguz_ zfI})65OfQXIy$J^EbaL5Tzb)w?SnKGeC)@L+Wnl$d$UJ*@}BbkG$3gElj;5=?5)O{ z+rw^myT^tB#xLXP;B~_pBlBfmp_A!$&I~jwM*EhSb80Xaad11KU|R>tTvz+4IfcGMb#=XqPG67Fa}b+GhgN9?7U(h(7g*s(5tZg&=n*PDM?K@aInWa- ze_xbWBs$Di2O)s?c%Ufn4B^)m(Bq`7_(V79d`hZLBoY{uQ(idvw4rlzs&7|0!{cWx$bygnMSTOZk6*?179S+l%zfj#| z+nE8x`AH=rL~j-a>(1l~cF5f|FPGvQZ8NV5CEtMhGgGEvW^6^kOgZssLk_)o)wYyugLEi zy7esasXF$FKDmm_kXr(1X8uYiHHMK(( zS<^W1p%1BA8*SGJ;0BtGHx;{an``PO2P?RuuyyipH>(X5Q;(YTLDm`Qun4wlo9eb1 zrATRQ^i+@!OToNqOL%$@A#NWl_$G{B1&#s!kF zE(z-sP$@SmhF7Bd83akiF1$p;9+Td1x* z7!bW-vZ}6-p3%S?p`Ue1S$f=jZg-u!L4H#;z09>F6Zo0q-g{Id|3O>5M7lR^*OF*q z+khHJ#%h7YIvLaM5u9EpgD8tzq=r+~Bh$iye}0n$39W_ z3Y`k9GIK?6evj0U5&X?BSoP=jS2?j&@vrmI%0QjVMUkrSE7gdO%evMcF0PK7#SCsC zG#Z+=3`BZ9QApLqrA|mO;4fJzYFFR`=M0E9X*5?Tdu*5uFndu`f^R#w=MOJpsK1t7 z4hx04fY~3X%Z~v$A2>paL9U^KjL}7<1bHO)_`Me4U;@$*C*F@W9_(Xp z+|Jh2F;-Qgc7$}$J?;|6$U9`PUcF2dO17+nyQ~a`P=7!; z7DpW9<@1E2>$(C`iobgg-f;HoR8f8N7=SN4;i*>YQBwUK#0i5Zu^VQl<}l8+KeB5| z_9l8rH>^Zx!4bi(k;Mgp!tbW7BN=6J8UY>B3iZD7zT=Yt2k3HdKCD-6q9Yvti% z3^j>aED$}mhGxInSfA_FA5*>G{3JOL_N?1;q0fn{j=12mHyD zI@~^RrQb;90RABxnkwEj!RoXc4C)#gr7;xmD#DOn_TH3sdgt_XQ~m;Z?&=G9)Tf~l zaVT=5Si8uvIhvY_IaC?-(Mz8D;XexUs@0QCdHSVW9Yqt7zcu1Cl#Yo_;i`?k;3$h3 zHZ6g&Rz6%jsL`Q^a6#yaQjMe@7~t`d>Y){$AMoB$&xMFs;->103wb(oe>+UIm9hO^ zi2@N6|@a3RKZpOJ1lhGv^bH#NtfKqubmd)}yzitapW z#aY$Ltl^-2N}=Bn4A<658wNykqe->pJdKXAO}0)++rNmyD^5mKnV<6J8%iz#2ZGwG z4ckq;zJdfv62sA zFcJ8fCsv4JgL$Jn`t;; zUFXrWNKs*Izi@IIq(pX3NaQuO>uH0h7^Rzqhj$D)v0tfF6b|?=^99m|%nj=YpNwI# zx#O%$l>}JbU6$zUlgpEcQtROKt`EokH3leL02N6$O1h$3_HG?hM+Pbz0ZA8t`O)ZK z*z3cYJl6+n2N&u7gdjre{y7`Z0h*?7pn-Z{1DpIo&xuN|{iR7rkdOx80~J6J;*5uty{zMzR_ z78RACg$YFL6O0^?{RdXv`yi2-ODX{Ybw)nF0f;%$R9kjxw=(*gf1y551 zi5s$!r=!KG8t4NmoNP@n*3j>@iy)(1k{L|U#xF{XVvh<&_y`m>^JT^k8g3lsKjZ$W z&>q7~*g-zV39&5?+Yjr1s?|NBA zUC;eAz2o3}Yo6hg)ykRL+AD(-@0DBIl_tL~yAQR+!8{2FR$=`5$Ip~2=-wtnJU+-c z>T4%~H%DhbLj(F!l?C$0FOMD!a*^|lCX$TDEG`>F&9sVFfjbe{M4_B-DAZTyejbzctZX@Z7E|vvk{NOm03}Y0-?v zW4Z1&om+3gHg|q$*2Sx2N{Lx=p0-(cZO^k&u}NM|Dm;?e2)Wh5l(8LIcSG@5UU|fz z_lBg`X(H8BLJN9P47cCqLF`ye65Fclt&uD3)FSBO93FXko+ukFwv>`+lK1e2sQsGl z!ET!s%V1|&J2C$NLD>z$)p|$a?85kMxru%J&0Nn>-mSQw~CM|7^dRFGpisKh(fnj{_If{l0r8QSMw*JQorq~O2f z@0hlSh+n&)Rj+^j>_MdrT}W=ZR(p8mP6s)}&{13x`LebIQn|Cgf%b&V2Z+9~%pFUUhmtr7J;Q1(6{@6p%6t@y zX#oES&ogM;Q|@GE*!Cv%3}R}pt~i7eoOYy5+e zPEnR7&J`6_<`D}H!EBv*qDf*E6H{rv<~L>?Sm`a0&4ayuRwBF%DLY2|{N$a!FOfR# z55qV!SQ(tsy|ZjsbleHH36-F-oKePad8_^6Bdu9T(R)Va55g(?>7TrdY)DR_$HJ{^ zmZ-FNWy^cBhHv@4Pqdw$FXLyH=?Kak&g(jlmUzHh&bOyy7D$Ee8+)H8Xv4|ceJ&{# zf1dyz+Km$y(SkVFG+j2QuWjbw{wh-35&2Sml>5Bxvk7WrNE`|P_mev|p-2>8NQbb> zmQrHO*`y*bz4I#megN_b_%}$m8?tq2#5M&pl4WWHx#**d}-o80ki9k8oKu z_QuxeTF2fx`g+`}Cyg~08O!IV8D>Nnv>(fL6J)t=+w-myL}Jn272^<1>x(I` zNaN+DkK&}2Nh>L|ubf-E62j4&9}3AG5~gRNz-<|BJx}q^&=IX*Q|~y&C6q6PxmtF^ zTm#!-B@z{2ZJ`fvuwK!za*3qpCk7LvGWmcyId=mD0Ye zh+dv(j!C$TPOrwrIux9#4nn*zqr0@8Upy~`D31?JxYr?16_vzO${X(#H++wC!)mHM z2Y>iu^3lmE;%y&YcZ{viJCg+_d|r1#L~mY&8Cx-Nu`)8T0sgz=r|rKM%%>#iWb9~R@9f0D^e?}^ zbgA7+Canure|k>vPX|c=0QRq+i4lW|p|c^qiP2@+wB3d%+VG`Iodu?Fu5ekI<2Cp~ zj%^BdjM)$$vM@DK_*&6vHEjAzsrVSVX}#PyIF*HTfIyI6V0RF#pCE%yi#-5A2WuU7 z<&r&}0!+;Gn5KGp>xH-4Y{+iwZe_Vzck5(*!RD#0F8lkeEZXqws|Ai&>~;41tp1-F zzqs6YPHsJ7tqUKXbB?spc}Jk?t=Fy9t?@1E3)c(PACAQg8})6khe+RDJzPvJY{+!A|LojKw;?cTDoj_qEzRq$ z&bygj3Af4r=-Gf)#W|F~fq(wdDKly>mVu6jsx13Q0*Zx23ps4mT?}h!f*QVjBmQk}fo*A;OWPI5OuE&^!>P5g%SPBx zvL_P-SDy5b=jkNg4&O^h&yrXaW8LC9T$1&T$IXhf%^aW$pvYR zuC}-WTOxt8vgUC)ITotdz?_^NppcNz{3$SMD5&Iy@P%J1aw~c(e9M|0v3Sbt9Q(L) z(-Xl5Xut^hen6HT^vK(vI*GMwz%P=pvLWa}1;z3SL&R=fTV>u@2Fy?l74(rcidzOU z83S+=X3+ZhO(o~sq(m}^bHUWJSh2pAyj9o@O3Bs#Hn=A@LnT@Rp zWrLhBjh?(h%@`A@B(I@$3#zPdf~L%sLJ?e7L3xGA8gu|mk}72xTO*gind4vJ)+0Rd zkQMnLKtp3?`ryb@$baVP{xjU@cQ;_))l+Ghpv_KoFPQR8yq|W!gmwa^^nFCjzql9g zSk_9Yx1*qB1`coNXHhC?SYTgYUvFV+EMzUjYnb`TfGAu~LzdJ4fZQ1V+-nA=w+CK~ zZFeoajs)(VPFf=A zv#5?qxZq1*-UfB5h{w-ZYUb}b9T>t?qz1$KE9-ok-Mfy3XHaxCx2);T(_ajaBzQoL zn3s^hkX*kn`!5rA-2z)YffVlhekDPh-(B8))b#!2Ux@Hb9gTP)87Z=U9s}ZU+_N;J z!ayjbGjM1jfSiYl zk}TmjJ4Y)cQ(I$mLODAVQ)@y)M`LpfS5rbJdPW$VzZyCxV{=m*Lnm%RYYQVsLq`vo F{|DStOY8su literal 15104 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 F{4ak_JcR%N diff --git a/src/assets/book_import_scheme.xsd b/src/assets/book_import_scheme.xsd index 5137892..f9374ca 100644 --- a/src/assets/book_import_scheme.xsd +++ b/src/assets/book_import_scheme.xsd @@ -6,9 +6,8 @@ - + - @@ -21,14 +20,14 @@ - + - + @@ -50,6 +49,16 @@ + + + + + + + + + + @@ -64,5 +73,4 @@ - - \ No newline at end of file + diff --git a/src/database/__init__.py b/src/database/__init__.py index cd6d5e1..6f4dfb0 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -1,13 +1,17 @@ from .manager import * from .book import * +from .book_category import * from .book_category_statistics import * +from .book_category_statistics_overview import * from .member import * from .book_overview import * __all__ = [ *manager.__all__, *book.__all__, + *book_category.__all__, *book_category_statistics.__all__, + *book_category_statistics_overview.__all__, *book_overview.__all__, *member.__all__, ] \ No newline at end of file diff --git a/src/database/book.py b/src/database/book.py index 61c3335..e1adcec 100644 --- a/src/database/book.py +++ b/src/database/book.py @@ -1,37 +1,24 @@ -from typing import Dict, List, Optional -import logging -import time - +from typing import Dict, List from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError from sqlalchemy.orm import joinedload from sqlalchemy import delete from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError from models import Book from database.manager import DatabaseManager - from .author import get_or_create_author from .book_category import get_or_create_categories from .book_category_statistics import update_category_statistics -from utils.config import UserConfig - +import logging logger = logging.getLogger(__name__) - def fetch_all_books() -> List[Book]: - """ - Fetches all books from the database. - - :return: A list of all books in the database. - :raises DatabaseConnectionError: If the connection to the database is interrupted. - :raises DatabaseError: If any other error occurs while fetching books. - """ with DatabaseManager.get_session() as session: try: return session.query(Book) \ .options( - joinedload(Book.author), - joinedload(Book.categories) + joinedload(Book.author), + joinedload(Book.categories) ) \ .all() except SqlAlchemyDatabaseError as e: @@ -41,68 +28,41 @@ def fetch_all_books() -> List[Book]: logger.error(f"An error occurred when fetching all books: {e}") raise DatabaseError("An error occurred when fetching all books") from e +def create_book(book: Dict[str, object], skip_existing: bool = True) -> None: + create_books([book], skip_existing) -def create_book(book: Dict[str, object]) -> None: - """ - Creates a new book in the database. - - :param book: A dictionary containing the book details (title, description, year_published, ISBN, author, and categories). - :raises DuplicateEntryError: If a book with the same ISBN already exists in the database. - :raises DatabaseConnectionError: If the connection to the database is interrupted. - :raises DatabaseError: If any other error occurs while creating the book. - """ - create_books([book]) - - -def create_books(books: List[Dict[str, object]]) -> None: - """ - Creates multiple books in the database. - - :param books: A list of dictionaries, each containing the details of a book. - :raises DuplicateEntryError: If a book with the same ISBN already exists in the database. - :raises DatabaseConnectionError: If the connection to the database is interrupted. - :raises DatabaseError: If any other error occurs while creating the books. - """ +def create_books(books: List[Dict[str, object]], skip_existing: bool = True) -> None: try: with DatabaseManager.get_session() as session: for book in books: logger.debug(f"Attempting to create a new book: {book['title']}") - # Check if the book already exists existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first() if existing_book: - logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.") - continue + if skip_existing: + logger.warning(f"Book with ISBN {book['isbn']} already exists. Skipping.") + continue + else: + logger.error(f"Book with ISBN {book['isbn']} already exists.") + raise DuplicateEntryError(f"Book with ISBN {book['isbn']} already exists.") author = get_or_create_author(session, book["author"]) categories = get_or_create_categories(session, book["categories"]) - # Create the new book new_book = Book( title=book["title"], description=book["description"], year_published=book["year_published"], isbn=book["isbn"], + price=book["price"], + is_damaged=book["is_damaged"], author=author, categories=categories ) session.add(new_book) - - user_config = UserConfig() - if user_config.simulate_slowdown: - logger.debug("Simulating slowdown before updating statistics for 10 seconds") - time.sleep(10) - else: - logger.debug("Performing category statistics update normally") - update_category_statistics(session) - - session.commit() - # logger.info(f"Book {book['title']} successfully created.") - - logger.debug("Committing all changes") session.commit() except IntegrityError as e: logger.warning("Data already exists") @@ -114,41 +74,34 @@ def create_books(books: List[Dict[str, object]]) -> None: logger.error(f"An error occurred when creating the book: {e}") raise DatabaseError("An error occurred when creating the book") from e - def update_book(book: Dict[str, object]) -> None: - """ - Updates an existing book in the database. Reuses existing authors and categories if they exist. - - :param book: A dictionary containing the updated book details, including the book ID. - :raises DatabaseError: If the book is not found in the database. - :raises DuplicateEntryError: If an attempt is made to update the book with duplicate data. - :raises DatabaseConnectionError: If the connection to the database is interrupted. - """ try: with DatabaseManager.get_session() as session: logger.debug(f"Updating book {book['title']}") - # Find the existing book - existing_book = session.query(Book).get(book["id"]) + existing_book = session.query(Book).filter_by(isbn=book["isbn"]).first() + if not existing_book: - logger.warning(f"Book with ID {book['id']} not found") + logger.warning(f"Book with ISBN {book['isbn']} not found") raise DatabaseError("Book not found in the database") - # Get or create the author author = get_or_create_author(session, book["author"]) - - # Get or create the categories categories = get_or_create_categories(session, book["categories"]) + session.commit() - # Update the book details existing_book.title = book["title"] existing_book.description = book["description"] existing_book.year_published = book["year_published"] existing_book.isbn = book["isbn"] + existing_book.price = book["price"] + existing_book.is_damaged = book["is_damaged"] + existing_book.status = book["status"] existing_book.author = author existing_book.categories = categories + update_category_statistics(session, ignore_config=True) session.commit() + logger.info(f"{book['title']} successfully updated.") except IntegrityError as e: logger.warning("Data already exists") @@ -163,9 +116,12 @@ def update_book(book: Dict[str, object]) -> None: def delete_book(book_id: int) -> None: try: with DatabaseManager.get_session() as session: + logger.debug(f"Deleting book id {book_id}") stmt = delete(Book).where(Book.id == book_id) session.execute(stmt) + update_category_statistics(session, ignore_config=True) session.commit() + logger.info(f"Successfully deleted book with id {book_id}") except SqlAlchemyDatabaseError as e: logger.critical("Connection with database interrupted") raise DatabaseConnectionError("Connection with database interrupted") from e @@ -173,4 +129,4 @@ def delete_book(book_id: int) -> None: logger.error(f"An error occurred when updating the book: {e}") raise DatabaseError("An error occurred when updating the book") from e -__all__ = ["create_book", "create_books", "update_book", "fetch_all_books"] +__all__ = ["create_book", "create_books", "update_book", "fetch_all_books", "delete_book"] diff --git a/src/database/book_category.py b/src/database/book_category.py index bc5d436..8d22308 100644 --- a/src/database/book_category.py +++ b/src/database/book_category.py @@ -4,6 +4,9 @@ import logging from models import BookCategory from database.manager import DatabaseManager +from sqlalchemy.orm import Session +from sqlalchemy import func + logger = logging.getLogger(__name__) @@ -36,4 +39,10 @@ def get_or_create_categories(session, category_names: List[str]) -> List[BookCat processed_categories[category_name] = new_category filtered_categories.append(new_category) - return filtered_categories \ No newline at end of file + return filtered_categories + + +def get_total_count(session: Session) -> int: + return session.query(func.count(BookCategory.id)).scalar() + +__all__ = ["get_total_count", "get_or_create_categories"] \ No newline at end of file diff --git a/src/database/book_category_statistics.py b/src/database/book_category_statistics.py index cb93359..16303ff 100644 --- a/src/database/book_category_statistics.py +++ b/src/database/book_category_statistics.py @@ -1,43 +1,56 @@ +from sqlalchemy import func, insert from sqlalchemy.orm import Session -from sqlalchemy import func +from models import BookCategory, BookCategoryStatistics, BookCategoryLink +import logging -from models import BookCategoryStatistics, BookCategoryLink +logger = logging.getLogger(__name__) - -def update_category_statistics(session: Session) -> None: +def update_category_statistics(session: Session, ignore_config: bool = False) -> None: """ Updates category statistics by calculating the count of books in each category. :param session: SQLAlchemy session object. """ - # Calculate the book count for each category using a query + # Fetch book counts per category using a join between book_category and book_category_link category_counts = ( session.query( - BookCategoryLink.book_category_id, + BookCategory.id.label('book_category_id'), func.count(BookCategoryLink.book_id).label('book_count') ) - .group_by(BookCategoryLink.book_category_id) + .join(BookCategoryLink, BookCategoryLink.book_category_id == BookCategory.id, isouter=True) + .group_by(BookCategory.id) .all() ) - # Update or create statistics based on the query results + # Iterate over the results and update or insert the category statistics for category_id, book_count in category_counts: + # Try to get the existing statistics or create a new one if it doesn't exist existing_statistics = ( session.query(BookCategoryStatistics) - .filter_by(book_category_id=category_id) + .filter(BookCategoryStatistics.book_category_id == category_id) .one_or_none() ) if existing_statistics: - # Update the existing count + # If statistics exist, update the count existing_statistics.book_count = book_count + logger.debug(f"Updated category {category_id} with count {book_count}") else: - # Create new statistics for the category + # If statistics don't exist, create a new one new_statistics = BookCategoryStatistics( book_category_id=category_id, book_count=book_count ) session.add(new_statistics) + logger.debug(f"Inserted new statistics for category {category_id} with count {book_count}") + try: + # Commit the transaction + session.commit() + logger.debug("Category statistics updated successfully") + except Exception as e: + # In case of error, rollback the transaction + logger.error(f"An error occurred while updating category statistics: {e}") + session.rollback() __all__ = ["update_category_statistics"] diff --git a/src/database/book_category_statistics_overview.py b/src/database/book_category_statistics_overview.py new file mode 100644 index 0000000..160675a --- /dev/null +++ b/src/database/book_category_statistics_overview.py @@ -0,0 +1,55 @@ +from typing import List, Tuple +import time + +import logging +from sqlalchemy.exc import IntegrityError, SQLAlchemyError, DatabaseError as SqlAlchemyDatabaseError + +from utils.errors.database import DatabaseError, DuplicateEntryError, DatabaseConnectionError +from models import BookCategoryStatisticsOverview +from database.manager import DatabaseManager +from database import get_total_count + +from utils.config import UserConfig + + +logger = logging.getLogger(__name__) + + +def fetch_all_book_category_statistics_overviews() -> List[BookCategoryStatisticsOverview]: + with DatabaseManager.get_session() as session: + try: + return session.query(BookCategoryStatisticsOverview).all() + except SqlAlchemyDatabaseError as e: + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError("Connection with database interrupted") from e + except SQLAlchemyError as e: + logger.error(f"An error occurred when fetching all category overviews: {e}") + raise DatabaseError("An error occurred when fetching all category overviews") from e + + +def fetch_all_book_category_statistics_overviews_with_count() -> Tuple[List[BookCategoryStatisticsOverview], int]: + with DatabaseManager.get_session() as session: + try: + category_list = session.query(BookCategoryStatisticsOverview).all() + + user_config = UserConfig() + + if user_config.simulate_slowdown: + logger.debug("Simulating slowdown after fetching statistics for 10 seconds") + time.sleep(10) + else: + logger.debug("Performing category statistics update normally") + + count = get_total_count(session) + + return (category_list, count) + + except SqlAlchemyDatabaseError as e: + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError("Connection with database interrupted") from e + except SQLAlchemyError as e: + logger.error(f"An error occurred when fetching all category overviews: {e}") + raise DatabaseError("An error occurred when fetching all category overviews") from e + + +__all__ = ["fetch_all_book_category_statistics_overviews", "fetch_all_book_category_statistics_overviews_with_count"] diff --git a/src/database/book_category_statistics_overview_model_overview.py b/src/database/book_category_statistics_overview_model_overview.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/database/book_overview.py b/src/database/book_overview.py index 4cf9b2b..77b31e7 100644 --- a/src/database/book_overview.py +++ b/src/database/book_overview.py @@ -18,8 +18,8 @@ def fetch_all_book_overviews() -> List[BooksOverview]: logger.critical("Connection with database interrupted") raise DatabaseConnectionError("Connection with database interrupted") from e except SQLAlchemyError as e: - logger.error(f"An error occured when fetching all books: {e}") - raise DatabaseError("An error occured when fetching all books") from e + logger.error(f"An error occurred when fetching all books: {e}") + raise DatabaseError("An error occurred when fetching all books") from e __all__ = ["fetch_all_book_overviews"] diff --git a/src/database/manager.py b/src/database/manager.py index b50c0a6..1f9df6e 100644 --- a/src/database/manager.py +++ b/src/database/manager.py @@ -22,27 +22,28 @@ class DatabaseManager(): self.logger = logging.getLogger(__name__) self.logger.info("Reading database config") database_config = DatabaseConfig() + user_config = UserConfig() self.engine = create_engine('mysql+mysqlconnector://%s:%s@%s:%s/%s' % ( database_config.user, database_config.password, database_config.host, database_config.port, database_config.name), - pool_pre_ping=True) - if self.test_connection(): - self.Session = sessionmaker(bind=self.engine) + pool_pre_ping=True, + isolation_level=user_config.transaction_level.value) + self.test_connection() + self.Session = sessionmaker(bind=self.engine) def cleanup(self) -> None: self.logger.debug("Closing connection") self.engine.dispose() - def test_connection(self) -> bool: + def test_connection(self): self.logger.debug("Testing database connection") try: with self.engine.connect() as connection: connection.execute(text("select 1")) self.logger.debug("Database connection successful") - return True except DatabaseError as e: self.logger.critical(f"Database connection failed: {e}") raise DatabaseConnectionError("Database connection failed") from e diff --git a/src/database/member.py b/src/database/member.py index ff100e1..3c159f1 100644 --- a/src/database/member.py +++ b/src/database/member.py @@ -67,7 +67,44 @@ def create_members(members: List[Dict[str, str]]): raise DatabaseError("An error occurred when creating a new member") from e def update_member(member: Dict[str, str]): - pass + try: + with DatabaseManager.get_session() as session: + logger.debug(f"Editing member {member['first_name']} {member['last_name']}") + + existing_member = session.query(Member).get(member["id"]) + + if not existing_member: + logger.warning(f"Member with ID {member['id']} not found") + raise DatabaseError("Member not found in database") + + existing_member.first_name = member["first_name"] + existing_member.last_name = member["last_name"] + existing_member.email = member["email"] + existing_member.phone = member["phone"] + + session.commit() + except IntegrityError as e: + session.rollback() + + if "email" in str(e.orig): + logger.warning("Email is already in use") + raise DuplicateEntryError("Email", "Email is already in use") from e + elif "phone" in str(e.orig): + logger.warning("Phone number is already in use") + raise DuplicateEntryError("Phone number", "Phone number is already in use") from e + else: + logger.error("An error occurred when updating member") + raise DatabaseError("An error occurred when updating member") from e + + except DatabaseError as e: + session.rollback() + logger.critical("Connection with database interrupted") + raise DatabaseConnectionError("Connection with database interrupted") from e + except SQLAlchemyError as e: + session.rollback() + logger.error(f"An error occurred when saving member: {e}") + raise DatabaseError("An error occurred when creating a new member") from e + def delete_member(member_id: int) -> None: try: diff --git a/src/importer/__init__.py b/src/importer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/importer/book/__init__.py b/src/importer/book/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/importer/book/book_importer.py b/src/importer/book/book_importer.py deleted file mode 100644 index 49a0f18..0000000 --- a/src/importer/book/book_importer.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import logging -import time - -from typing import List, Dict - -from xml.etree import ElementTree as ET -from xmlschema import XMLSchema -from sqlalchemy.exc import IntegrityError - -from database.manager import DatabaseManager -from database.utils import get_or_create_categories, get_or_create_author -from utils.errors.import_error.xsd_scheme_not_found import XsdSchemeNotFoundError -from utils.errors.import_error.invalid_contents_error import InvalidContentsError -from utils.errors.import_error.import_error import ImportError -from utils.config import UserConfig -from models import Book, Author, BookCategory - -class BookImporter: - def __init__(self): - # Initialize the logger and schema - self.logger = logging.getLogger(__name__) - try: - self.logger.debug("Opening XSD scheme in ./") - scheme_path = os.path.join(os.path.dirname(__file__), "book_import_scheme.xsd") - self.schema = XMLSchema(scheme_path) - except Exception as e: - self.logger.error("Failed to load XSD scheme") - raise XsdSchemeNotFoundError(f"Failed to load XSD schema: {e}") - - def parse_xml(self, file_path: str) -> List[Dict[str, object]]: - """Parses the XML file and validates it against the XSD schema.""" - try: - tree = ET.parse(file_path) - root = tree.getroot() - - if not self.schema.is_valid(file_path): - raise InvalidContentsError("XML file is not valid according to XSD schema.") - - books = [] - for book_element in root.findall("book"): - title = book_element.find("title").text - year_published = book_element.find("year_published").text - description = book_element.find("description").text - isbn = book_element.find("isbn").text - - # Parse author - author_element = book_element.find("author") - author = { - "first_name": author_element.find("first_name").text, - "last_name": author_element.find("last_name").text - } - - # Parse categories - category_elements = book_element.find("categories").findall("category") - categories = [category_element.text for category_element in category_elements] - - # Create a book dictionary with explicit types - book = { - "title": title, - "description": description, - "year_published": year_published, - "isbn": isbn, - "author": author, - "categories": categories - } - books.append(book) - - return books - except ET.ParseError as e: - raise ImportError(f"Failed to parse XML file: {e}") \ No newline at end of file diff --git a/src/models/__init__.py b/src/models/__init__.py index 490c7b5..45924e2 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -6,8 +6,6 @@ from .book_category_statistics_model import * from .book_category_statistics_overview_model import * from .book_overview_model import * from .member_model import * -from .librarian_model import * -from .loan_model import * __all__ = [ *author_model.__all__, @@ -18,6 +16,4 @@ __all__ = [ *book_category_statistics_overview_model.__all__, *book_overview_model.__all__, *member_model.__all__, - *librarian_model.__all__, - *loan_model.__all__ ] diff --git a/src/models/book_category_model.py b/src/models/book_category_model.py index fd1b54c..fe17396 100644 --- a/src/models/book_category_model.py +++ b/src/models/book_category_model.py @@ -8,13 +8,9 @@ class BookCategory(Base): __table_args__ = (UniqueConstraint('name'),) id = Column(Integer, primary_key=True, autoincrement=True) - parent_category_id = Column(Integer, ForeignKey('book_category.id'), nullable=True) name = Column(String(100), nullable=False) - mature_content = Column(Integer, nullable=False, default=0) last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) - parent_category = relationship('BookCategory', remote_side=[id]) - books = relationship( 'Book', secondary='book_category_link', diff --git a/src/models/book_category_statistics_overview_model.py b/src/models/book_category_statistics_overview_model.py index 518815b..1c4f922 100644 --- a/src/models/book_category_statistics_overview_model.py +++ b/src/models/book_category_statistics_overview_model.py @@ -8,11 +8,12 @@ class BookCategoryStatisticsOverview(Base): __tablename__ = 'book_category_statistics_overview' __table_args__ = {'extend_existing': True} - name = Column(String) - book_count = Column(INTEGER(unsigned=True), default=0) + id = Column(Integer, primary_key=True) + name = Column(String) + book_count = Column(INTEGER(unsigned=True), default=0) def __repr__(self): - return (f"") + return (f"") __all__ = ["BookCategoryStatisticsOverview"] diff --git a/src/models/book_model.py b/src/models/book_model.py index 452943b..0004402 100644 --- a/src/models/book_model.py +++ b/src/models/book_model.py @@ -1,6 +1,6 @@ import enum -from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func +from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func, DECIMAL,Boolean from sqlalchemy.orm import relationship from .base_model import Base @@ -22,6 +22,8 @@ class Book(Base): description = Column(Text, nullable=False) year_published = Column(String(4), nullable=False) isbn = Column(String(13), nullable=False, unique=True) + price = Column(DECIMAL(5, 2), nullable=False, default=0) + is_damaged = Column(Boolean, nullable=False, default=False) status = Column(Enum(BookStatusEnum), nullable=False, default=BookStatusEnum.available) created_at = Column(TIMESTAMP, nullable=False, server_default=func.now()) last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) diff --git a/src/models/book_overview_model.py b/src/models/book_overview_model.py index 635f754..fd6d08e 100644 --- a/src/models/book_overview_model.py +++ b/src/models/book_overview_model.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, TIMESTAMP, Integer, Text, Enum +from sqlalchemy import Column, String, TIMESTAMP, Integer, Text, Enum, DECIMAL, Boolean from .base_model import Base from .book_model import BookStatusEnum @@ -15,12 +15,10 @@ class BooksOverview(Base): categories = Column(Text, nullable=True) year_published = Column(Integer, nullable=True) isbn = Column(String, nullable=True) + price = Column(DECIMAL(5, 2), nullable=False, default=0) + is_damaged = Column(Boolean, nullable=False, default=False) status = Column(Enum(BookStatusEnum), nullable=False) created_at = Column(TIMESTAMP, nullable=False) - borrower_name = Column(String, nullable=True) - member_id = Column(Integer, nullable=True) - librarian_name = Column(String, nullable=True) - librarian_id = Column(Integer, nullable=True) # This prevents accidental updates/deletes as it's a view def __repr__(self): diff --git a/src/models/librarian_model.py b/src/models/librarian_model.py deleted file mode 100644 index 9f108ac..0000000 --- a/src/models/librarian_model.py +++ /dev/null @@ -1,33 +0,0 @@ -import enum - -from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, func -from sqlalchemy.orm import relationship - -from .base_model import Base - - -class LibrarianStatusEnum(enum.Enum): - active = 'active' - inactive = 'inactive' - -class LibrarianRoleEnum(enum.Enum): - staff = 'staff' - admin = 'admin' - - -class Librarian(Base): - __tablename__ = 'librarian' - __table_args__ = (UniqueConstraint('id'),) - - id = Column(Integer, primary_key=True, autoincrement=True) - first_name = Column(String(50), nullable=False) - last_name = Column(String(50), nullable=False) - email = Column(String(100), nullable=False, unique=True) - phone = Column(String(20), nullable=False) - hire_date = Column(TIMESTAMP, nullable=False, server_default=func.now()) - status = Column(Enum(LibrarianStatusEnum), nullable=False, default=LibrarianStatusEnum.active) - role = Column(Enum(LibrarianRoleEnum), nullable=False) - last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) - - -__all__ = ["Librarian", "LibrarianRoleEnum", "LibrarianStatusEnum"] diff --git a/src/models/loan_model.py b/src/models/loan_model.py deleted file mode 100644 index 88526e2..0000000 --- a/src/models/loan_model.py +++ /dev/null @@ -1,39 +0,0 @@ -import enum - -from sqlalchemy import Column, Integer, String, TIMESTAMP, Text, ForeignKey, Enum, UniqueConstraint, Float, func -from sqlalchemy.orm import relationship - -from .base_model import Base -from .book_model import Book -from .member_model import Member -from .librarian_model import Librarian - - -class LoanStatusEnum(enum.Enum): - borrowed = 'borrowed' - returned = 'returned' - overdue = 'overdue' - reserved = 'reserved' - - -class Loan(Base): - __tablename__ = 'loan' - __table_args__ = (UniqueConstraint('id'),) - - id = Column(Integer, primary_key=True, autoincrement=True) - book_id = Column(Integer, ForeignKey('book.id'), nullable=False) - member_id = Column(Integer, ForeignKey('member.id'), nullable=False) - librarian_id = Column(Integer, ForeignKey('librarian.id'), nullable=False) - loan_date = Column(TIMESTAMP, nullable=False, server_default=func.now()) - due_date = Column(TIMESTAMP, nullable=False) - return_date = Column(TIMESTAMP, nullable=True) - status = Column(Enum(LoanStatusEnum), nullable=False, default=LoanStatusEnum.borrowed) - overdue_fee = Column(Float, nullable=True) - last_updated = Column(TIMESTAMP, nullable=False, server_default=func.now()) - - book = relationship('Book', backref='loans') - member = relationship('Member', backref='loans') - librarian = relationship('Librarian', backref='loans') - - -__all__ = ["Loan", "LoanStatusEnum"] diff --git a/src/requirements.txt b/src/requirements.txt index 4cf16d3..2af9c34 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -5,3 +5,4 @@ PySide6_Essentials==6.8.1 python-dotenv==1.0.1 SQLAlchemy==2.0.36 xmlschema==3.4.3 +mysql-connector-python==9.1.0 \ No newline at end of file diff --git a/src/services/book_category_statistics_service.py b/src/services/book_category_statistics_service.py new file mode 100644 index 0000000..5d76014 --- /dev/null +++ b/src/services/book_category_statistics_service.py @@ -0,0 +1,61 @@ +from typing import List +import logging + +import xml.etree.ElementTree as ET +from xml.dom import minidom +from xmlschema import XMLSchema + +from utils.errors import ( + NoExportEntityError, + ExportError, + ExportFileError, + InvalidContentsError, + XsdSchemeNotFoundError, + ImportError, +) +from models import BookCategoryStatisticsOverview +from assets import asset_manager + +from utils.errors import DatabaseConnectionError, DatabaseError + +from database import fetch_all_book_category_statistics_overviews_with_count + +logger = logging.getLogger(__name__) + +def export_to_xml(file_path: str) -> None: + try: + category_list, count = fetch_all_book_category_statistics_overviews_with_count() + + if not category_list: + raise NoExportEntityError("No categories found to export") + + xml = category_statistics_to_xml(category_list, count) + + with open(file_path, "w", encoding="utf-8") as file: + file.write(xml) + except OSError as e: + raise ExportFileError("Failed to save to a file") from e + except DatabaseConnectionError as e: + raise ExportError("An error with database occurred. Try again later") from e + except DatabaseError as e: + raise ExportError("Unknown error occurred") from e + +def category_statistics_to_xml(category_statistics: List[BookCategoryStatisticsOverview], total_count: int): + root = ET.Element("statistics") + + for statistic in category_statistics: + category_element = ET.SubElement(root, "category") + + name_element = ET.SubElement(category_element, "name") + name_element.text = statistic.name + + count_element = ET.SubElement(category_element, "count") + count_element.text = str(statistic.book_count) + + total_count_element = ET.SubElement(root, "total_category_count") + total_count_element.text = str(total_count) + + tree_str = ET.tostring(root, encoding="unicode") + + pretty_xml = minidom.parseString(tree_str).toprettyxml(indent=(" " * 4)) + return pretty_xml \ No newline at end of file diff --git a/src/services/book_service.py b/src/services/book_service.py index da289bf..1b29a7e 100644 --- a/src/services/book_service.py +++ b/src/services/book_service.py @@ -58,6 +58,8 @@ def parse_from_xml(file_path: str) -> List[Dict[str, object]]: year_published = book_element.find("year_published").text description = book_element.find("description").text isbn = book_element.find("isbn").text + price = float(book_element.find("price").text) + is_damaged = bool(book_element.find("is_damaged").text) # Parse author author_element = book_element.find("author") @@ -77,6 +79,8 @@ def parse_from_xml(file_path: str) -> List[Dict[str, object]]: "year_published" : year_published, "isbn" : isbn, "author" : author, + "price": price, + "is_damaged" : is_damaged, "categories" : categories, } books.append(book) @@ -119,6 +123,12 @@ def books_to_xml(books: List[Book]) -> str: isbn_element = ET.SubElement(book_element, "isbn") isbn_element.text = book.isbn + price_element = ET.SubElement(book_element, "price") + price_element.text = str(book.price) + + damaged_element = ET.SubElement(book_element, "is_damaged") + damaged_element.text = str(book.is_damaged).lower() + # Add categories_element = ET.SubElement(book_element, "categories") for category in book.categories: @@ -133,4 +143,4 @@ def books_to_xml(books: List[Book]) -> str: return pretty_xml -__all__ = ["export_to_xml", "parse_books_from_xml"] +__all__ = ["export_to_xml", "parse_from_xml"] diff --git a/src/ui/editor/book_editor.py b/src/ui/editor/book_editor.py index e2f9187..22d4b04 100644 --- a/src/ui/editor/book_editor.py +++ b/src/ui/editor/book_editor.py @@ -1,9 +1,9 @@ -from typing import Dict +from typing import Dict, Callable import logging from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout, QMessageBox + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QComboBox, QFormLayout, QMessageBox, QCheckBox ) from PySide6.QtGui import QRegularExpressionValidator from PySide6.QtCore import QRegularExpression @@ -15,12 +15,14 @@ from utils.errors.database import DatabaseError, DatabaseConnectionError, Duplic class BookEditor(QDialog): - def __init__(self, book: Book = None, parent=None): + def __init__(self, book: Book = None, parent=None, refresh_callback: Callable[[Dict[str, object]], None] = None): super().__init__(parent) self.logger = logging.getLogger(__name__) self.create_layout() + self.refresh_callback = refresh_callback + if book: self.book_id = book.id self.logger.debug(f"Editing book {book.title}") @@ -54,7 +56,6 @@ class BookEditor(QDialog): # Year published field self.year_input = QLineEdit() - # self.year_input.setValidator form_layout.addRow("Year Published:", self.year_input) # ISBN field @@ -64,9 +65,24 @@ class BookEditor(QDialog): self.isbn_input.setValidator(self.isbn_validator) form_layout.addRow("ISBN:", self.isbn_input) + # Categories field self.categories_input = QLineEdit() form_layout.addRow("Categories: ", self.categories_input) + # Damage field + self.damage_input = QCheckBox() + form_layout.addRow("Damage:", self.damage_input) + + # Price field + self.price_input = QLineEdit() + self.price_input.setValidator(QRegularExpressionValidator(QRegularExpression(r"^\d+(\.\d{1,2})?$"))) + form_layout.addRow("Price:", self.price_input) + + # Status field + self.status_input = QComboBox() + self.status_input.addItems([status.value for status in BookStatusEnum]) + form_layout.addRow("Status:", self.status_input) + layout.addLayout(form_layout) # Buttons @@ -94,21 +110,28 @@ class BookEditor(QDialog): all_categories = ", ".join(category.name for category in book.categories) self.categories_input.setText(all_categories) + self.damage_input.setChecked(book.is_damaged) + self.price_input.setText(str(book.price)) + self.status_input.setCurrentText(book.status.value) + def save_book(self): try: book_object = self.parse_inputs() if self.create_new: - create_book(book_object) + create_book(book_object, skip_existing=False) else: book_object["id"] = self.book_id update_book(book_object) - + QMessageBox.information(None, "Success", "Book updated successfully", QMessageBox.StandardButton.Ok) - + + if self.refresh_callback: + self.refresh_callback(book_object) + self.accept() except ValueError as e: QMessageBox.critical(None, @@ -168,6 +191,21 @@ class BookEditor(QDialog): if not categories: raise ValueError("At least one category must be specified.") + # Damage validation + damage = self.damage_input.isChecked() + + # Price validation + price = self.price_input.text().strip() + try: + price = float(price) + if price < 0: + raise ValueError("Price must be a non-negative number.") + except ValueError: + raise ValueError("Price must be a valid decimal number.") + + # Status validation + status = self.status_input.currentText() + # Map parsed values to dictionary format for saving return { "title": title, @@ -178,7 +216,10 @@ class BookEditor(QDialog): "description": description, "year_published": year_published, "isbn": isbn, - "categories": categories + "categories": categories, + "is_damaged": damage, + "price": price, + "status": status } __all__ = ["BookEditor"] diff --git a/src/ui/editor/member_editor.py b/src/ui/editor/member_editor.py index 678fbd0..c038c76 100644 --- a/src/ui/editor/member_editor.py +++ b/src/ui/editor/member_editor.py @@ -1,6 +1,6 @@ import logging import re -from typing import Dict +from typing import Dict, Callable from PySide6.QtGui import QGuiApplication, QAction from PySide6.QtQml import QQmlApplicationEngine @@ -15,16 +15,18 @@ from utils.errors.database import DatabaseError, DatabaseConnectionError, Duplic class MemberEditor(QDialog): - def __init__(self, member: Member = None): + def __init__(self, member: Member = None, refresh_callback: Callable[[Dict[str, object]], None] = None): super().__init__() self.logger = logging.getLogger(__name__) self.create_layout() + self.refresh_callback = refresh_callback + if member: self.member_id = member.id self.logger.debug(f"Editing member {member.first_name} {member.last_name}") - self.fill_with_existing_data() + self.fill_with_existing_data(member) self.create_new = False else: self.logger.debug("Editing a new member") @@ -90,13 +92,16 @@ class MemberEditor(QDialog): QMessageBox.StandardButton.NoButton) else: member_object["id"] = self.member_id - update_member(book_object) + update_member(member_object) QMessageBox.information(None, "Success", "Member updated successfully", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) + if self.refresh_callback: + self.refresh_callback(member_object) + self.accept() except ValueError as e: QMessageBox.critical(None, @@ -144,8 +149,7 @@ class MemberEditor(QDialog): "first_name": first_name, "last_name": last_name, "email": email, - "phone_number": phone_number + "phone": phone_number } - __all__ = ["MemberEditor"] diff --git a/src/ui/main_tabs/__init__.py b/src/ui/main_tabs/__init__.py index 353979f..40c3fb2 100644 --- a/src/ui/main_tabs/__init__.py +++ b/src/ui/main_tabs/__init__.py @@ -1,2 +1,3 @@ from .book_overview_list import BookOverviewList -from .member_list import MemberList \ No newline at end of file +from .member_list import MemberList +from .category_statistics_overview_list import BookCategoryStatisticsOverview \ No newline at end of file diff --git a/src/ui/main_tabs/book_overview_list/book_card.py b/src/ui/main_tabs/book_overview_list/book_card.py index bcd89ee..7b11ac6 100644 --- a/src/ui/main_tabs/book_overview_list/book_card.py +++ b/src/ui/main_tabs/book_overview_list/book_card.py @@ -1,15 +1,10 @@ -from PySide6.QtGui import QGuiApplication, QAction, Qt -from PySide6.QtQml import QQmlApplicationEngine -from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox -from PySide6.QtCore import qDebug - -from ui.editor import BookEditor - +from PySide6.QtGui import QAction, Qt +from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox, QDialog from models import BooksOverview, Book, BookStatusEnum - +from ui.editor import BookEditor from database.manager import DatabaseManager - -from sqlalchemy import delete +from database import delete_book +from utils.errors import DatabaseConnectionError, DatabaseError STATUS_TO_COLOR_MAP = { BookStatusEnum.available: "#3c702e", @@ -24,10 +19,8 @@ class BookCard(QWidget): self.book_overview = book_overview - self.setAttribute(Qt.WidgetAttribute.WA_Hover, - True) # Enable hover events - # Enable styling for background - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) # Enable hover events + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) # Enable styling for background # Set initial stylesheet with hover behavior self.setStyleSheet(""" @@ -39,106 +32,104 @@ class BookCard(QWidget): # Layout setup layout = QHBoxLayout(self) layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize) - self.setSizePolicy(QSizePolicy.Policy.Preferred, - QSizePolicy.Policy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) + # Initialize UI components + self.title_label = QLabel() + self.author_label = QLabel() + self.isbn_label = QLabel() + self.status_label = QLabel() + self.price_label = QLabel() + self.is_damaged_label = QLabel() + # Left-side content left_side = QVBoxLayout() layout.addLayout(left_side) - title_label = QLabel(book_overview.title) - title_label.setStyleSheet("font-size: 20px; font-weight: bold;") - author_label = QLabel("By: " + book_overview.author_name) - isbn_label = QLabel("ISBN: " + (book_overview.isbn or "Not Available")) - left_side.addWidget(title_label) - left_side.addWidget(author_label) - left_side.addWidget(isbn_label) + left_side.addWidget(self.title_label) + left_side.addWidget(self.author_label) + left_side.addWidget(self.isbn_label) # Right-side content right_side = QVBoxLayout() layout.addLayout(right_side) - - status_label = QLabel(str(book_overview.status.value.capitalize())) - status_label.setStyleSheet(f"color: { - STATUS_TO_COLOR_MAP[book_overview.status]}; font-size: 20px; font-weight: bold;") - status_label.setAlignment(Qt.AlignmentFlag.AlignRight) - - right_side.addWidget(status_label) - - if book_overview.librarian_name and book_overview.borrower_name: - borrower_label = QLabel("Borrowed: " + book_overview.borrower_name) - borrower_label.setAlignment(Qt.AlignmentFlag.AlignRight) - - librarian_label = QLabel("By: " + book_overview.librarian_name) - librarian_label.setAlignment(Qt.AlignmentFlag.AlignRight) - - right_side.addWidget(borrower_label) - right_side.addWidget(librarian_label) + right_side.addWidget(self.status_label) + right_side.addWidget(self.price_label) + right_side.addWidget(self.is_damaged_label) self.setLayout(layout) + self.update_display() - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.contextMenuEvent(event) - else: - super().mousePressEvent(event) + def update_display(self): + """Refreshes the display of the book card based on its current data.""" + self.title_label.setText(self.book_overview.title) + self.title_label.setStyleSheet("font-size: 20px; font-weight: bold;") + + self.author_label.setText("By: " + self.book_overview.author_name) + self.isbn_label.setText("ISBN: " + (self.book_overview.isbn or "Not Available")) + + self.status_label.setText(str(self.book_overview.status.value.capitalize())) + self.status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[self.book_overview.status]}; font-size: 20px; font-weight: bold;") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.price_label.setText("Price: " + str(self.book_overview.price)) + self.price_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.is_damaged_label.setText("Damaged: " + str(self.book_overview.is_damaged)) + self.is_damaged_label.setAlignment(Qt.AlignmentFlag.AlignRight) def contextMenuEvent(self, event): context_menu = QMenu(self) action_edit_book = context_menu.addAction("Edit Book") - action_edit_author = context_menu.addAction("Edit Author") - action_mark_returned = context_menu.addAction("Mark as Returned") - action_remove_reservation = context_menu.addAction("Remove reservation") - context_menu.addSeparator() delete_book_action = context_menu.addAction("Delete Book") delete_book_action.triggered.connect(self.delete_book) - if self.book_overview.status != BookStatusEnum.borrowed: - action_mark_returned.setVisible(False) - - if self.book_overview.status != BookStatusEnum.reserved: - action_remove_reservation.setVisible(False) - action = context_menu.exec_(self.mapToGlobal(event.pos())) if action == action_edit_book: - with DatabaseManager.get_session() as session: - book_id = self.book_overview.id + self.open_editor() - book = session.query(Book).filter( - Book.id == book_id).one_or_none() + def open_editor(self): + """Opens the BookEditor and updates the card if changes are made.""" + with DatabaseManager.get_session() as session: + book_id = self.book_overview.id + book = session.query(Book).filter(Book.id == book_id).one_or_none() - if book: - BookEditor(book).exec() - else: - QMessageBox.critical(self, - "Error", - "The book you requested could not be found. Try again later", - QMessageBox.StandardButton.Ok, - QMessageBox.StandardButton.NoButton) - elif action == action_edit_author: - print("Edit Author selected") - elif action == action_mark_returned: - print("Mark as Returned selected") - elif action == action_remove_reservation: - print("Remove reservation selected") + if book: + editor = BookEditor(book) + if editor.exec() == QDialog.DialogCode.Accepted: + updated_data = editor.parse_inputs() + self.refresh(updated_data) + else: + QMessageBox.critical(self, + "Error", + "The book you requested could not be found. Try again later", + QMessageBox.StandardButton.Ok) + + def refresh(self, updated_data): + """Updates the card's data and refreshes the display.""" + self.book_overview.title = updated_data["title"] + self.book_overview.author_name = f"{updated_data['author']['first_name']} {updated_data['author']['last_name']}" + self.book_overview.isbn = updated_data["isbn"] + self.book_overview.status = BookStatusEnum(updated_data["status"]) + self.book_overview.price = updated_data["price"] + self.book_overview.is_damaged = updated_data["is_damaged"] + self.update_display() def delete_book(self): if not self.make_sure(): return - - with DatabaseManager.get_session() as session: - try: - stmt = delete(Book).where(Book.id == self.book_overview.id) - session.execute(stmt) - session.commit() - self.setVisible(False) - except Exception as e: - session.rollback - print(e) + + try: + delete_book(self.book_overview.id) + self.setVisible(False) + except DatabaseConnectionError as e: + QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok) + except DatabaseError as e: + QMessageBox.critical(None, "Failed", f"An error occurred when deleting book: {e}", QMessageBox.StandardButton.Ok) def make_sure(self) -> bool: are_you_sure_box = QMessageBox() @@ -154,5 +145,4 @@ class BookCard(QWidget): # Handle the response return response == QMessageBox.Yes - __all__ = ["BookCard"] diff --git a/src/ui/main_tabs/book_overview_list/overview_list.py b/src/ui/main_tabs/book_overview_list/overview_list.py index 03c82ca..a35e685 100644 --- a/src/ui/main_tabs/book_overview_list/overview_list.py +++ b/src/ui/main_tabs/book_overview_list/overview_list.py @@ -61,10 +61,6 @@ class BookOverviewList(QWidget): register_member_button.clicked.connect(self.register_member) button_layout.addWidget(register_member_button) - add_borrow_record_button = QPushButton("Add Borrow Record") - add_borrow_record_button.clicked.connect(self.add_borrow_record) - button_layout.addWidget(add_borrow_record_button) - main_layout.addLayout(button_layout) def filter_books(self, text): diff --git a/src/ui/main_tabs/category_statistics_overview_list/__init__.py b/src/ui/main_tabs/category_statistics_overview_list/__init__.py index e69de29..d6c24c7 100644 --- a/src/ui/main_tabs/category_statistics_overview_list/__init__.py +++ b/src/ui/main_tabs/category_statistics_overview_list/__init__.py @@ -0,0 +1,7 @@ +from .category_overview_list import * +from .category_overview_card import * + +__all__ = [ + *category_overview_list.__all__, + *category_overview_card.__all__ +] \ No newline at end of file diff --git a/src/ui/main_tabs/category_statistics_overview_list/category_overview_card.py b/src/ui/main_tabs/category_statistics_overview_list/category_overview_card.py index 54088db..b5f5991 100644 --- a/src/ui/main_tabs/category_statistics_overview_list/category_overview_card.py +++ b/src/ui/main_tabs/category_statistics_overview_list/category_overview_card.py @@ -47,8 +47,8 @@ class BookCategoryStatisticsOverviewCard(QWidget): right_side = QVBoxLayout() layout.addLayout(right_side) - status_label = QLabel(book_category_statistics_overview.book_count) - status_label.setStyleSheet(f"font-size: 20px; font-weight: bold;") + status_label = QLabel(str(book_category_statistics_overview.book_count)) + status_label.setStyleSheet("font-size: 20px; font-weight: bold;") status_label.setAlignment(Qt.AlignmentFlag.AlignRight) right_side.addWidget(status_label) diff --git a/src/ui/main_tabs/category_statistics_overview_list/category_overview_list.py b/src/ui/main_tabs/category_statistics_overview_list/category_overview_list.py index 10b8e83..392dc68 100644 --- a/src/ui/main_tabs/category_statistics_overview_list/category_overview_list.py +++ b/src/ui/main_tabs/category_statistics_overview_list/category_overview_list.py @@ -5,14 +5,11 @@ from PySide6.QtWidgets import ( ) from PySide6.QtCore import Qt -from .book_card import BookCard -from models import BooksOverview +from .category_overview_card import BookCategoryStatisticsOverviewCard +from models import BookCategoryStatisticsOverview from database.manager import DatabaseManager -from database.book_overview import fetch_all_book_overviews - -from ui.editor import MemberEditor - +from database import fetch_all_book_category_statistics_overviews class BookCategoryStatisticsOverview(QWidget): def __init__(self, parent = None): @@ -47,25 +44,21 @@ class BookCategoryStatisticsOverview(QWidget): # Align the cards to the top self.scroll_layout.setAlignment(Qt.AlignTop) - self.books = [] - self.book_cards = [] + self.category_overviews = [] + self.category_overview_cards = [] self.redraw_cards() self.scroll_widget.setLayout(self.scroll_layout) self.scroll_area.setWidget(self.scroll_widget) main_layout.addWidget(self.scroll_area) - main_layout.addLayout(button_layout) - def filter_categories(self, text): """Filter the cards based on the search input.""" - for card, book in zip(self.book_cards, self.books): + for card, category in zip(self.category_overview_cards, self.category_overviews): - title_contains_text = text.lower() in book.title.lower() - author_name_contains_text = text.lower() in book.author_name.lower() - isbn_contains_text = text.lower() in book.isbn + name_contains_text = text.lower() in category.name.lower() - card.setVisible(title_contains_text or author_name_contains_text or isbn_contains_text) + card.setVisible(name_contains_text) def clear_layout(self, layout): @@ -82,15 +75,15 @@ class BookCategoryStatisticsOverview(QWidget): def redraw_cards(self): self.clear_layout(self.scroll_layout) - self.book_cards = [] + self.category_overview_cards = [] - self.books = fetch_all_book_overviews() + self.category_overviews = fetch_all_book_category_statistics_overviews() - for book in self.books: - card = BookCard(book) + for category in self.category_overviews: + card = BookCategoryStatisticsOverviewCard(category) self.scroll_layout.addWidget(card) - self.book_cards.append(card) + self.category_overview_cards.append(card) __all__ = ["BookCategoryStatisticsOverview"] diff --git a/src/ui/main_tabs/member_list/member_card.py b/src/ui/main_tabs/member_list/member_card.py index 60eb3f9..f0d8126 100644 --- a/src/ui/main_tabs/member_list/member_card.py +++ b/src/ui/main_tabs/member_list/member_card.py @@ -2,13 +2,10 @@ from PySide6.QtWidgets import ( QHBoxLayout, QVBoxLayout, QLabel, QWidget, QMenu, QSizePolicy, QLayout, QMessageBox ) from PySide6.QtGui import Qt -from PySide6.QtCore import qDebug - from models import Member, MemberStatusEnum from database.manager import DatabaseManager from database import delete_member -from sqlalchemy import delete - +from ui.editor import MemberEditor from utils.errors import DatabaseConnectionError, DatabaseError STATUS_TO_COLOR_MAP = { @@ -41,32 +38,43 @@ class MemberCard(QWidget): layout.setContentsMargins(10, 10, 10, 10) layout.setSpacing(10) + # Initialize UI components + self.name_label = QLabel() + self.email_label = QLabel() + self.phone_label = QLabel() + self.status_label = QLabel() + self.register_date_label = QLabel() + # Left-side content left_side = QVBoxLayout() layout.addLayout(left_side) - name_label = QLabel(f"{member.first_name} {member.last_name}") - name_label.setStyleSheet("font-size: 20px; font-weight: bold;") - email_label = QLabel(f"Email: {member.email}") - phone_label = QLabel(f"Phone: {member.phone or 'Not Available'}") - left_side.addWidget(name_label) - left_side.addWidget(email_label) - left_side.addWidget(phone_label) + left_side.addWidget(self.name_label) + left_side.addWidget(self.email_label) + left_side.addWidget(self.phone_label) # Right-side content right_side = QVBoxLayout() layout.addLayout(right_side) - - status_label = QLabel(str(member.status.value.capitalize())) - status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[member.status]}; font-size: 20px; font-weight: bold;") - status_label.setAlignment(Qt.AlignmentFlag.AlignRight) - - register_date_label = QLabel(f"Registered: {member.register_date}") - register_date_label.setAlignment(Qt.AlignmentFlag.AlignRight) - - right_side.addWidget(status_label) - right_side.addWidget(register_date_label) + right_side.addWidget(self.status_label) + right_side.addWidget(self.register_date_label) self.setLayout(layout) + self.update_display() + + def update_display(self): + """Refreshes the display of the member card based on its current data.""" + self.name_label.setText(f"{self.member.first_name} {self.member.last_name}") + self.name_label.setStyleSheet("font-size: 20px; font-weight: bold;") + + self.email_label.setText(f"Email: {self.member.email}") + self.phone_label.setText(f"Phone: {self.member.phone or 'Not Available'}") + + self.status_label.setText(str(self.member.status.value.capitalize())) + self.status_label.setStyleSheet(f"color: {STATUS_TO_COLOR_MAP[self.member.status]}; font-size: 20px; font-weight: bold;") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + self.register_date_label.setText(f"Registered: {self.member.register_date}") + self.register_date_label.setAlignment(Qt.AlignmentFlag.AlignRight) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: @@ -92,7 +100,8 @@ class MemberCard(QWidget): action = context_menu.exec_(self.mapToGlobal(event.pos())) if action == action_edit_member: - print("Edit Member selected") # Implement editor logic here + editor = MemberEditor(self.member, refresh_callback=self.refresh) + editor.exec() elif action == action_deactivate_member: self.update_member_status(MemberStatusEnum.inactive) elif action == action_activate_member: @@ -106,10 +115,9 @@ class MemberCard(QWidget): delete_member(self.member.id) self.setVisible(False) except DatabaseConnectionError as e: - QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) + QMessageBox.critical(None, "Failed", "Connection with database failed", QMessageBox.StandardButton.Ok) except DatabaseError as e: - QMessageBox.critical(None, "Failed", f"An error occured when deleting member: {e}", QMessageBox.StandardButton.Ok, QMessageBox.StandardButton.NoButton) - + QMessageBox.critical(None, "Failed", f"An error occurred when deleting member: {e}", QMessageBox.StandardButton.Ok) def update_member_status(self, new_status): with DatabaseManager.get_session() as session: @@ -120,17 +128,20 @@ class MemberCard(QWidget): session.commit() QMessageBox.information(self, "Status Updated", f"Member status updated to {new_status.value.capitalize()}.") self.member.status = new_status - self.update_status_label() + self.update_display() else: QMessageBox.critical(self, "Error", "The member you requested could not be found.", QMessageBox.StandardButton.Ok) except Exception as e: session.rollback() print(e) - def update_status_label(self): - self.findChild(QLabel, self.member.status.value).setStyleSheet( - f"color: {STATUS_TO_COLOR_MAP[self.member.status]}; font-size: 20px; font-weight: bold;" - ) + def refresh(self, updated_data): + """Updates the card's data and refreshes the display.""" + self.member.first_name = updated_data["first_name"] + self.member.last_name = updated_data["last_name"] + self.member.email = updated_data["email"] + self.member.phone = updated_data["phone"] + self.update_display() def make_sure(self) -> bool: are_you_sure_box = QMessageBox() @@ -143,5 +154,4 @@ class MemberCard(QWidget): response = are_you_sure_box.exec() return response == QMessageBox.Yes - -__all__ = ["MemberCard"] \ No newline at end of file +__all__ = ["MemberCard"] diff --git a/src/ui/menu_bar.py b/src/ui/menu_bar.py index e2e3835..c972317 100644 --- a/src/ui/menu_bar.py +++ b/src/ui/menu_bar.py @@ -7,7 +7,7 @@ from ui.import_preview import PreviewDialog from ui.editor import BookEditor, MemberEditor from utils.errors import ExportError, ExportFileError, InvalidContentsError -from services import book_service, book_overview_service +from services import book_service, book_overview_service, book_category_statistics_service class MenuBar(QMenuBar): @@ -50,10 +50,6 @@ class MenuBar(QMenuBar): import_books_action.triggered.connect(self.import_books) import_submenu.addAction(import_books_action) - import_members_action = QAction("Import members", self) - import_members_action.triggered.connect(self.import_members) - import_submenu.addAction(import_members_action) - # Export submenu export_submenu = QMenu("Export", self) file_menu.addMenu(export_submenu) @@ -62,13 +58,9 @@ class MenuBar(QMenuBar): export_books_action.triggered.connect(self.export_books) export_submenu.addAction(export_books_action) - export_overview_action = QAction("Export overview", self) - export_overview_action.triggered.connect(self.export_overviews) - export_submenu.addAction(export_overview_action) - - export_members_action = QAction("Export members", self) - export_members_action.triggered.connect(self.export_members) - export_submenu.addAction(export_members_action) + export_category_statistics = QAction("Export category statistics", self) + export_category_statistics.triggered.connect(self.export_category_statistics) + export_submenu.addAction(export_category_statistics) file_menu.addSeparator() @@ -109,18 +101,12 @@ class MenuBar(QMenuBar): def import_books(self): self.import_data("Book", None, book_service) - def import_members(self): - # self.import_data("Member", memb) - pass - def export_books(self): self.export_data("Book", book_service) - def export_overviews(self): - self.export_data("Book overview", book_overview_service) - def export_members(self): - pass + def export_category_statistics(self): + self.export_data("Category statistics", book_category_statistics_service) def about(self): QMessageBox.information( diff --git a/src/ui/settings.py b/src/ui/settings.py index b4dc50d..032f840 100644 --- a/src/ui/settings.py +++ b/src/ui/settings.py @@ -24,14 +24,8 @@ class SettingsDialog(QDialog): self.data_mode_label = QtWidgets.QLabel(UserConfig.get_friendly_name("transaction_level") + ":") data_mode_layout.addWidget(self.data_mode_label) - self.data_mode_dropdown = QtWidgets.QComboBox() - for tl in TransactionLevel: - self.data_mode_dropdown.addItem(tl.name.capitalize(), tl) - self.data_mode_dropdown.setCurrentIndex( - list(TransactionLevel).index(self.user_config.transaction_level) - ) - - data_mode_layout.addWidget(self.data_mode_dropdown) + self.data_mode_selected = QtWidgets.QLabel(self.user_config.transaction_level.name.capitalize()) + data_mode_layout.addWidget(self.data_mode_selected) # Slowdown simulation @@ -48,17 +42,8 @@ class SettingsDialog(QDialog): layout.addLayout(data_mode_layout) layout.addLayout(self.slowdown_layout) - # Set the currently selected mode to the mode in UserConfig - config = UserConfig() - - # Transaction level - current_level = config.transaction_level - index = self.data_mode_dropdown.findData(current_level) - if index != -1: - self.data_mode_dropdown.setCurrentIndex(index) - # Slowdown simulation - simulate_slowdown = config.simulate_slowdown + simulate_slowdown = self.user_config.simulate_slowdown self.slowdown_checkbox.setChecked(simulate_slowdown) # Buttons @@ -75,15 +60,8 @@ class SettingsDialog(QDialog): layout.addLayout(button_layout) def save_settings(self): - data_mode = self.data_mode_dropdown.currentData() simulate_slowdown = self.slowdown_checkbox.isChecked() - try: - self.logger.debug("Saving user configuration") - config = UserConfig() - config.transaction_level = data_mode - config.simulate_slowdown = simulate_slowdown - self.accept() - except TypeError as e: - self.logger.error("Invalid user configuration found") - QMessageBox.critical(None, "Invalid config detected", "Double check your configuration", QMessageBox.StandardButton.Ok, QMessageBox.StandardBUttons.NoButton) - + self.logger.debug("Saving user configuration") + config = UserConfig() + config.simulate_slowdown = simulate_slowdown + self.accept() diff --git a/src/ui/window.py b/src/ui/window.py index de256e8..88a6dce 100644 --- a/src/ui/window.py +++ b/src/ui/window.py @@ -1,7 +1,7 @@ from PySide6.QtGui import QGuiApplication, QIcon from PySide6.QtWidgets import QMainWindow, QApplication, QTabWidget -from ui.main_tabs import BookOverviewList, MemberList +from ui.main_tabs import BookOverviewList, MemberList, BookCategoryStatisticsOverview from ui.menu_bar import MenuBar @@ -24,8 +24,10 @@ class LibraryWindow(QMainWindow): self.dashboard = BookOverviewList(self) self.member_list = MemberList() + self.category_statistics_overview_list = BookCategoryStatisticsOverview() central_widget.addTab(self.dashboard, "Dashboard") central_widget.addTab(self.member_list, "Members") + central_widget.addTab(self.category_statistics_overview_list, "Category stats") def center_window(self): # Get the screen geometry @@ -46,6 +48,7 @@ class LibraryWindow(QMainWindow): def refresh_book_cards(self): self.dashboard.redraw_cards() + self.category_statistics_overview_list.redraw_cards() def refresh_member_cards(self): self.member_list.redraw_cards() \ No newline at end of file diff --git a/src/utils/config.py b/src/utils/config.py index da9d05c..e6540e5 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -60,11 +60,12 @@ class DatabaseConfig(): class TransactionLevel(enum.Enum): - insecure = "READ UNCOMMITTED" + insecure = "READ COMMITTED" secure = "SERIALIZABLE" class UserConfig: + _instance = None _metadata = { @@ -78,26 +79,23 @@ class UserConfig: return cls._instance def __init__(self): - if not hasattr(self, "_transaction_level"): - self._transaction_level = TransactionLevel.insecure - if not hasattr(self, "_simulate_slowdown"): - self._simulate_slowdown = False if not hasattr(self, "logger"): self.logger = logging.getLogger(__name__) + if not hasattr(self, "_transaction_level"): + env_level = os.getenv("TRANSACTION_LEVEL", "INSECURE").upper() + if env_level == "SECURE": + self.logger.debug("Running in SECURE mode") + self._transaction_level = TransactionLevel.secure + else: + self.logger.debug("Running in INSECURE mode") + self._transaction_level = TransactionLevel.insecure + if not hasattr(self, "_simulate_slowdown"): + self._simulate_slowdown = False @property def transaction_level(self) -> TransactionLevel: return self._transaction_level - @transaction_level.setter - def transaction_level(self, value: Any): - if not isinstance(value, TransactionLevel): - raise TypeError( - f"Invalid value for 'transaction_level'. Must be a TransactionLevel enum, got {type(value).__name__}." - ) - self.logger.debug(f"Transaction isolation level set to: {value}") - self._transaction_level = value - @property def simulate_slowdown(self) -> bool: return self._simulate_slowdown diff --git a/src/utils/errors/database.py b/src/utils/errors/database.py index 6a7b51a..35c82f5 100644 --- a/src/utils/errors/database.py +++ b/src/utils/errors/database.py @@ -20,7 +20,7 @@ class DatabaseConnectionError(DatabaseError): class DuplicateEntryError(DatabaseError): - def __init__(self, duplicate_entry_name: str, message: str): + def __init__(self, duplicate_entry_name: str, message: str = ""): super().__init__(message) self.duplicate_entry_name = duplicate_entry_name self.message = message diff --git a/src/utils/setup_logger.py b/src/utils/setup_logger.py index 078824f..e7b7268 100644 --- a/src/utils/setup_logger.py +++ b/src/utils/setup_logger.py @@ -1,12 +1,24 @@ import sys +import os import logging def setup_logger(): logger = logging.getLogger() - logger.setLevel(logging.DEBUG) + + verbosity = os.getenv("VERBOSITY", "DEBUG").upper() + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + log_level = level_map.get(verbosity, logging.DEBUG) # Default to DEBUG if invalid + + logger.setLevel(log_level) handler = logging.StreamHandler(sys.stdout) - handler.setLevel(logging.DEBUG) + handler.setLevel(log_level) formatter = logging.Formatter("[%(levelname)s] - %(name)s:%(lineno)d - %(message)s") handler.setFormatter(formatter)