From 438334bd92dcc14755a78bd6a28d57c63fc301d9 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 01:53:44 +0100 Subject: [PATCH] snapshot: preserve backup UX, remote target setup, and docs updates --- .gitignore | 2 + Makefile | 9 +- README.md | 21 + backend/Dockerfile | 2 +- backend/config/settings.py | 2 + backend/locale/en/LC_MESSAGES/django.mo | Bin 23041 -> 27944 bytes backend/locale/en/LC_MESSAGES/django.po | 1018 +++++++++++------ backend/workflows/backup_ops.py | 325 ++++++ ...owconfig_remote_backup_enabled_and_more.py | 38 + backend/workflows/models.py | 16 + backend/workflows/services.py | 67 +- .../static/workflows/css/admin_tools.css | 2 + .../static/workflows/css/app_chrome.css | 76 ++ .../static/workflows/js/action_progress.js | 35 + .../static/workflows/js/confirm_dialog.js | 6 + .../templates/workflows/backup_recovery.html | 114 ++ .../templates/workflows/base_shell.html | 12 + .../workflows/developer_handbook.html | 27 +- .../workflows/templates/workflows/home.html | 5 + .../workflows/integrations_setup.html | 70 +- .../templates/workflows/project_wiki.html | 8 +- backend/workflows/urls.py | 5 + backend/workflows/views.py | 134 ++- scripts/backup_create.sh | 36 + scripts/backup_restore.sh | 35 + scripts/backup_verify.sh | 55 + 26 files changed, 1737 insertions(+), 383 deletions(-) create mode 100644 backend/workflows/backup_ops.py create mode 100644 backend/workflows/migrations/0035_workflowconfig_remote_backup_enabled_and_more.py create mode 100644 backend/workflows/static/workflows/js/action_progress.js create mode 100644 backend/workflows/templates/workflows/backup_recovery.html create mode 100644 scripts/backup_create.sh create mode 100644 scripts/backup_restore.sh create mode 100644 scripts/backup_verify.sh diff --git a/.gitignore b/.gitignore index 291344a..ce0ff02 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ staticfiles/ .pytest_cache/ .mypy_cache/ .DS_Store +backups/ +backend/backups/ diff --git a/Makefile b/Makefile index 269a172..878034a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ COMPOSE ?= docker compose -.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de +.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de backup-create backup-verify i18n-extract-en: $(COMPOSE) exec -T web django-admin makemessages -l en @@ -14,3 +14,10 @@ i18n-compile: i18n-update-en: i18n-extract-en i18n-compile i18n-update-de: i18n-extract-de i18n-compile + +backup-create: + ./scripts/backup_create.sh + +backup-verify: + @if [ -z "$(BACKUP_DIR)" ]; then echo "Usage: make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS"; exit 1; fi + ./scripts/backup_verify.sh "$(BACKUP_DIR)" diff --git a/README.md b/README.md index 5fc601d..8730ed8 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,24 @@ Run a real workflow verification (onboarding + offboarding), including PDF check - `docker compose exec -T web python manage.py run_staging_e2e_check --email-check mailhog --mailhog-api-url http://mailhog:8025/api/v2/messages` - Skip Nextcloud existence checks: - `docker compose exec -T web python manage.py run_staging_e2e_check --skip-nextcloud` + +## Backup and restore +Use the repo-level scripts so database and media backups stay consistent. + +- Create a backup bundle: + - `make backup-create` +- Verify a backup non-destructively: + - `make backup-verify BACKUP_DIR=backend/backups/backup_YYYYmmdd_HHMMSS` +- Real restore: + - `./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS` + +What is included: +- PostgreSQL custom-format dump: `db.dump` +- media archive: `media.tar.gz` +- metadata file and SHA256 checksums +- default storage path: `backend/backups/` + +Verification behavior: +- restores the dump into a temporary verification database +- extracts media into a temporary directory +- checks that the restored DB and media structure are readable diff --git a/backend/Dockerfile b/backend/Dockerfile index 9091816..c9f8682 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential netcat-openbsd gettext \ + && apt-get install -y --no-install-recommends build-essential netcat-openbsd gettext postgresql-client \ && rm -rf /var/lib/apt/lists/* RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app diff --git a/backend/config/settings.py b/backend/config/settings.py index 45a9181..4e4d7b2 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -140,6 +140,8 @@ PDF_TEMPLATES_DIR = MEDIA_ROOT / 'templates' PDF_TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) EMAIL_TEXT_DIR = MEDIA_ROOT / 'email_text' EMAIL_TEXT_DIR.mkdir(parents=True, exist_ok=True) +BACKUP_OUTPUT_DIR = BASE_DIR / 'backups' +BACKUP_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) ONBOARDING_SHARED_PDF_LINK = os.getenv('ONBOARDING_SHARED_PDF_LINK', '') SMTP_TIMEOUT_SECONDS = int(os.getenv('SMTP_TIMEOUT_SECONDS', '20')) diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index 46ff1edf790158f5b8babc0ed0162e4bcb225a0e..bb415ac5989c47d2605abcb127af7d574e7bf020 100644 GIT binary patch delta 11695 zcmb7|33yc1*~f3fVjzGL7TJW$o?tQ-WD`UTgs_ExN!Y7!letO8OlIQD9YRnXw`!%L z)^S7q)LOLG^=qeE7pz*U)+()5ZPg;Q{aUw{>W5mTTK#_id(VUveV+Eak8ghGyk~pQ zd*1V&!SjEVcjxPQ>3v1rud(>tJj)sZD+(=ZKjr9PX)WvR<1A|ud3F}}5yb;QCzlW#5 z5d=atTo2{RyC&d&oy8+41z>RP_l>To}QJaT*w6iHtJ8p+s z=ptAEZ-dL>-LNP86lw$c$8)T32=ontINK^uQ#gu(3kzW@>${v067lY_J}O zC%|{04TqKn2F`*yl4VeyJP)1-uZEYvpF_>Ng54Fu^>70j#Ck zANGJQltC@95?%=V!C%1c@HJE42X!O|AWpSPu~wd031!$Bh6&hB_y1xFeQ4MQb%xhL zS^7gL*FOej*b7koUWH09e}-Hf>vL1LIUhy!Nl+VF1GR7?)Q;m&o;=&=Z-)JJ|G!5; z6F%rGEbCW>`=ACMF!e)F1{81wH1QzV1V_St@M6Pjpw50Llp%LR?fhZWz6WY!d!^U? ze~&^h*o^~JO6?1|G1ergAXxz?K@Z|p>lUN`CG?{m;smP~2d@4nK?T|AP@Zfzya4J} zZHF3n8%&={;b98Lz<)sPq;P6**2ADWPJxPn#jrnI4i(i7lqcGu#+?TT!Yg1o+-d4> z!im&Bh4R?gX@O_IH4Xm{Kye)k?fh=2VEQ?ftM|ZG_&Ss!i>3$HZ39&Qt*{8*05#9O zM*k!nM13C|3_pbOMB$9U(7}e|m48RkFcrl&VI7pK-+-E+fV`{V8wm%(2~aUH8*0IY zFbvn5`V&z3;AN-<55ghvpHPMlK8f@TOQ7o8(-ib*+zQok7d#fe0_Exh@Lc!>)P(1f zmo@NGDA(U)>bJw8)E|Pw;IE+!J^(-4Jn-DX$@ zuYd~D$DkH|1}e&5fHM5IP@Z}p%Fs`s=IwTJFmHdzDOe++Ji8LI!L-#vLBY2bYJtn4 zg6Dduj;}*4^uDQo4CSG|bAqE957oW`YR9Xg42&A4pyodp%A;4njqp3Lx9)#F=}H#& zg-637P}iv#>TK6QMfYP+J9-|&ww-?}lP4p-Vxp+6U;eJz>U&m1&0Tn#uPzKb(iLf4O z+%~9r?tqiweefChdsqzbTo@ePi_oTSEebp^I!!^A&W7XRded+|oJ9RbC>K8s)$eUM z1r{t063uL=h1bHK(1E>Rvta`2%+E2r6srHVQ2o<48^!HVJG&R^Y<59K_itbi_y<#e z3u@p&s9SIdYQjEC0{uX!SQ!rGu~K*z#HMItZzL8y-~`#6&UF(JrgQ|60IBF&VbQf$;lK3FRm7Lii!nPByO$j_N|VjrtW( zh7DU47+ee$tRZ+hoNn}2LoIj%Bu804gfjf&wCV6UJQ_v!)j{wKgkz{LhFUlZ)!q(e z@Fh?l+5xrWTi_sg57fjzg+=gLD9`MLGW1QTVEz!=Fx_`eFfasl6ctb%>!Ge$8`O?c zP>E!5bB6UqZWhC174-~jk4)IxuSI=YXbJlA_&@Jpu*PN2R7N`Dp{2Cs&Lb^q_DFcrnq zPzmHyI0TMb9~d;#a6Z%m)ljZ(fR(TV%FrjEJhBJMBfo<({2X=)!f-HxO!Ld*Cqm zCQQqfd1nNc6+tzOHar>1($!ECo(VNk%J2fH{#U>)@GdA1j^7Xj>0&5D*F(+YKpnMb z+Rxj7{~J+Ug+eUutJ_)?mJcKABfzz?7%{tRltqZ)#V2gB*q$3hvp4yu0)s((Aw^|}Bm zm@hN>C!xO}l&4;TGVA~h!F0E9VA&L?=$;8R@iN0zPy;qVMRy9;!3&{w^eU7A`=EmN z9jF~2g8XNV=e)F$ZBX;>fI5QPAme!dDJUqOfU@v;s0{Zel;xkmYWM|IBB~}}u7s-~ zH_Lhps^9#kz|bX7^^I^mY=Szn?NFY%6DmgDfW378KcY~H;tSXvRyGGmvJ}eIVW{BS z3^nnkumE0ZxC3g3x535m2T-2+6v}hGB7tW{!2;?h7|w)aSl=qApoPOw6Gfl~ZiegN z<*)~Q$F#o(W!UF%I4n9d_}^?HsQza{9mP|y6ux5W`7J>#6+x{x3Z^yjI11C?Bq*0{ zgrnfaP&>W@%JO^R@$f-d0{>|AeWHOOgP@ImvZ>d=anxf_N1B21%#j z8)?${FQg!=x_-5Ex&p-xq=B-&HOR@7e+Vx{Qi#4*t zaRB9Bh#$G&ETi8GA2a1|!1451f&75-L9MU5T8)fFISTm`<(H6tNH^+p5q%3`2q~wm zOxYVLq;4ZmQ~oRRDsmL^CNdYvAjcy5c<%l0X!>7F=`F2@K7$T`S+NbV~)eXc^G^nER|9(e`PSMRrD!mWOlpU!YH%6X># zFuWQ$5$SJq^Sdm30=hlOpOIDQi%r{asCTD46B$Rj2w860DEgn0p1xl4{|iVrW10SLMD`Ww65B?U>_salQ5}tr z{|lY}{S@>KM>ZpsH2xG`h`fRP9vO%3bTd&O>iQN_-wwZxjHG-$yboDTc`JMzc?i+B z4BZA~8Rc4Jzm$C6RQ&g*p^Zwhud=NFf&-8x=qDlizK(1{T;w984!H(dOM6ea8`1YY zWCFVXhG8>SdVP7+XLr%vDJARsPp!)O-bZG2>2xyncaV+9*U;Yt_5BaB2ssXU7Wq5! zeI)n&+!QKv3b-2is7rkv<Wk4Ik4o<4MKe&&wh^ZLhQc69g7RHJ8= zw|J3w%+3CB_`3YG)6kM?vnSX!ZbN*No9M9S1sXSz^xSCFjpY<)y51Jgrc0zTvL)gs z+}PY)lbP3yY>yadtNFQj=DDOEzw9rGd4b$Y-m0_d8W^dp@qTB zW$PktG&^hL4SDH^=P|}fc(SicC1mC+wJRLj>YZ3iSw*U~&1MyQnH!EcW#x&6=Ex?y z-A#nunBC;IC3f#>^g>5Wb9i`{p*dUKv>BUl!mhn3o~Xwi5zqbdED_fZysZs}?MSQb zw0J4suXdeNZ#$`EliRp^S99V^V{4bytteaNCh8-xa4Od1n^ET`J)D?y9Jcdsc5uYd zm2J_ug9R}=>?G}$c&stfluB@pI8uH+V!F%=qXu=?$z-OLmt+g=^?7OAoB|VXaXG43 zBx$#%l1Y5%5e+edBi?Gq;)zy=u(7M%&0a$^o(kI)@s?Dp8{<6UiEzwK*hx1ME48<9 zSRs31BA#k1i^t01jg4jXaVHUu#G1}%~z>*yIZorl`>RD!;_kYi~b zHmA~zm)w|ZLLzA=F`CtJU}p%2tjv=q3@EGk|D03yfzey@`YnrxQ%u>=>_pjEcGB1f z^3rZ|l#`m(W$R8f8Q7KNg0;py*NdL*5d^eGKb=BSSST_1HL^`2etwsXLqJ-=!(M-=S{Wv?2)Ex$J=G`Ue?Kb9F* zT*TIn0y4tsITUXh@qpMY`^m&ExFVF_Heg;fh$+ZuQ8 zj5V>{CO0WZd7;eQ;?dpZ?Cj~qx8_fZw>VL&N`KX*P4P%58%^LI69L&nlg8y0;ocRE z#D8(TP5XDEC7I!+KVF=2lP0X~Ais5{y}8!m0RD2|cWDelAhV}*&YUtiB9~=5hX>lE z7i6nW^(?E}KgC=I$`+RG>5=w>#0p|1XPF6yBeeeM>;C5SOXF?0Fy<(U$}w(hF1dDY z%BqQz(q~x&-Mmyoi|cjqhoyv*wrgvv{E}T)RqGf2WmA4q@^_irPi=4mJX_hc%^dC~ z%AFf6GpQ#TZ;U5An{1aL%E|dMwJ{!T@^6B@DxPTCU8GaDNe^~B%;}RJL)rJHl;v;q z+!R~&7g@$NQ67vZ6vV-N%4tn*`|e#aj)RQ0`zG#66Bj1zc&S!~CG3T6V)ss(2>Io) z&O}p5x=KZzM0V)3qx1TigAVe1GBbR7Nl|C2u!8%uC6bvl{jRCGjBoj7%W9Q;gBFp) zc$(a@P9yB%I;Y7k$sCw|XK_5y(in}mm({pji`R9qpZOcGoG*EadZgT;F6j z_;ZAFZ+E9Y$eeE2s^CVmh&QcSZNU$QdjBDxYt=fNa?6wGJ&y^KxtX{FJQmr#bC=|$ zS43SWsYg8GSzXE@yRMn>{$!=RRi0C?ZebpAG9U@ERj;`1_^&vLL&+*Iy4fY7!=W!v z);Y=H;q|Gs|Fh%p@V128M(wM{JHE$lwwg%AVsa49^RTiZmEhs?Y;63BWp!8VhIlOI z2IIB5rvwppqmfN%erx?l)*mrLDtO1ecwm8f$6coOefw2Y)}&%MG}>YZ8?pTG&U3TV z%IovezPE!D3`Y`}8pkf4btQk!51%NU8WTM2+H&x`AHLjnINsnt?lw1>YgTHzp{9TP zsxQ~u#+_}h&wOXz)JhYxUv*CAZI|!-=*7sKWFl8jmCY08W+zo#l9!IR@}Oc47j2fEBK$g&nN6aR9~vV(u6Dvt*T(h! zTP{QOmMgGwA?1$=PDiggHzwubNYCyd0n6A;*njvPcf*-`7Ce*=oD#Nt>dEf(L8Kvg z!-+;BF&W`Es7!L&+7xd8`jAKTvnzPl)7@HXEH(lt(PP zwepnwf*^Hc(Ibn-Zd^>%D}LptQakCt@qz1-{(iYVegtue^wRr}fgk#%vM-2sUUTkC z5WHlWC?+*Tc(-C+GL+f7Xh^|)?SNYLgGKTDiB<6i-uYVnCJFPZQHgd~)ws_8nbN7s zI7<%>klWiFuer;cmZ_<_slr5;W5?X~T(a^r;o(CyDXjAXSSBtydt?q&O+7i-eb>aP zo7LIJZ?-zCyzkhVOPSd*%c_oe-OZxr-YPOI^X{7#H|Fp=%tU~j3$})M8?J2hc+z}_ zn0pW;ZQ?|4eDK8AANj;TccZ{Ddcr z{RGXw9Xm(~{!6daz>^n=^=Y!(&3nhYeo@Ua`RjDti8wzK6~V2MSfrKbnwNom7HQ?0 zF@suo@CmIj+!SeWZT+&>zgG1=@S+FVINtfHYglXcs=9%B{fsMv_avFQapi=gSLt_f zhk4Ek?I%`VH+6-{{+z&B{3i60R_+x)yw+i>io;GvvLv%%)n#LH?>A3F)J+DDUv7_q LF8l7PW4rwy3en{p delta 6898 zcmZwMeSFUK9>?+THFo26Gqa7kxn?(PGcosDF=6iJZX?W<}^CC21$>8d8Zu zbQn38b_U%#*O@W=T*`t14ret*B~d-M5zf7ey#to_C{ zd$Y@PD!lR{!`0knOnoe?V$4#?x5CwG%!s&zK}k!Dt+W zEwBjn{BmrB`)&IYxBW_0xbOR$X zBGwq@YEqD6V0xk^G#XQ}6qT8kI0CoWdK3pnXCt{W`R_{MAsUpL6{rp?P^tU``{7a4 z-o-PknwWwbFdg~F4CIe8HWhVtW})`H45M%f(uLWGO8pVM2XDoBjB!(F7Vpfo4=Qyd zP^t1_D_n!>@C0grb2uM=MIF+a3C;wbM;$sZYT%`qg_}?lzmAnLm{&se>K+P8Z45TW z2T?1ZgPK5@bp=+Uz8RINt*EWohnm>8s8nA>W#SLib3sj<*ReK|ebda=`y$82cnT>iyn=yjf-!l09=0**;T=jZDTS*c@|E?Xyvbb2Vy@ciQ%!QD-KM4=H2G ze%b#v6!aEkqCPkXGqDi$#Z!S=*$1dS{S5WoSq#Bj_I_ZB(@z9;raj5l$0JEHv#%dgK(Y{C93g>!5}8ETJ~qfYlW)QpeX`{%8rN|ahBdG~ zR>v&co{P%ha3l%lF|2~CP~&cBN&bT}cWJ)nWESZ`t|^`KVH>5W8fO%~R~b*Kq#$8h`@ z_4yOlbEt(}MP;sPYiHm_m`gnowZJFvW%N8nA&^2$8)v4msF}7yy>1;*dz*)ByP1XB z+l{D|9>6U80`&zIO?v8Jebj{0Py=?h^`6*``Vizfk9nPfzKJ%YQurmR<1?s@qZg{T#7M`dacy782)|B3O`W73_ol8Kr?4mM$YGm?Tn_#CRE z)yO$B6?gzoV^ds{;cUTSbW{Hm>!SN!XQJt-OgxBcpM}Zz3dZ6-R0e-Uj+qI*kNoRB zPp6=PhoDkE0<{GN)=8+-KixVH_532#^UG{~6>8x1s6)9QmANl${W$9R@2%(WBmdg- zD>Udea`98h!8Nr}9dt*w(d3{eG#|Bx<*4W1wDrxXw_y)zV#ll}QD@{V>hNAbO`vWk z=eNS$iTo$h(3J-5(Rk#AFbh!+?n6!RQ)Cy+No<1Ana*>WsEL)L4x0znUpeY*>_Gl8 zXZfQ;oY>j%b*K)vqQ0rV!ftpPwU-H9oh?W~4cGx2VlFCU zlTjI&hU#w)DuWBL5x#+L+>P;i|G%dYMME&7XipoVPJc^ON=Kt!%c-cDZ$Q0<+i*Va zL!F8KSO)YOn1NMsQ7`hZ!U`HRz&ccFb|JfAzCz8k zL2qZ}$*7LoqbA-DmHJV(J`S~IGf;0!8EOIh(2a-i5xk7r%0AiTUxy?&+e!U+tV(?% z=Hqm%j^CkDeF?R~+oZE-JZqBl{`hvqoXM`IQ0aj4fi)wYj94Lkw$e2HK7 ze}=vB3~IpVF%%c0X8acB;StnC+T}VOWMMe^7mM0mcy*p}43Q-Gxawz#%2W2!w;1<+`KEW1v7+qMEKi?v6k3Xg0pRf*^ zu9qClF>FU@b?lNq&0y9Lsr0b~cc_7@Gm-3}@$VFTgnov55d1`&--%O1WA4wyIMmOq zuG*Ae##DdJ`LhDf6Rn6}2~Gaa^%{lAcPe;-P!?tro?3LKYdvwzUvl=^P5UoG@#tX)_?Q2I`6oOa|k!FiqQ25@s+>Ce-ohId0hjDZ-~DU&kweq3d~K z9pO2un!O&wuWh-l^?Ph$%l+{(QGgcll)u2HL?s~jJSs=A#`OCQ;8TpydSR;y9r$viCEhIg?ov&2wjzlJYth7 z_NsBGfR!~7oxe~D$(T;ONjyZ%C3Mx)LtK9)MiC*l9!_3&U06TIOJW zwAM!bzbhZu`U~hK{zt+2L|}wiF?^qv$Sx$dt_QsZkKm`n|Hk%+qQS5`{LVu78q4rSeTz~a{CPI@6P4l zgZ+hp*||Pf$J7Adi<#A3-mRVMdw=P?GN|CARYe8)-o5v)^)2p_5ad1Bvz0Hp*AbtbcTOjlZ&L1Dmv{OgpRfFZ z!2#ZDLmEeRE_{6Cv=Vp8*y6`c|H;Lp^Giy`6&3gv4=oPxrapYtoA$`7-tb|qnC|Rh w*@2P8|9`f}^TGmrT?)co-lDNF-lby~hvei>D$OsMQsNywuFMzyXk?}T0;nq@>i_@% diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 705a0dc..6d0b407 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,13 +2,59 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-25 23:29+0000\n" +"POT-Creation-Date: 2026-03-26 00:10+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +#: workflows/backup_ops.py:141 +msgid "Remote Backup ist deaktiviert." +msgstr "" + +#: workflows/backup_ops.py:146 +#, python-format +msgid "Zieltyp %(target)s ist vorbereitet, aber noch nicht implementiert." +msgstr "" + +#: workflows/backup_ops.py:152 +msgid "Nextcloud Backup-Verzeichnis fehlt." +msgstr "" + +#: workflows/backup_ops.py:170 +#, python-format +msgid "Upload nach Nextcloud fehlgeschlagen bei %(file)s." +msgstr "" + +#: workflows/backup_ops.py:176 +#, python-format +msgid "Nach Nextcloud hochgeladen: %(count)s Datei(en)." +msgstr "" + +#: workflows/backup_ops.py:239 workflows/backup_ops.py:318 +msgid "Backup-Dateien nicht gefunden." +msgstr "" + +#: workflows/backup_ops.py:289 +msgid "Media-Archiv enthält kein media/-Verzeichnis." +msgstr "" + +#: workflows/backup_ops.py:291 +#, python-format +msgid "" +"%(tables)s Tabellen, %(onboarding)s Onboarding, %(offboarding)s Offboarding, " +"%(media)s Mediendateien geprüft." +msgstr "" + +#: workflows/backup_ops.py:316 +msgid "Ungültiger Backup-Pfad." +msgstr "" + +#: workflows/backup_ops.py:323 +msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." +msgstr "" + #: workflows/forms.py:338 #, python-format msgid "" @@ -16,21 +62,22 @@ msgid "" "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:55 workflows/views.py:180 +#: workflows/models.py:55 workflows/views.py:181 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:56 workflows/views.py:181 +#: workflows/models.py:56 workflows/views.py:182 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:57 workflows/models.py:372 workflows/views.py:182 +#: workflows/models.py:57 workflows/models.py:372 workflows/views.py:183 msgid "Abgeschlossen" msgstr "Completed" #: workflows/models.py:58 workflows/models.py:312 -#: workflows/templates/workflows/requests_dashboard.html:226 -#: workflows/templates/workflows/welcome_emails.html:112 workflows/views.py:183 +#: workflows/templates/workflows/backup_recovery.html:70 +#: workflows/templates/workflows/requests_dashboard.html:222 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:184 msgid "Fehlgeschlagen" msgstr "Failed" @@ -55,8 +102,8 @@ msgid "unbefristet" msgstr "" #: workflows/models.py:138 -#: workflows/templates/workflows/onboarding_intro_session.html:32 -#: workflows/templates/workflows/requests_dashboard.html:149 +#: workflows/templates/workflows/onboarding_intro_session.html:28 +#: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" @@ -90,34 +137,34 @@ msgstr "" msgid "Automatisch" msgstr "" -#: workflows/models.py:171 workflows/views.py:86 +#: workflows/models.py:171 workflows/views.py:87 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:172 workflows/views.py:87 +#: workflows/models.py:172 workflows/views.py:88 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:173 workflows/views.py:88 +#: workflows/models.py:173 workflows/views.py:89 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:174 workflows/views.py:89 +#: workflows/models.py:174 workflows/views.py:90 msgid "Abschluss" msgstr "Finish" #: workflows/models.py:177 workflows/models.py:258 -#: workflows/templates/workflows/home.html:66 +#: workflows/templates/workflows/home.html:62 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 -#: workflows/templates/workflows/requests_dashboard.html:135 +#: workflows/templates/workflows/requests_dashboard.html:131 msgid "Onboarding" msgstr "Onboarding" #: workflows/models.py:178 workflows/models.py:259 -#: workflows/templates/workflows/home.html:82 +#: workflows/templates/workflows/home.html:78 #: workflows/templates/workflows/requests_dashboard.html:78 -#: workflows/templates/workflows/requests_dashboard.html:136 +#: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" msgstr "Offboarding" @@ -206,22 +253,22 @@ msgid "Ist inaktiv/Nein" msgstr "inactive" #: workflows/models.py:308 -#: workflows/templates/workflows/welcome_emails.html:104 +#: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" #: workflows/models.py:309 -#: workflows/templates/workflows/welcome_emails.html:106 +#: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" #: workflows/models.py:310 -#: workflows/templates/workflows/welcome_emails.html:108 +#: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" #: workflows/models.py:311 -#: workflows/templates/workflows/welcome_emails.html:110 +#: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" @@ -257,6 +304,20 @@ msgstr "Is no / inactive" msgid "Entwurf" msgstr "Draft" +#: workflows/models.py:391 +#, fuzzy +#| msgid "Nextcloud:" +msgid "Nextcloud" +msgstr "Nextcloud:" + +#: workflows/models.py:392 +msgid "S3" +msgstr "" + +#: workflows/models.py:393 +msgid "NFS" +msgstr "" + #: workflows/tasks.py:591 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" @@ -350,24 +411,24 @@ msgstr "Sign in" msgid "Bitte melden Sie sich mit Ihrem Benutzerkonto an." msgstr "Please sign in with your user account." -#: workflows/templates/registration/login.html:29 +#: workflows/templates/registration/login.html:30 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Anmeldung fehlgeschlagen" msgstr "Failed" -#: workflows/templates/registration/login.html:30 +#: workflows/templates/registration/login.html:31 msgid "" "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." msgstr "" -#: workflows/templates/registration/login.html:35 +#: workflows/templates/registration/login.html:37 msgid "Anmelden" msgstr "Sign in" #: workflows/templates/workflows/audit_log.html:4 #: workflows/templates/workflows/audit_log.html:15 -#: workflows/templates/workflows/home.html:124 +#: workflows/templates/workflows/home.html:120 msgid "Audit Log" msgstr "" @@ -377,15 +438,16 @@ msgstr "" #: workflows/templates/workflows/audit_log.html:23 #: workflows/templates/workflows/audit_log.html:54 -#: workflows/templates/workflows/requests_dashboard.html:197 -#: workflows/templates/workflows/welcome_emails.html:91 +#: workflows/templates/workflows/backup_recovery.html:43 +#: workflows/templates/workflows/requests_dashboard.html:193 +#: workflows/templates/workflows/welcome_emails.html:87 msgid "Aktion" msgstr "Action" #: workflows/templates/workflows/audit_log.html:25 -#: workflows/templates/workflows/requests_dashboard.html:134 -#: workflows/templates/workflows/requests_dashboard.html:142 -#: workflows/templates/workflows/requests_dashboard.html:151 +#: workflows/templates/workflows/requests_dashboard.html:130 +#: workflows/templates/workflows/requests_dashboard.html:138 +#: workflows/templates/workflows/requests_dashboard.html:147 msgid "Alle" msgstr "" @@ -413,7 +475,7 @@ msgid "Filtern" msgstr "" #: workflows/templates/workflows/audit_log.html:45 -#: workflows/templates/workflows/requests_dashboard.html:169 +#: workflows/templates/workflows/requests_dashboard.html:165 msgid "Zurücksetzen" msgstr "Reset" @@ -423,8 +485,8 @@ msgstr "" #: workflows/templates/workflows/audit_log.html:55 #: workflows/templates/workflows/request_timeline.html:62 -#: workflows/templates/workflows/requests_dashboard.html:132 -#: workflows/templates/workflows/requests_dashboard.html:192 +#: workflows/templates/workflows/requests_dashboard.html:128 +#: workflows/templates/workflows/requests_dashboard.html:188 msgid "Typ" msgstr "Type" @@ -460,12 +522,172 @@ msgstr "" msgid "Noch keine Audit-Einträge vorhanden." msgstr "No requests available yet." +#: workflows/templates/workflows/backup_recovery.html:4 +#: workflows/templates/workflows/backup_recovery.html:12 +#: workflows/templates/workflows/home.html:125 +msgid "Backup & Recovery" +msgstr "Backup & Recovery" + +#: workflows/templates/workflows/backup_recovery.html:13 +msgid "" +"Datenbank- und Media-Backups erstellen und vorhandene Bundles sicher " +"verifizieren." +msgstr "Create database and media backups and verify existing bundles safely." + +#: workflows/templates/workflows/backup_recovery.html:20 +msgid "Aktionen" +msgstr "Actions" + +#: workflows/templates/workflows/backup_recovery.html:21 +msgid "" +"Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst " +"CLI-only." +msgstr "Creation and verification run inside the app context. Restore intentionally remains CLI-only." + +#: workflows/templates/workflows/backup_recovery.html:23 +msgid "Neues Backup jetzt erstellen?" +msgstr "Create a new backup now?" + +#: workflows/templates/workflows/backup_recovery.html:25 +msgid "Backup erstellen" +msgstr "Create backup" + +#: workflows/templates/workflows/backup_recovery.html:31 +msgid "Verfügbare Backup-Bundles" +msgstr "Available backup bundles" + +#: workflows/templates/workflows/backup_recovery.html:37 +msgid "Bundle" +msgstr "Bundle" + +#: workflows/templates/workflows/backup_recovery.html:38 +msgid "Erstellt" +msgstr "Created" + +#: workflows/templates/workflows/backup_recovery.html:39 +#: workflows/templates/workflows/backup_recovery.html:54 +msgid "Verifiziert" +msgstr "Verified" + +#: workflows/templates/workflows/backup_recovery.html:40 +#: workflows/templates/workflows/home.html:98 +#: workflows/templates/workflows/onboarding_intro_session.html:37 +#: workflows/templates/workflows/request_timeline.html:70 +#: workflows/templates/workflows/requests_dashboard.html:136 +#: workflows/templates/workflows/welcome_emails.html:85 +msgid "Status" +msgstr "Status" + +#: workflows/templates/workflows/backup_recovery.html:41 +msgid "Inhalt" +msgstr "Contents" + +#: workflows/templates/workflows/backup_recovery.html:42 +msgid "Remote" +msgstr "Remote" + +#: workflows/templates/workflows/backup_recovery.html:56 +msgid "Nicht geprüft" +msgstr "Not verified" + +#: workflows/templates/workflows/backup_recovery.html:68 +msgid "Hochgeladen" +msgstr "Uploaded" + +#: workflows/templates/workflows/backup_recovery.html:72 +msgid "Vorbereitet" +msgstr "Prepared" + +#: workflows/templates/workflows/backup_recovery.html:74 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Deaktiviert" +msgstr "Disabled" + +#: workflows/templates/workflows/backup_recovery.html:76 +msgid "Lokal" +msgstr "Local" + +msgid "Lokal gespeichert" +msgstr "Stored locally" + +msgid "Lokal nicht vorhanden" +msgstr "Not stored locally" + +#: workflows/templates/workflows/backup_recovery.html:90 +msgid "Backup jetzt verifizieren?" +msgstr "Verify backup now?" + +#: workflows/templates/workflows/backup_recovery.html:92 +msgid "Verifizieren" +msgstr "Verify" + +#: workflows/templates/workflows/backup_recovery.html:94 +msgid "Backup-Bundle wirklich löschen?" +msgstr "Delete this backup bundle?" + +#: workflows/templates/workflows/backup_recovery.html:96 +#: workflows/templates/workflows/form_builder.html:92 +#: workflows/templates/workflows/form_builder.html:107 +#: workflows/templates/workflows/integrations_setup.html:265 +#: workflows/templates/workflows/intro_builder.html:66 +#: workflows/templates/workflows/intro_builder.html:102 +#: workflows/templates/workflows/requests_dashboard.html:279 +#: workflows/templates/workflows/welcome_emails.html:70 +msgid "Löschen" +msgstr "Delete" + +#: workflows/templates/workflows/backup_recovery.html:106 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Noch keine Backup-Bundles vorhanden." +msgstr "No backup bundles available yet." + +msgid "Bitte warten" +msgstr "Please wait" + +msgid "Aktion läuft" +msgstr "Action in progress" + +msgid "Die Aktion wird im aktuellen Tab ausgeführt." +msgstr "The action is running in the current tab." + +msgid "Backup läuft" +msgstr "Backup in progress" + +msgid "Bitte warten. Die Aktion wird im aktuellen Tab ausgeführt." +msgstr "Please wait. The action is running in the current tab." + +msgid "Backup wird erstellt" +msgstr "Backup is being created" + +msgid "Bitte warten. Datenbank- und Media-Bundle werden gerade vorbereitet." +msgstr "Please wait. The database and media bundle are being prepared." + +msgid "Backup wird verifiziert" +msgstr "Backup is being verified" + +msgid "Bitte warten. Bundle, Datenbank-Dump und Media-Archiv werden geprüft." +msgstr "Please wait. The bundle, database dump, and media archive are being checked." + +msgid "Nextcloud-Test läuft" +msgstr "Nextcloud test in progress" + +msgid "Bitte warten. Verbindung und Upload in das konfigurierte Ziel werden geprüft." +msgstr "Please wait. The connection and upload to the configured target are being checked." + +msgid "SMTP-Test läuft" +msgstr "SMTP test in progress" + +msgid "Bitte warten. SMTP-Verbindung und Testversand werden geprüft." +msgstr "Please wait. The SMTP connection and test delivery are being checked." + #: workflows/templates/workflows/base_shell.html:24 msgid "Bitte bestätigen" msgstr "" #: workflows/templates/workflows/base_shell.html:28 -#: workflows/templates/workflows/welcome_emails.html:138 +#: workflows/templates/workflows/welcome_emails.html:134 msgid "Abbrechen" msgstr "Cancel" @@ -475,7 +697,7 @@ msgstr "" #: workflows/templates/workflows/form_builder.html:4 #: workflows/templates/workflows/form_builder.html:14 -#: workflows/templates/workflows/home.html:134 +#: workflows/templates/workflows/home.html:135 msgid "Form Builder" msgstr "Form Builder" @@ -483,133 +705,123 @@ msgstr "Form Builder" msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." msgstr "Sort fields by drag and drop and group them by step." -#: workflows/templates/workflows/form_builder.html:33 +#: workflows/templates/workflows/form_builder.html:29 msgid "Reihenfolge speichern" msgstr "Save order" -#: workflows/templates/workflows/form_builder.html:50 +#: workflows/templates/workflows/form_builder.html:46 msgid "Fix" msgstr "Fixed" -#: workflows/templates/workflows/form_builder.html:51 +#: workflows/templates/workflows/form_builder.html:47 msgid "Ausgeblendet" msgstr "Hidden" -#: workflows/templates/workflows/form_builder.html:52 +#: workflows/templates/workflows/form_builder.html:48 msgid "Pflicht" msgstr "Required" -#: workflows/templates/workflows/form_builder.html:63 +#: workflows/templates/workflows/form_builder.html:59 msgid "Optionen verwalten" msgstr "Manage options" -#: workflows/templates/workflows/form_builder.html:66 +#: workflows/templates/workflows/form_builder.html:62 msgid "Kategorie" msgstr "Category" -#: workflows/templates/workflows/form_builder.html:79 -#: workflows/templates/workflows/form_builder.html:92 -#: workflows/templates/workflows/form_builder.html:137 +#: workflows/templates/workflows/form_builder.html:75 +#: workflows/templates/workflows/form_builder.html:88 +#: workflows/templates/workflows/form_builder.html:133 msgid "Label (DE)" msgstr "Label (DE)" -#: workflows/templates/workflows/form_builder.html:80 +#: workflows/templates/workflows/form_builder.html:76 msgid "Label (EN, optional)" msgstr "Label (EN, optional)" -#: workflows/templates/workflows/form_builder.html:81 +#: workflows/templates/workflows/form_builder.html:77 msgid "Technischer Wert (optional)" msgstr "Technical value (optional)" -#: workflows/templates/workflows/form_builder.html:82 +#: workflows/templates/workflows/form_builder.html:78 msgid "Option hinzufügen" msgstr "Add option" -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/intro_builder.html:62 +#: workflows/templates/workflows/form_builder.html:87 +#: workflows/templates/workflows/intro_builder.html:58 msgid "Sortierung" msgstr "Sort order" -#: workflows/templates/workflows/form_builder.html:93 -#: workflows/templates/workflows/form_builder.html:138 +#: workflows/templates/workflows/form_builder.html:89 +#: workflows/templates/workflows/form_builder.html:134 msgid "Label (EN)" msgstr "Label (EN)" -#: workflows/templates/workflows/form_builder.html:95 -#: workflows/templates/workflows/integrations_setup.html:266 -#: workflows/templates/workflows/intro_builder.html:69 +#: workflows/templates/workflows/form_builder.html:91 +#: workflows/templates/workflows/integrations_setup.html:263 +#: workflows/templates/workflows/intro_builder.html:65 msgid "Aktiv" msgstr "Active" -#: workflows/templates/workflows/form_builder.html:96 -#: workflows/templates/workflows/form_builder.html:111 -#: workflows/templates/workflows/integrations_setup.html:268 -#: workflows/templates/workflows/intro_builder.html:70 -#: workflows/templates/workflows/intro_builder.html:106 -#: workflows/templates/workflows/requests_dashboard.html:283 -#: workflows/templates/workflows/welcome_emails.html:74 -msgid "Löschen" -msgstr "Delete" - -#: workflows/templates/workflows/form_builder.html:104 +#: workflows/templates/workflows/form_builder.html:100 msgid "Ziehen zum Sortieren" msgstr "Drag to reorder" -#: workflows/templates/workflows/form_builder.html:111 +#: workflows/templates/workflows/form_builder.html:107 msgid "Option wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/form_builder.html:115 +#: workflows/templates/workflows/form_builder.html:111 msgid "Keine Optionen in dieser Kategorie." msgstr "No options in this category." -#: workflows/templates/workflows/form_builder.html:121 +#: workflows/templates/workflows/form_builder.html:117 msgid "Optionen speichern" msgstr "Save options" -#: workflows/templates/workflows/form_builder.html:128 +#: workflows/templates/workflows/form_builder.html:124 msgid "Feldtexte verwalten" msgstr "Manage field text" -#: workflows/templates/workflows/form_builder.html:136 +#: workflows/templates/workflows/form_builder.html:132 msgid "Feld" msgstr "Field" -#: workflows/templates/workflows/form_builder.html:139 +#: workflows/templates/workflows/form_builder.html:135 msgid "Hilfetext (DE)" msgstr "Help text (DE)" -#: workflows/templates/workflows/form_builder.html:140 +#: workflows/templates/workflows/form_builder.html:136 msgid "Hilfetext (EN)" msgstr "Help text (EN)" -#: workflows/templates/workflows/form_builder.html:150 +#: workflows/templates/workflows/form_builder.html:146 msgid "Fallback: Standardlabel" msgstr "Fallback: default label" -#: workflows/templates/workflows/form_builder.html:151 +#: workflows/templates/workflows/form_builder.html:147 msgid "English label" msgstr "English label" -#: workflows/templates/workflows/form_builder.html:152 +#: workflows/templates/workflows/form_builder.html:148 msgid "Optionaler Hilfetext" msgstr "Optional help text" -#: workflows/templates/workflows/form_builder.html:153 +#: workflows/templates/workflows/form_builder.html:149 msgid "Optional English help text" msgstr "Optional English help text" -#: workflows/templates/workflows/form_builder.html:156 +#: workflows/templates/workflows/form_builder.html:152 msgid "Keine Feldkonfigurationen verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:162 +#: workflows/templates/workflows/form_builder.html:158 msgid "Feldtexte speichern" msgstr "Save field text" #: workflows/templates/workflows/handbook.html:4 #: workflows/templates/workflows/handbook.html:15 -#: workflows/templates/workflows/home.html:144 +#: workflows/templates/workflows/home.html:145 msgid "Handbook" msgstr "Handbook" @@ -732,7 +944,7 @@ msgstr "" #: workflows/templates/workflows/home.html:4 #: workflows/templates/workflows/home.html:35 -#: workflows/templates/workflows/requests_dashboard.html:299 +#: workflows/templates/workflows/requests_dashboard.html:295 msgid "TUBCO Onboarding & Offboarding Portal" msgstr "TUBCO Onboarding & Offboarding Portal" @@ -770,12 +982,12 @@ msgid "Nextcloud:" msgstr "Nextcloud:" #: workflows/templates/workflows/home.html:40 -#: workflows/templates/workflows/integrations_setup.html:63 +#: workflows/templates/workflows/integrations_setup.html:60 msgid "aktiv" msgstr "active" #: workflows/templates/workflows/home.html:40 -#: workflows/templates/workflows/integrations_setup.html:63 +#: workflows/templates/workflows/integrations_setup.html:60 msgid "inaktiv" msgstr "inactive" @@ -786,12 +998,12 @@ msgid "E-Mail:" msgstr "Email:" #: workflows/templates/workflows/home.html:43 -#: workflows/templates/workflows/integrations_setup.html:125 +#: workflows/templates/workflows/integrations_setup.html:122 msgid "Testmodus" msgstr "Test mode" #: workflows/templates/workflows/home.html:43 -#: workflows/templates/workflows/integrations_setup.html:125 +#: workflows/templates/workflows/integrations_setup.html:122 msgid "Produktion" msgstr "Production" @@ -799,15 +1011,15 @@ msgstr "Production" msgid "PDF + E-Mail Workflow bereit" msgstr "PDF + Email Workflow Ready" -#: workflows/templates/workflows/home.html:59 +#: workflows/templates/workflows/home.html:55 msgid "Apps" msgstr "Apps" -#: workflows/templates/workflows/home.html:60 +#: workflows/templates/workflows/home.html:56 msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/templates/workflows/home.html:67 +#: workflows/templates/workflows/home.html:63 msgid "" "Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen " "senden und in Nextcloud ablegen." @@ -815,19 +1027,19 @@ msgstr "" "Capture new employees, generate a PDF with letterhead, send notifications, " "and store it in Nextcloud." -#: workflows/templates/workflows/home.html:69 +#: workflows/templates/workflows/home.html:65 msgid "Mehrschritt-Formular" msgstr "Multi-step form" -#: workflows/templates/workflows/home.html:71 +#: workflows/templates/workflows/home.html:67 msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/templates/workflows/home.html:75 +#: workflows/templates/workflows/home.html:71 msgid "Onboarding starten" msgstr "Start onboarding" -#: workflows/templates/workflows/home.html:83 +#: workflows/templates/workflows/home.html:79 msgid "" "Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und " "Rückgabe-Prozess starten." @@ -835,29 +1047,29 @@ msgstr "" "Search employees, prefill data, generate offboarding documents, and start " "the return process." -#: workflows/templates/workflows/home.html:85 +#: workflows/templates/workflows/home.html:81 msgid "Profile-Suche" msgstr "Profile search" -#: workflows/templates/workflows/home.html:86 +#: workflows/templates/workflows/home.html:82 msgid "Hardware-Liste" msgstr "Hardware list" -#: workflows/templates/workflows/home.html:87 +#: workflows/templates/workflows/home.html:83 msgid "IT-Rückgabe" msgstr "IT return" -#: workflows/templates/workflows/home.html:91 +#: workflows/templates/workflows/home.html:87 msgid "Offboarding starten" msgstr "Start offboarding" -#: workflows/templates/workflows/home.html:98 +#: workflows/templates/workflows/home.html:94 #: workflows/templates/workflows/requests_dashboard.html:4 #: workflows/templates/workflows/requests_dashboard.html:33 msgid "Anfragen Dashboard" msgstr "Requests Dashboard" -#: workflows/templates/workflows/home.html:99 +#: workflows/templates/workflows/home.html:95 msgid "" "Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-" "Anfragen." @@ -865,92 +1077,89 @@ msgstr "" "Status, search, PDF links, and history of all onboarding/offboarding " "requests." -#: workflows/templates/workflows/home.html:101 +#: workflows/templates/workflows/home.html:97 msgid "Suche" msgstr "Search" -#: workflows/templates/workflows/home.html:102 -#: workflows/templates/workflows/onboarding_intro_session.html:41 -#: workflows/templates/workflows/request_timeline.html:70 -#: workflows/templates/workflows/requests_dashboard.html:140 -#: workflows/templates/workflows/welcome_emails.html:89 -msgid "Status" -msgstr "Status" - -#: workflows/templates/workflows/home.html:103 +#: workflows/templates/workflows/home.html:99 msgid "PDF Zugriff" msgstr "PDF access" -#: workflows/templates/workflows/home.html:107 +#: workflows/templates/workflows/home.html:103 msgid "Dashboard öffnen" msgstr "Open dashboard" -#: workflows/templates/workflows/home.html:114 +#: workflows/templates/workflows/home.html:110 msgid "Admin Apps" msgstr "Admin Apps" -#: workflows/templates/workflows/home.html:115 +#: workflows/templates/workflows/home.html:111 msgid "Konfiguration, Tests und Steuerung." msgstr "Configuration, tests, and controls." -#: workflows/templates/workflows/home.html:119 +#: workflows/templates/workflows/home.html:115 msgid "Integrationen" msgstr "Integrations" -#: workflows/templates/workflows/home.html:120 +#: workflows/templates/workflows/home.html:116 msgid "Nextcloud- und E-Mail-Setup." msgstr "Nextcloud and email setup." -#: workflows/templates/workflows/home.html:121 -#: workflows/templates/workflows/home.html:126 -#: workflows/templates/workflows/home.html:131 -#: workflows/templates/workflows/home.html:136 -#: workflows/templates/workflows/home.html:141 -#: workflows/templates/workflows/home.html:146 -#: workflows/templates/workflows/home.html:151 +#: workflows/templates/workflows/home.html:117 +#: workflows/templates/workflows/home.html:122 +#: workflows/templates/workflows/home.html:127 +#: workflows/templates/workflows/home.html:132 +#: workflows/templates/workflows/home.html:137 +#: workflows/templates/workflows/home.html:142 +#: workflows/templates/workflows/home.html:147 +#: workflows/templates/workflows/home.html:152 msgid "Öffnen" msgstr "Open" -#: workflows/templates/workflows/home.html:125 +#: workflows/templates/workflows/home.html:121 msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." msgstr "" -#: workflows/templates/workflows/home.html:129 +#: workflows/templates/workflows/home.html:126 +msgid "Backups erstellen und sicher verifizieren." +msgstr "" + +#: workflows/templates/workflows/home.html:130 #: workflows/templates/workflows/welcome_emails.html:4 msgid "Welcome E-Mails" msgstr "Welcome Emails" -#: workflows/templates/workflows/home.html:130 +#: workflows/templates/workflows/home.html:131 msgid "Geplante Welcome Mails verwalten." msgstr "Manage scheduled welcome emails." -#: workflows/templates/workflows/home.html:135 +#: workflows/templates/workflows/home.html:136 msgid "Felder, Schritte und Optionen verwalten." msgstr "Manage fields, steps, and options." -#: workflows/templates/workflows/home.html:139 +#: workflows/templates/workflows/home.html:140 #: workflows/templates/workflows/intro_builder.html:4 #: workflows/templates/workflows/intro_builder.html:17 msgid "Einweisungs-Builder" msgstr "Introduction Builder" -#: workflows/templates/workflows/home.html:140 +#: workflows/templates/workflows/home.html:141 msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." msgstr "Configure checklist items for the introduction protocol." -#: workflows/templates/workflows/home.html:145 +#: workflows/templates/workflows/home.html:146 msgid "Project wiki and developer documentation in one place." msgstr "Project wiki and developer documentation in one place." -#: workflows/templates/workflows/home.html:149 +#: workflows/templates/workflows/home.html:150 msgid "Django Admin" msgstr "Django Admin" -#: workflows/templates/workflows/home.html:150 +#: workflows/templates/workflows/home.html:151 msgid "Vollständige Datenverwaltung." msgstr "Full data management." -#: workflows/templates/workflows/home.html:157 +#: workflows/templates/workflows/home.html:158 msgid "Tipp: Die letzten Vorgänge sehen Sie jederzeit im Anfragen Dashboard." msgstr "Tip: You can always see the latest requests in the Requests Dashboard." @@ -991,267 +1200,307 @@ msgstr "Email routing & templates" #: workflows/templates/workflows/integrations_setup.html:21 msgid "Workflow-Regeln" -msgstr "" +msgstr "Workflow rules" -#: workflows/templates/workflows/integrations_setup.html:57 +#: workflows/templates/workflows/integrations_setup.html:22 +msgid "Backup-Ziel" +msgstr "Backup target" + +#: workflows/templates/workflows/integrations_setup.html:54 msgid "Nextcloud speichern" msgstr "Save Nextcloud" -#: workflows/templates/workflows/integrations_setup.html:58 -#, fuzzy -#| msgid "Nextcloud-Test" +#: workflows/templates/workflows/integrations_setup.html:55 msgid "Nextcloud-Test starten" -msgstr "Nextcloud test" +msgstr "Run Nextcloud test" -#: workflows/templates/workflows/integrations_setup.html:62 -#: workflows/templates/workflows/integrations_setup.html:124 -#: workflows/templates/workflows/requests_dashboard.html:266 +#: workflows/templates/workflows/integrations_setup.html:59 +#: workflows/templates/workflows/integrations_setup.html:121 +#: workflows/templates/workflows/requests_dashboard.html:262 msgid "Status:" msgstr "Status:" -#: workflows/templates/workflows/integrations_setup.html:65 -#, fuzzy -#| msgid "Nextcloud speichern" +#: workflows/templates/workflows/integrations_setup.html:62 msgid "Nextcloud schalten" -msgstr "Save Nextcloud" +msgstr "Toggle Nextcloud" -#: workflows/templates/workflows/integrations_setup.html:71 +#: workflows/templates/workflows/integrations_setup.html:68 msgid "" "Schaltet den produktiven Nextcloud-Upload sofort für alle nachfolgenden " "Vorgänge ein oder aus." msgstr "" +"Turns productive Nextcloud upload on or off immediately for all following " +"workflows." -#: workflows/templates/workflows/integrations_setup.html:72 -#: workflows/templates/workflows/integrations_setup.html:134 +#: workflows/templates/workflows/integrations_setup.html:69 +#: workflows/templates/workflows/integrations_setup.html:131 msgid "Leeres Passwortfeld lässt das bestehende Passwort unverändert." msgstr "Leaving the password field empty keeps the current password unchanged." -#: workflows/templates/workflows/integrations_setup.html:110 -#, fuzzy -#| msgid "Absenderadresse (optional)" +#: workflows/templates/workflows/integrations_setup.html:107 msgid "Absenderadresse" -msgstr "Sender address (optional)" +msgstr "Sender address" -#: workflows/templates/workflows/integrations_setup.html:115 +#: workflows/templates/workflows/integrations_setup.html:112 msgid "SMTP SSL" msgstr "SMTP SSL" -#: workflows/templates/workflows/integrations_setup.html:116 +#: workflows/templates/workflows/integrations_setup.html:113 msgid "SMTP TLS" msgstr "SMTP TLS" -#: workflows/templates/workflows/integrations_setup.html:119 +#: workflows/templates/workflows/integrations_setup.html:116 msgid "Mail speichern" msgstr "Save mail settings" -#: workflows/templates/workflows/integrations_setup.html:120 -#, fuzzy -#| msgid "SMTP-Test" +#: workflows/templates/workflows/integrations_setup.html:117 msgid "SMTP-Test starten" -msgstr "SMTP test" +msgstr "Run SMTP test" -#: workflows/templates/workflows/integrations_setup.html:127 -#, fuzzy -#| msgid "E-Mail Modus" +#: workflows/templates/workflows/integrations_setup.html:124 msgid "E-Mail Modus schalten" -msgstr "Email mode" +msgstr "Toggle email mode" -#: workflows/templates/workflows/integrations_setup.html:133 +#: workflows/templates/workflows/integrations_setup.html:130 msgid "" "Im Testmodus werden Systemmails umgeleitet. In Produktion werden sie an die " "echten Empfänger gesendet." msgstr "" +"In test mode, system emails are redirected. In production, they are sent to " +"the real recipients." -#: workflows/templates/workflows/integrations_setup.html:147 +#: workflows/templates/workflows/integrations_setup.html:144 msgid "It onboarding email" msgstr "IT onboarding email" -#: workflows/templates/workflows/integrations_setup.html:151 +#: workflows/templates/workflows/integrations_setup.html:148 msgid "General info email" msgstr "General info email" -#: workflows/templates/workflows/integrations_setup.html:155 +#: workflows/templates/workflows/integrations_setup.html:152 msgid "Business card email" msgstr "Business card email" -#: workflows/templates/workflows/integrations_setup.html:159 +#: workflows/templates/workflows/integrations_setup.html:156 msgid "Hr works email" msgstr "HR Works email" -#: workflows/templates/workflows/integrations_setup.html:163 +#: workflows/templates/workflows/integrations_setup.html:160 msgid "Key notification email" msgstr "Key notification email" -#: workflows/templates/workflows/integrations_setup.html:167 +#: workflows/templates/workflows/integrations_setup.html:164 msgid "Diese Empfänger werden für condition-based E-Mail Routing genutzt." msgstr "These recipients are used for condition-based email routing." -#: workflows/templates/workflows/integrations_setup.html:194 +#: workflows/templates/workflows/integrations_setup.html:191 msgid "E-Mail Routing & Vorlagen speichern" msgstr "Save email routing & templates" -#: workflows/templates/workflows/integrations_setup.html:200 +#: workflows/templates/workflows/integrations_setup.html:197 msgid "Bedingungsregeln für zusätzliche E-Mails" msgstr "Conditional rules for additional emails" -#: workflows/templates/workflows/integrations_setup.html:201 +#: workflows/templates/workflows/integrations_setup.html:198 msgid "Zusätzliche Regeln laufen nach dem Standard-Routing." msgstr "Additional rules run after the standard routing flow." -#: workflows/templates/workflows/integrations_setup.html:208 -#: workflows/templates/workflows/integrations_setup.html:279 +#: workflows/templates/workflows/integrations_setup.html:205 +#: workflows/templates/workflows/integrations_setup.html:276 msgid "Regelname" msgstr "Rule name" -#: workflows/templates/workflows/integrations_setup.html:212 -#: workflows/templates/workflows/integrations_setup.html:283 +#: workflows/templates/workflows/integrations_setup.html:209 +#: workflows/templates/workflows/integrations_setup.html:280 msgid "Event" msgstr "Event" -#: workflows/templates/workflows/integrations_setup.html:220 -#: workflows/templates/workflows/integrations_setup.html:291 +#: workflows/templates/workflows/integrations_setup.html:217 +#: workflows/templates/workflows/integrations_setup.html:288 msgid "Feldname" msgstr "Field name" -#: workflows/templates/workflows/integrations_setup.html:224 -#: workflows/templates/workflows/integrations_setup.html:295 -#: workflows/templates/workflows/intro_builder.html:67 +#: workflows/templates/workflows/integrations_setup.html:221 +#: workflows/templates/workflows/integrations_setup.html:292 +#: workflows/templates/workflows/intro_builder.html:63 msgid "Operator" msgstr "Operator" -#: workflows/templates/workflows/integrations_setup.html:232 -#: workflows/templates/workflows/integrations_setup.html:303 +#: workflows/templates/workflows/integrations_setup.html:229 +#: workflows/templates/workflows/integrations_setup.html:300 msgid "Vergleichswert" msgstr "Comparison value" -#: workflows/templates/workflows/integrations_setup.html:236 -#: workflows/templates/workflows/integrations_setup.html:307 -#: workflows/templates/workflows/welcome_emails.html:87 +#: workflows/templates/workflows/integrations_setup.html:233 +#: workflows/templates/workflows/integrations_setup.html:304 +#: workflows/templates/workflows/welcome_emails.html:83 msgid "Empfänger" msgstr "Recipients" -#: workflows/templates/workflows/integrations_setup.html:240 -#: workflows/templates/workflows/integrations_setup.html:311 +#: workflows/templates/workflows/integrations_setup.html:237 +#: workflows/templates/workflows/integrations_setup.html:308 msgid "Template Key (optional)" msgstr "Template key (optional)" -#: workflows/templates/workflows/integrations_setup.html:242 -#: workflows/templates/workflows/integrations_setup.html:313 +#: workflows/templates/workflows/integrations_setup.html:239 +#: workflows/templates/workflows/integrations_setup.html:310 msgid "-- Custom Betreff/Body verwenden --" msgstr "-- Use custom subject/body --" -#: workflows/templates/workflows/integrations_setup.html:249 -#: workflows/templates/workflows/integrations_setup.html:320 +#: workflows/templates/workflows/integrations_setup.html:246 +#: workflows/templates/workflows/integrations_setup.html:317 msgid "Custom Subject (optional)" msgstr "Custom subject (optional)" -#: workflows/templates/workflows/integrations_setup.html:253 -#: workflows/templates/workflows/integrations_setup.html:324 +#: workflows/templates/workflows/integrations_setup.html:250 +#: workflows/templates/workflows/integrations_setup.html:321 msgid "Custom Body (optional)" msgstr "Custom body (optional)" -#: workflows/templates/workflows/integrations_setup.html:257 -#: workflows/templates/workflows/integrations_setup.html:328 +#: workflows/templates/workflows/integrations_setup.html:254 +#: workflows/templates/workflows/integrations_setup.html:325 msgid "Custom Subject (EN, optional)" msgstr "Custom subject (EN, optional)" -#: workflows/templates/workflows/integrations_setup.html:261 -#: workflows/templates/workflows/integrations_setup.html:332 +#: workflows/templates/workflows/integrations_setup.html:258 +#: workflows/templates/workflows/integrations_setup.html:329 msgid "Custom Body (EN, optional)" msgstr "Custom body (EN, optional)" -#: workflows/templates/workflows/integrations_setup.html:267 -#: workflows/templates/workflows/integrations_setup.html:337 +#: workflows/templates/workflows/integrations_setup.html:264 +#: workflows/templates/workflows/integrations_setup.html:334 msgid "PDF anhängen" msgstr "Attach PDF" -#: workflows/templates/workflows/integrations_setup.html:272 +#: workflows/templates/workflows/integrations_setup.html:269 msgid "Noch keine zusätzlichen Regeln vorhanden." msgstr "No additional rules configured yet." -#: workflows/templates/workflows/integrations_setup.html:276 +#: workflows/templates/workflows/integrations_setup.html:273 msgid "Neue Regel hinzufügen" msgstr "Add new rule" -#: workflows/templates/workflows/integrations_setup.html:342 +#: workflows/templates/workflows/integrations_setup.html:339 msgid "Regeln speichern" msgstr "Save rules" -#: workflows/templates/workflows/integrations_setup.html:352 -#, fuzzy -#| msgid "Hardware-Übergabetermin" +#: workflows/templates/workflows/integrations_setup.html:349 msgid "Vorlauf Hardware-Übergabe (Tage)" -msgstr "Hardware handover date" +msgstr "Hardware handover lead time (days)" -#: workflows/templates/workflows/integrations_setup.html:364 -#, fuzzy -#| msgid "Regeln speichern" +#: workflows/templates/workflows/integrations_setup.html:361 msgid "Workflow-Regeln speichern" -msgstr "Save rules" +msgstr "Save workflow rules" -#: workflows/templates/workflows/integrations_setup.html:366 +#: workflows/templates/workflows/integrations_setup.html:363 msgid "" "Steuert den Mindestvorlauf für das gewünschte Übergabedatum der Geräte im " "Onboarding-Formular." msgstr "" +"Controls the minimum lead time for the desired device handover date in the " +"onboarding form." + +#: workflows/templates/workflows/integrations_setup.html:372 +msgid "Remote Backup aktiviert" +msgstr "Remote backup enabled" + +#: workflows/templates/workflows/integrations_setup.html:374 +msgid "Remote Kopie nach lokalem Bundle erstellen" +msgstr "Create remote copy after local bundle creation" + +#: workflows/templates/workflows/integrations_setup.html:378 +msgid "Remote Backup Zieltyp" +msgstr "Remote backup target type" + +#: workflows/templates/workflows/integrations_setup.html:386 +msgid "Nextcloud Backup-Verzeichnis" +msgstr "Nextcloud backup directory" + +#: workflows/templates/workflows/integrations_setup.html:390 +msgid "S3 Bucket (optional)" +msgstr "S3 bucket (optional)" + +#: workflows/templates/workflows/integrations_setup.html:394 +msgid "NFS Pfad (optional)" +msgstr "NFS path (optional)" + +#: workflows/templates/workflows/integrations_setup.html:399 +msgid "Backup-Einstellungen speichern" +msgstr "Save backup settings" + +#: workflows/templates/workflows/integrations_setup.html:401 +msgid "" +"Empfehlung: Nextcloud als erstes Remote-Ziel verwenden. S3 und NFS sind als " +"Zieltypen vorbereitet, aber noch nicht aktiv implementiert." +msgstr "" +"Recommendation: use Nextcloud as the first remote target. S3 and NFS are " +"prepared as target types but not yet actively implemented." + +#: workflows/templates/workflows/integrations_setup.html:402 +msgid "" +"Das Backup-Verzeichnis muss getrennt vom normalen Nextcloud Dokumentenordner " +"sein, z. B. Group-on-off-boarding-backups." +msgstr "" +"The backup directory must be separate from the normal Nextcloud document " +"folder, e.g. Group-on-off-boarding-backups." #: workflows/templates/workflows/intro_builder.html:18 msgid "Checklistenpunkte für das Einweisungs- und Übergabeprotokoll verwalten." msgstr "Manage checklist items for the introduction and handover protocol." -#: workflows/templates/workflows/intro_builder.html:33 -#: workflows/templates/workflows/intro_builder.html:63 +#: workflows/templates/workflows/intro_builder.html:29 +#: workflows/templates/workflows/intro_builder.html:59 msgid "Abschnitt" msgstr "Section" -#: workflows/templates/workflows/intro_builder.html:41 -#: workflows/templates/workflows/intro_builder.html:64 +#: workflows/templates/workflows/intro_builder.html:37 +#: workflows/templates/workflows/intro_builder.html:60 msgid "Checklistenpunkt (DE)" msgstr "Checklist item (DE)" -#: workflows/templates/workflows/intro_builder.html:42 +#: workflows/templates/workflows/intro_builder.html:38 msgid "z. B. Nextcloud Ordnerstruktur erklärt" msgstr "e.g. Nextcloud folder structure explained" -#: workflows/templates/workflows/intro_builder.html:45 +#: workflows/templates/workflows/intro_builder.html:41 msgid "Checklist item (EN)" msgstr "Checklist item (EN)" -#: workflows/templates/workflows/intro_builder.html:46 +#: workflows/templates/workflows/intro_builder.html:42 msgid "e.g. Nextcloud folder structure explained" msgstr "e.g. Nextcloud folder structure explained" -#: workflows/templates/workflows/intro_builder.html:49 +#: workflows/templates/workflows/intro_builder.html:45 msgid "Punkt hinzufügen" msgstr "Add item" -#: workflows/templates/workflows/intro_builder.html:52 +#: workflows/templates/workflows/intro_builder.html:48 msgid "" "Bedingungen und Sortierung können anschließend in der Tabelle bearbeitet " "werden." msgstr "Conditions and sort order can then be edited in the table." -#: workflows/templates/workflows/intro_builder.html:65 +#: workflows/templates/workflows/intro_builder.html:61 msgid "Checklistenpunkt (EN)" msgstr "Checklist item (EN)" -#: workflows/templates/workflows/intro_builder.html:66 +#: workflows/templates/workflows/intro_builder.html:62 msgid "Feld-Bedingung" msgstr "Field condition" -#: workflows/templates/workflows/intro_builder.html:68 +#: workflows/templates/workflows/intro_builder.html:64 msgid "Wert" msgstr "Value" -#: workflows/templates/workflows/intro_builder.html:103 +#: workflows/templates/workflows/intro_builder.html:99 msgid "z. B. HR Works" msgstr "e.g. HR Works" -#: workflows/templates/workflows/intro_builder.html:106 +#: workflows/templates/workflows/intro_builder.html:102 msgid "Checklistenpunkt wirklich löschen?" msgstr "Delete this checklist item?" -#: workflows/templates/workflows/intro_builder.html:110 +#: workflows/templates/workflows/intro_builder.html:106 msgid "" "Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste " "leer ist, nutzt das System die integrierten Standardpunkte." @@ -1259,11 +1508,11 @@ msgstr "" "No custom checklist items have been created yet. As long as the list is " "empty, the system uses the built-in default items." -#: workflows/templates/workflows/intro_builder.html:115 +#: workflows/templates/workflows/intro_builder.html:111 msgid "Reihenfolge folgt derzeit der Tabellenreihenfolge beim Speichern." msgstr "Order currently follows the table order when saving." -#: workflows/templates/workflows/intro_builder.html:117 +#: workflows/templates/workflows/intro_builder.html:113 msgid "Checkliste speichern" msgstr "Save checklist" @@ -1300,7 +1549,7 @@ msgid "z. B. max.mustermann@tub.co" msgstr "e.g. john.doe@tub.co" #: workflows/templates/workflows/offboarding_form.html:33 -#: workflows/templates/workflows/requests_dashboard.html:167 +#: workflows/templates/workflows/requests_dashboard.html:163 msgid "Suchen" msgstr "Search" @@ -1343,7 +1592,7 @@ msgstr "" #: workflows/templates/workflows/offboarding_success.html:23 #: workflows/templates/workflows/onboarding_success.html:22 #: workflows/templates/workflows/request_timeline.html:104 -#: workflows/templates/workflows/requests_dashboard.html:221 +#: workflows/templates/workflows/requests_dashboard.html:217 msgid "PDF öffnen" msgstr "Open PDF" @@ -1389,7 +1638,7 @@ msgstr "Please check the highlighted fields. Invalid input was detected." #: workflows/templates/workflows/onboarding_form.html:77 #: workflows/templates/workflows/onboarding_form.html:114 #: workflows/templates/workflows/onboarding_form.html:116 -#: workflows/templates/workflows/welcome_emails.html:69 +#: workflows/templates/workflows/welcome_emails.html:65 msgid "Alle auswählen" msgstr "Select all" @@ -1436,98 +1685,98 @@ msgid "" "markieren." msgstr "" -#: workflows/templates/workflows/onboarding_intro_session.html:29 -#: workflows/templates/workflows/welcome_emails.html:86 +#: workflows/templates/workflows/onboarding_intro_session.html:25 +#: workflows/templates/workflows/welcome_emails.html:82 msgid "Mitarbeitende Person" msgstr "Employee" -#: workflows/templates/workflows/onboarding_intro_session.html:31 +#: workflows/templates/workflows/onboarding_intro_session.html:27 #: workflows/templates/workflows/request_timeline.html:66 msgid "Name" msgstr "Name" -#: workflows/templates/workflows/onboarding_intro_session.html:33 +#: workflows/templates/workflows/onboarding_intro_session.html:29 msgid "Berufsbezeichnung" msgstr "Job title" -#: workflows/templates/workflows/onboarding_intro_session.html:34 +#: workflows/templates/workflows/onboarding_intro_session.html:30 msgid "Dienstliche E-Mail" msgstr "Work email" -#: workflows/templates/workflows/onboarding_intro_session.html:35 -#: workflows/views.py:413 +#: workflows/templates/workflows/onboarding_intro_session.html:31 +#: workflows/views.py:487 msgid "Vertragsbeginn" msgstr "Contract start" -#: workflows/templates/workflows/onboarding_intro_session.html:39 +#: workflows/templates/workflows/onboarding_intro_session.html:35 msgid "Sitzungsstatus" msgstr "Session status" -#: workflows/templates/workflows/onboarding_intro_session.html:43 +#: workflows/templates/workflows/onboarding_intro_session.html:39 msgid "Abgeschlossen von" msgstr "Completed by" -#: workflows/templates/workflows/onboarding_intro_session.html:44 +#: workflows/templates/workflows/onboarding_intro_session.html:40 msgid "Abgeschlossen am" msgstr "Completed at" -#: workflows/templates/workflows/onboarding_intro_session.html:45 +#: workflows/templates/workflows/onboarding_intro_session.html:41 msgid "Letzte Änderung" msgstr "Last updated" -#: workflows/templates/workflows/onboarding_intro_session.html:53 +#: workflows/templates/workflows/onboarding_intro_session.html:49 msgid "Fortschritt der Einweisung" msgstr "Introduction progress" -#: workflows/templates/workflows/onboarding_intro_session.html:54 +#: workflows/templates/workflows/onboarding_intro_session.html:50 #, python-format msgid "%(checked)s von %(total)s Punkten erledigt" msgstr "%(checked)s of %(total)s items completed" -#: workflows/templates/workflows/onboarding_intro_session.html:78 +#: workflows/templates/workflows/onboarding_intro_session.html:74 msgid "Notizen" msgstr "Notes" -#: workflows/templates/workflows/onboarding_intro_session.html:80 +#: workflows/templates/workflows/onboarding_intro_session.html:76 msgid "" "Diese Seite bleibt bewusst einfach: echte Web-Checkboxen, Notizen und ein " "klarer Entwurf/Abschluss-Status. Kein zusätzlicher komplexer PDF-Signatur-" "Workflow." msgstr "" -#: workflows/templates/workflows/onboarding_intro_session.html:82 +#: workflows/templates/workflows/onboarding_intro_session.html:78 msgid "Als Entwurf speichern" msgstr "Save as draft" -#: workflows/templates/workflows/onboarding_intro_session.html:83 +#: workflows/templates/workflows/onboarding_intro_session.html:79 msgid "Als abgeschlossen markieren" msgstr "Mark as completed" -#: workflows/templates/workflows/onboarding_intro_session.html:84 +#: workflows/templates/workflows/onboarding_intro_session.html:80 msgid "Einweisung wirklich zurücksetzen?" msgstr "Reset the introduction session?" -#: workflows/templates/workflows/onboarding_intro_session.html:84 +#: workflows/templates/workflows/onboarding_intro_session.html:80 msgid "Alles zurücksetzen" msgstr "Reset all" -#: workflows/templates/workflows/onboarding_intro_session.html:90 -#: workflows/templates/workflows/requests_dashboard.html:239 +#: workflows/templates/workflows/onboarding_intro_session.html:86 +#: workflows/templates/workflows/requests_dashboard.html:235 msgid "Live-Protokoll" msgstr "Live protocol" -#: workflows/templates/workflows/onboarding_intro_session.html:91 +#: workflows/templates/workflows/onboarding_intro_session.html:87 msgid "" "Erzeugt das Live-Protokoll nur aus den aktuell gespeicherten Haken und " "Notizen." msgstr "" -#: workflows/templates/workflows/onboarding_intro_session.html:95 +#: workflows/templates/workflows/onboarding_intro_session.html:91 msgid "Live-Protokoll erzeugen" msgstr "Generate live protocol" -#: workflows/templates/workflows/onboarding_intro_session.html:98 -#: workflows/templates/workflows/requests_dashboard.html:243 +#: workflows/templates/workflows/onboarding_intro_session.html:94 +#: workflows/templates/workflows/requests_dashboard.html:239 msgid "Live-Protokoll öffnen" msgstr "Open live protocol" @@ -1714,7 +1963,7 @@ msgid "Request Timeline" msgstr "" #: workflows/templates/workflows/request_timeline.html:74 -#: workflows/templates/workflows/requests_dashboard.html:194 +#: workflows/templates/workflows/requests_dashboard.html:190 msgid "E-Mail" msgstr "Email" @@ -1788,108 +2037,108 @@ msgstr "Activity 14 Days" msgid "Zeitraum des visuellen Aktivitätsverlaufs in dieser Übersicht." msgstr "Time span of the visual activity timeline in this overview." -#: workflows/templates/workflows/requests_dashboard.html:118 +#: workflows/templates/workflows/requests_dashboard.html:114 msgid "Vorgänge" msgstr "Requests" -#: workflows/templates/workflows/requests_dashboard.html:119 +#: workflows/templates/workflows/requests_dashboard.html:115 msgid "" "Dokumente, Status und Einweisungsaktionen in einer verdichteten " "Arbeitsansicht." msgstr "Documents, status, and introduction actions in a condensed work view." -#: workflows/templates/workflows/requests_dashboard.html:121 +#: workflows/templates/workflows/requests_dashboard.html:117 #, python-format msgid "%(count)s Einträge sichtbar" msgstr "%(count)s entries visible" -#: workflows/templates/workflows/requests_dashboard.html:128 +#: workflows/templates/workflows/requests_dashboard.html:124 msgid "Nach Name oder E-Mail suchen" msgstr "Search by name or email" -#: workflows/templates/workflows/requests_dashboard.html:158 +#: workflows/templates/workflows/requests_dashboard.html:154 msgid "Von" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:162 +#: workflows/templates/workflows/requests_dashboard.html:158 msgid "Bis" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:176 +#: workflows/templates/workflows/requests_dashboard.html:172 #, fuzzy #| msgid "Ausgewählte Welcome-Einträge wirklich löschen?" msgid "Ausgewählte Einträge wirklich löschen?" msgstr "Delete the selected welcome entries?" -#: workflows/templates/workflows/requests_dashboard.html:179 -#: workflows/templates/workflows/welcome_emails.html:78 +#: workflows/templates/workflows/requests_dashboard.html:175 +#: workflows/templates/workflows/welcome_emails.html:74 msgid "ausgewählt" msgstr "selected" -#: workflows/templates/workflows/requests_dashboard.html:180 +#: workflows/templates/workflows/requests_dashboard.html:176 msgid "Auswahl löschen" msgstr "Delete selection" -#: workflows/templates/workflows/requests_dashboard.html:193 +#: workflows/templates/workflows/requests_dashboard.html:189 msgid "Person" msgstr "Person" -#: workflows/templates/workflows/requests_dashboard.html:195 +#: workflows/templates/workflows/requests_dashboard.html:191 msgid "Dokument" msgstr "Document" -#: workflows/templates/workflows/requests_dashboard.html:196 -#: workflows/templates/workflows/requests_dashboard.html:236 +#: workflows/templates/workflows/requests_dashboard.html:192 +#: workflows/templates/workflows/requests_dashboard.html:232 msgid "Einweisung" msgstr "Introduction" -#: workflows/templates/workflows/requests_dashboard.html:223 +#: workflows/templates/workflows/requests_dashboard.html:219 msgid "Noch nicht verfügbar" msgstr "Not available yet" -#: workflows/templates/workflows/requests_dashboard.html:241 +#: workflows/templates/workflows/requests_dashboard.html:237 msgid "Einweisung öffnen" msgstr "Open introduction" -#: workflows/templates/workflows/requests_dashboard.html:248 +#: workflows/templates/workflows/requests_dashboard.html:244 msgid "Standard-Einweisungs-PDF" msgstr "Standard introduction PDF" -#: workflows/templates/workflows/requests_dashboard.html:253 +#: workflows/templates/workflows/requests_dashboard.html:249 msgid "Neu erzeugen" msgstr "Regenerate" -#: workflows/templates/workflows/requests_dashboard.html:255 +#: workflows/templates/workflows/requests_dashboard.html:251 msgid "Standard-PDF öffnen" msgstr "Open standard PDF" -#: workflows/templates/workflows/requests_dashboard.html:259 +#: workflows/templates/workflows/requests_dashboard.html:255 msgid "PDF erzeugen" msgstr "Generate PDF" -#: workflows/templates/workflows/requests_dashboard.html:270 +#: workflows/templates/workflows/requests_dashboard.html:266 msgid "Nicht relevant" msgstr "Not relevant" -#: workflows/templates/workflows/requests_dashboard.html:274 +#: workflows/templates/workflows/requests_dashboard.html:270 msgid "Timeline" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:276 +#: workflows/templates/workflows/requests_dashboard.html:272 msgid "Eintrag erneut verarbeiten?" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:278 +#: workflows/templates/workflows/requests_dashboard.html:274 msgid "Erneut versuchen" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:281 +#: workflows/templates/workflows/requests_dashboard.html:277 #, fuzzy #| msgid "Option wirklich löschen?" msgid "Eintrag wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/requests_dashboard.html:290 +#: workflows/templates/workflows/requests_dashboard.html:286 msgid "Noch keine Vorgänge vorhanden." msgstr "No requests available yet." @@ -1905,345 +2154,397 @@ msgstr "" "Configure welcome emails and control scheduled messages (send now, pause, " "resume, cancel)." -#: workflows/templates/workflows/welcome_emails.html:27 +#: workflows/templates/workflows/welcome_emails.html:23 msgid "Verzögerung in Tagen" msgstr "Delay in days" -#: workflows/templates/workflows/welcome_emails.html:31 +#: workflows/templates/workflows/welcome_emails.html:27 msgid "Absenderadresse (optional)" msgstr "Sender address (optional)" -#: workflows/templates/workflows/welcome_emails.html:32 +#: workflows/templates/workflows/welcome_emails.html:28 msgid "Leer = System-Absender" msgstr "Empty = system sender" -#: workflows/templates/workflows/welcome_emails.html:52 +#: workflows/templates/workflows/welcome_emails.html:48 msgid "Onboarding-PDF anhängen" msgstr "Attach onboarding PDF" -#: workflows/templates/workflows/welcome_emails.html:55 +#: workflows/templates/workflows/welcome_emails.html:51 msgid "Verfügbare Keywords:" msgstr "Available keywords:" -#: workflows/templates/workflows/welcome_emails.html:61 +#: workflows/templates/workflows/welcome_emails.html:57 msgid "Welcome-Einstellungen speichern" msgstr "Save welcome settings" -#: workflows/templates/workflows/welcome_emails.html:65 +#: workflows/templates/workflows/welcome_emails.html:61 msgid "Bitte mindestens einen Welcome-Eintrag auswählen." msgstr "Please select at least one welcome entry." -#: workflows/templates/workflows/welcome_emails.html:65 +#: workflows/templates/workflows/welcome_emails.html:61 msgid "Ausgewählte Welcome-Einträge wirklich löschen?" msgstr "Delete the selected welcome entries?" -#: workflows/templates/workflows/welcome_emails.html:65 +#: workflows/templates/workflows/welcome_emails.html:61 msgid "Ausgewählte Welcome-Einträge pausieren?" msgstr "Pause the selected welcome entries?" -#: workflows/templates/workflows/welcome_emails.html:65 +#: workflows/templates/workflows/welcome_emails.html:61 msgid "Ausgewählte Welcome-Einträge sofort senden?" msgstr "Send the selected welcome entries now?" -#: workflows/templates/workflows/welcome_emails.html:72 -#: workflows/templates/workflows/welcome_emails.html:127 +#: workflows/templates/workflows/welcome_emails.html:68 +#: workflows/templates/workflows/welcome_emails.html:123 msgid "Pausieren" msgstr "Pause" -#: workflows/templates/workflows/welcome_emails.html:73 -#: workflows/templates/workflows/welcome_emails.html:121 +#: workflows/templates/workflows/welcome_emails.html:69 +#: workflows/templates/workflows/welcome_emails.html:117 msgid "Sofort senden" msgstr "Send now" -#: workflows/templates/workflows/welcome_emails.html:77 +#: workflows/templates/workflows/welcome_emails.html:73 msgid "Bulk ausführen" msgstr "Run bulk action" -#: workflows/templates/workflows/welcome_emails.html:84 +#: workflows/templates/workflows/welcome_emails.html:80 msgid "Auswahl" msgstr "Select" -#: workflows/templates/workflows/welcome_emails.html:88 +#: workflows/templates/workflows/welcome_emails.html:84 msgid "Geplant für" msgstr "Scheduled for" -#: workflows/templates/workflows/welcome_emails.html:90 +#: workflows/templates/workflows/welcome_emails.html:86 msgid "Gesendet am" msgstr "Sent at" -#: workflows/templates/workflows/welcome_emails.html:132 +#: workflows/templates/workflows/welcome_emails.html:128 msgid "Fortsetzen" msgstr "Resume" -#: workflows/templates/workflows/welcome_emails.html:145 +#: workflows/templates/workflows/welcome_emails.html:141 msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:86 +#: workflows/views.py:87 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:87 +#: workflows/views.py:88 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:88 +#: workflows/views.py:89 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:89 +#: workflows/views.py:90 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:190 +#: workflows/views.py:191 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:191 +#: workflows/views.py:192 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:192 +#: workflows/views.py:193 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:193 +#: workflows/views.py:194 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:194 +#: workflows/views.py:195 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:195 +#: workflows/views.py:196 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:196 +#: workflows/views.py:197 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:197 +#: workflows/views.py:198 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:198 +#: workflows/views.py:199 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:199 +#: workflows/views.py:200 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:200 +#: workflows/views.py:201 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:201 +#: workflows/views.py:202 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:202 +#: workflows/views.py:203 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:203 +#: workflows/views.py:204 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:204 +#: workflows/views.py:205 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:205 +#: workflows/views.py:206 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:206 +#: workflows/views.py:207 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:207 +#: workflows/views.py:208 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:208 +#: workflows/views.py:209 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:209 +#: workflows/views.py:210 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:210 +#: workflows/views.py:211 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:211 +#: workflows/views.py:212 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:212 +#: workflows/views.py:213 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:213 +#: workflows/views.py:214 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:214 +#: workflows/views.py:215 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:215 +#: workflows/views.py:216 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:216 +#: workflows/views.py:217 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:217 +#: workflows/views.py:218 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:218 +#: workflows/views.py:219 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:219 +#: workflows/views.py:220 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:401 +#: workflows/views.py:221 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "Backup erstellt" +msgstr "Request saved" + +#: workflows/views.py:222 +msgid "Backup verifiziert" +msgstr "" + +#: workflows/views.py:223 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "Backup gelöscht" +msgstr "Request saved" + +#: workflows/views.py:224 +#, fuzzy +#| msgid "Welcome-Einstellungen speichern" +msgid "Backup-Einstellungen gespeichert" +msgstr "Save welcome settings" + +#: workflows/views.py:408 +#, python-format +msgid "Backup wurde erstellt: %(name)s" +msgstr "" + +#: workflows/views.py:410 +#, python-format +msgid "Backup konnte nicht erstellt werden: %(error)s" +msgstr "" + +#: workflows/views.py:427 +#, python-format +msgid "Backup wurde verifiziert: %(name)s" +msgstr "" + +#: workflows/views.py:429 +#, python-format +msgid "Backup-Verifikation fehlgeschlagen: %(error)s" +msgstr "" + +#: workflows/views.py:446 +#, python-format +msgid "Backup wurde gelöscht: %(name)s" +msgstr "" + +#: workflows/views.py:448 +#, python-format +msgid "Backup konnte nicht gelöscht werden: %(error)s" +msgstr "" + +#: workflows/views.py:475 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:403 +#: workflows/views.py:477 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:415 +#: workflows/views.py:489 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:425 +#: workflows/views.py:499 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:427 +#: workflows/views.py:501 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:436 +#: workflows/views.py:510 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:462 +#: workflows/views.py:536 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:474 +#: workflows/views.py:548 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:500 +#: workflows/views.py:574 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:509 +#: workflows/views.py:583 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:552 +#: workflows/views.py:626 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:554 +#: workflows/views.py:628 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:556 +#: workflows/views.py:630 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:776 +#: workflows/views.py:850 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:794 +#: workflows/views.py:868 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:824 +#: workflows/views.py:898 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:838 +#: workflows/views.py:912 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:851 +#: workflows/views.py:925 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." @@ -2255,17 +2556,9 @@ msgstr "Introduction was saved as draft." #~ msgid "Nextcloud deaktivieren" #~ msgstr "Save Nextcloud" -#, fuzzy -#~| msgid "Nextcloud schalten" -#~ msgid "Nextcloud aktivieren" -#~ msgstr "Toggle Nextcloud" - #~ msgid "Aktiv/Inaktiv direkt umschalten." #~ msgstr "Switch active/inactive directly." -#~ msgid "Deaktivieren" -#~ msgstr "Disable" - #~ msgid "Aktivieren" #~ msgstr "Enable" @@ -2293,9 +2586,6 @@ msgstr "Introduction was saved as draft." #~ msgid "Testupload und Testmail auslösen." #~ msgstr "Trigger test upload and test email." -#~ msgid "Erstellt" -#~ msgstr "Created" - #~ msgid "" #~ "Datensätze können direkt in der Tabelle gefiltert, geöffnet, geprüft oder " #~ "gelöscht werden." diff --git a/backend/workflows/backup_ops.py b/backend/workflows/backup_ops.py new file mode 100644 index 0000000..8b6ec7c --- /dev/null +++ b/backend/workflows/backup_ops.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import tarfile +import tempfile +from datetime import datetime +from pathlib import Path + +from django.conf import settings +from django.utils import timezone +from django.utils.translation import gettext as _ + +from .models import WorkflowConfig +from .services import delete_from_nextcloud, upload_to_nextcloud + + +def _backup_root() -> Path: + root = Path(settings.BACKUP_OUTPUT_DIR) + root.mkdir(parents=True, exist_ok=True) + return root + + +def _metadata_path(backup_dir: Path) -> Path: + return backup_dir / 'backup_meta.json' + + +def _checksums_path(backup_dir: Path) -> Path: + return backup_dir / 'SHA256SUMS' + + +def _db_env() -> dict[str, str]: + db = settings.DATABASES['default'] + env = os.environ.copy() + env['PGPASSWORD'] = str(db['PASSWORD']) + return env + + +def _db_base_args() -> list[str]: + db = settings.DATABASES['default'] + return [ + '-h', str(db['HOST']), + '-p', str(db['PORT']), + '-U', str(db['USER']), + ] + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open('rb') as handle: + for chunk in iter(lambda: handle.read(65536), b''): + digest.update(chunk) + return digest.hexdigest() + + +def _write_checksums(backup_dir: Path, db_dump_path: Path, media_archive_path: Path) -> None: + _checksums_path(backup_dir).write_text( + f'{_sha256(db_dump_path)} {db_dump_path.name}\n{_sha256(media_archive_path)} {media_archive_path.name}\n', + encoding='utf-8', + ) + + +def _ignorable_pg_restore(stderr: str) -> bool: + text = (stderr or '').strip() + if not text: + return False + normalized = ' '.join(line.strip() for line in text.splitlines()) + return ( + 'unrecognized configuration parameter "transaction_timeout"' in normalized + and 'errors ignored on restore: 1' in normalized + ) + + +def _load_metadata(backup_dir: Path) -> dict: + path = _metadata_path(backup_dir) + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding='utf-8')) + except json.JSONDecodeError: + return {} + + +def _save_metadata(backup_dir: Path, payload: dict) -> None: + _metadata_path(backup_dir).write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding='utf-8') + + +def list_backup_bundles() -> list[dict]: + rows: list[dict] = [] + for entry in sorted(_backup_root().glob('backup_*'), reverse=True): + if not entry.is_dir(): + continue + meta = _load_metadata(entry) + rows.append( + { + 'name': entry.name, + 'path': str(entry), + 'created_at': meta.get('created_at') or '', + 'verified_at': meta.get('verified_at') or '', + 'verify_status': meta.get('verify_status') or '', + 'summary': meta.get('verify_summary') or '', + 'remote_status': meta.get('remote_status') or '', + 'remote_summary': meta.get('remote_summary') or '', + 'remote_target_type': meta.get('remote_target_type') or '', + 'remote_path': meta.get('remote_path') or '', + 'db_dump_exists': (entry / 'db.dump').exists(), + 'media_archive_exists': (entry / 'media.tar.gz').exists(), + } + ) + return rows + + +def _remote_backup_config() -> dict: + config = WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first() + if not config: + return {'enabled': False, 'target_type': '', 'nextcloud_directory': ''} + return { + 'enabled': bool(config.remote_backup_enabled), + 'target_type': (config.remote_backup_target_type or '').strip(), + 'nextcloud_directory': (config.remote_backup_nextcloud_directory or '').strip().strip('/'), + 's3_bucket': (config.remote_backup_s3_bucket or '').strip(), + 'nfs_path': (config.remote_backup_nfs_path or '').strip(), + } + + +def _upload_bundle_remote(backup_dir: Path, payload: dict) -> dict: + remote = _remote_backup_config() + payload.update( + { + 'remote_status': '', + 'remote_summary': '', + 'remote_target_type': remote['target_type'], + 'remote_path': '', + } + ) + if not remote['enabled']: + payload['remote_status'] = 'disabled' + payload['remote_summary'] = str(_('Remote Backup ist deaktiviert.')) + return payload + + if remote['target_type'] != 'nextcloud': + payload['remote_status'] = 'not_implemented' + payload['remote_summary'] = _('Zieltyp %(target)s ist vorbereitet, aber noch nicht implementiert.') % {'target': remote['target_type'] or '-'} + return payload + + remote_directory = remote['nextcloud_directory'] + if not remote_directory: + payload['remote_status'] = 'failed' + payload['remote_summary'] = str(_('Nextcloud Backup-Verzeichnis fehlt.')) + return payload + + remote_bundle_dir = f'{remote_directory}/{backup_dir.name}' + files_to_upload = ['db.dump', 'media.tar.gz', 'SHA256SUMS'] + uploaded_files: list[str] = [] + for filename in files_to_upload: + local_path = backup_dir / filename + if not local_path.exists(): + continue + ok = upload_to_nextcloud( + local_path, + filename, + directory_override=remote_bundle_dir, + require_enabled=False, + ) + if not ok: + payload['remote_status'] = 'failed' + payload['remote_summary'] = _('Upload nach Nextcloud fehlgeschlagen bei %(file)s.') % {'file': filename} + payload['remote_path'] = remote_bundle_dir + return payload + uploaded_files.append(filename) + + payload['remote_status'] = 'uploaded' + payload['remote_summary'] = _('Nach Nextcloud hochgeladen: %(count)s Datei(en).') % {'count': len(uploaded_files)} + payload['remote_uploaded_at'] = timezone.now().isoformat() + payload['remote_target_type'] = 'nextcloud' + payload['remote_path'] = remote_bundle_dir + payload['remote_files'] = uploaded_files + return payload + + +def create_backup_bundle() -> dict: + timestamp = timezone.localtime().strftime('%Y%m%d_%H%M%S') + backup_dir = _backup_root() / f'backup_{timestamp}' + backup_dir.mkdir(parents=True, exist_ok=False) + + db_dump_path = backup_dir / 'db.dump' + media_archive_path = backup_dir / 'media.tar.gz' + + db = settings.DATABASES['default'] + subprocess.run( + [ + 'pg_dump', + *_db_base_args(), + '-d', str(db['NAME']), + '-Fc', + '--no-owner', + '--no-privileges', + '-f', str(db_dump_path), + ], + check=True, + env=_db_env(), + ) + + with tarfile.open(media_archive_path, 'w:gz') as archive: + archive.add(settings.MEDIA_ROOT, arcname='media') + + payload = { + 'created_at': timezone.now().isoformat(), + 'postgres_db': str(db['NAME']), + 'postgres_user': str(db['USER']), + 'db_dump_file': db_dump_path.name, + 'media_archive_file': media_archive_path.name, + 'verify_status': '', + 'verified_at': '', + 'verify_summary': '', + } + _save_metadata(backup_dir, payload) + _write_checksums(backup_dir, db_dump_path, media_archive_path) + payload = _upload_bundle_remote(backup_dir, payload) + _save_metadata(backup_dir, payload) + if payload.get('remote_status') == 'uploaded' and payload.get('remote_path'): + upload_to_nextcloud( + _metadata_path(backup_dir), + _metadata_path(backup_dir).name, + directory_override=payload['remote_path'], + require_enabled=False, + ) + return {'name': backup_dir.name, 'path': str(backup_dir)} + + +def verify_backup_bundle(backup_name: str) -> dict: + backup_dir = _backup_root() / backup_name + db_dump_path = backup_dir / 'db.dump' + media_archive_path = backup_dir / 'media.tar.gz' + if not backup_dir.exists() or not db_dump_path.exists() or not media_archive_path.exists(): + raise FileNotFoundError(_('Backup-Dateien nicht gefunden.')) + + verify_db = f'{settings.DATABASES["default"]["NAME"]}_verify_{int(timezone.now().timestamp())}' + env = _db_env() + args = _db_base_args() + meta = _load_metadata(backup_dir) + + try: + subprocess.run( + ['psql', *args, '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-c', f'CREATE DATABASE "{verify_db}";'], + check=True, + env=env, + capture_output=True, + text=True, + ) + restore = subprocess.run( + ['pg_restore', *args, '-d', verify_db, '--no-owner', '--no-privileges', str(db_dump_path)], + env=env, + capture_output=True, + text=True, + ) + if restore.returncode != 0 and not _ignorable_pg_restore(restore.stderr): + raise subprocess.CalledProcessError( + restore.returncode, + restore.args, + output=restore.stdout, + stderr=restore.stderr, + ) + with connection.cursor() as cursor: + pass + table_count = subprocess.check_output( + ['psql', *args, '-d', verify_db, '-t', '-A', '-c', "SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';"], + env=env, + text=True, + ).strip() + onboarding_count = subprocess.check_output( + ['psql', *args, '-d', verify_db, '-t', '-A', '-c', 'SELECT COUNT(*) FROM workflows_onboardingrequest;'], + env=env, + text=True, + ).strip() + offboarding_count = subprocess.check_output( + ['psql', *args, '-d', verify_db, '-t', '-A', '-c', 'SELECT COUNT(*) FROM workflows_offboardingrequest;'], + env=env, + text=True, + ).strip() + with tempfile.TemporaryDirectory(prefix='tubco_backup_verify_media_') as tmpdir: + with tarfile.open(media_archive_path, 'r:gz') as archive: + archive.extractall(tmpdir, filter='data') + media_dir = Path(tmpdir) / 'media' + if not media_dir.exists(): + raise RuntimeError(_('Media-Archiv enthält kein media/-Verzeichnis.')) + media_file_count = sum(1 for path in media_dir.rglob('*') if path.is_file()) + summary = _('%(tables)s Tabellen, %(onboarding)s Onboarding, %(offboarding)s Offboarding, %(media)s Mediendateien geprüft.') % { + 'tables': table_count, + 'onboarding': onboarding_count, + 'offboarding': offboarding_count, + 'media': media_file_count, + } + meta['verified_at'] = timezone.now().isoformat() + meta['verify_status'] = 'verified' + meta['verify_summary'] = summary + _save_metadata(backup_dir, meta) + return {'name': backup_name, 'summary': summary} + finally: + subprocess.run( + ['psql', *args, '-d', 'postgres', '-v', 'ON_ERROR_STOP=1', '-c', f'DROP DATABASE IF EXISTS "{verify_db}";'], + check=False, + env=env, + capture_output=True, + text=True, + ) + + +def delete_backup_bundle(backup_name: str) -> dict: + backup_dir = (_backup_root() / backup_name).resolve() + backup_root = _backup_root().resolve() + if backup_root not in backup_dir.parents: + raise ValueError(_('Ungültiger Backup-Pfad.')) + if not backup_dir.exists() or not backup_dir.is_dir(): + raise FileNotFoundError(_('Backup-Dateien nicht gefunden.')) + meta = _load_metadata(backup_dir) + if meta.get('remote_status') == 'uploaded' and meta.get('remote_target_type') == 'nextcloud' and meta.get('remote_path'): + ok = delete_from_nextcloud(meta['remote_path'], directory_override='') + if not ok: + raise RuntimeError(_('Remote Backup in Nextcloud konnte nicht gelöscht werden.')) + shutil.rmtree(backup_dir) + return {'name': backup_name} diff --git a/backend/workflows/migrations/0035_workflowconfig_remote_backup_enabled_and_more.py b/backend/workflows/migrations/0035_workflowconfig_remote_backup_enabled_and_more.py new file mode 100644 index 0000000..3a5067d --- /dev/null +++ b/backend/workflows/migrations/0035_workflowconfig_remote_backup_enabled_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.5 on 2026-03-26 00:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0034_workflowconfig_device_handover_lead_days'), + ] + + operations = [ + migrations.AddField( + model_name='workflowconfig', + name='remote_backup_enabled', + field=models.BooleanField(default=False, verbose_name='Remote Backup aktiviert'), + ), + migrations.AddField( + model_name='workflowconfig', + name='remote_backup_nextcloud_directory', + field=models.CharField(blank=True, max_length=255, verbose_name='Nextcloud Backup-Verzeichnis'), + ), + migrations.AddField( + model_name='workflowconfig', + name='remote_backup_nfs_path', + field=models.CharField(blank=True, max_length=255, verbose_name='NFS Pfad (optional)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='remote_backup_s3_bucket', + field=models.CharField(blank=True, max_length=255, verbose_name='S3 Bucket (optional)'), + ), + migrations.AddField( + model_name='workflowconfig', + name='remote_backup_target_type', + field=models.CharField(choices=[('nextcloud', 'Nextcloud'), ('s3', 'S3'), ('nfs', 'NFS')], default='nextcloud', max_length=20, verbose_name='Remote Backup Zieltyp'), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index b68b1f1..7f6498c 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -387,6 +387,12 @@ class OnboardingIntroductionSession(models.Model): class WorkflowConfig(models.Model): + REMOTE_BACKUP_TARGET_CHOICES = [ + ('nextcloud', _('Nextcloud')), + ('s3', _('S3')), + ('nfs', _('NFS')), + ] + name = models.CharField(max_length=120, default='Default', unique=True) it_onboarding_email = models.EmailField(blank=True) general_info_email = models.EmailField(blank=True) @@ -413,6 +419,16 @@ class WorkflowConfig(models.Model): nextcloud_directory_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Verzeichnis (Override)') sync_interval_seconds = models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)') device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)') + remote_backup_enabled = models.BooleanField(default=False, verbose_name='Remote Backup aktiviert') + remote_backup_target_type = models.CharField( + max_length=20, + choices=REMOTE_BACKUP_TARGET_CHOICES, + default='nextcloud', + verbose_name='Remote Backup Zieltyp', + ) + remote_backup_nextcloud_directory = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Backup-Verzeichnis') + remote_backup_s3_bucket = models.CharField(max_length=255, blank=True, verbose_name='S3 Bucket (optional)') + remote_backup_nfs_path = models.CharField(max_length=255, blank=True, verbose_name='NFS Pfad (optional)') welcome_email_delay_days = models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)') welcome_sender_email = models.EmailField(blank=True, verbose_name='Welcome E-Mail Absender') welcome_include_pdf = models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang') diff --git a/backend/workflows/services.py b/backend/workflows/services.py index ec0882b..dfd8f8a 100644 --- a/backend/workflows/services.py +++ b/backend/workflows/services.py @@ -10,15 +10,19 @@ from .models import WorkflowConfig logger = logging.getLogger(__name__) +def _active_workflow_config() -> WorkflowConfig | None: + return WorkflowConfig.objects.filter(name='Default').order_by('-id').first() or WorkflowConfig.objects.order_by('id').first() + + def is_nextcloud_enabled() -> bool: - config = WorkflowConfig.objects.order_by('id').first() + config = _active_workflow_config() if config and config.nextcloud_enabled_override is not None: return bool(config.nextcloud_enabled_override) return bool(settings.NEXTCLOUD_ENABLED) def is_email_test_mode() -> bool: - config = WorkflowConfig.objects.order_by('id').first() + config = _active_workflow_config() if config and config.email_test_mode_override is not None: return bool(config.email_test_mode_override) return bool(settings.EMAIL_TEST_MODE) @@ -29,7 +33,7 @@ def get_email_test_redirect() -> str: def get_nextcloud_settings() -> dict[str, str]: - config = WorkflowConfig.objects.order_by('id').first() + config = _active_workflow_config() base_url = ( config.nextcloud_base_url_override.strip() if config and config.nextcloud_base_url_override.strip() @@ -58,20 +62,49 @@ def get_nextcloud_settings() -> dict[str, str]: } -def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool: - if not is_nextcloud_enabled(): +def _nextcloud_remote_url(base_url: str, directory: str, remote_path: str) -> str: + cleaned_parts = [part.strip('/') for part in [directory, remote_path] if part and part.strip('/')] + return f"{base_url}/{'/'.join(cleaned_parts)}" + + +def _ensure_nextcloud_directory(base_url: str, directory: str, auth: tuple[str, str], timeout: int) -> bool: + if not directory: + return False + current_parts: list[str] = [] + for part in [p for p in directory.split('/') if p]: + current_parts.append(part) + response = requests.request( + 'MKCOL', + f"{base_url}/{'/'.join(current_parts)}", + auth=auth, + timeout=timeout, + ) + if response.status_code in (201, 301, 405): + continue + logger.warning('Nextcloud directory ensure failed with status %s for %s', response.status_code, '/'.join(current_parts)) + return False + return True + + +def upload_to_nextcloud(local_file: Path, remote_filename: str, *, directory_override: str | None = None, require_enabled: bool = True) -> bool: + if require_enabled and not is_nextcloud_enabled(): return False nc = get_nextcloud_settings() base_url = nc['base_url'] - directory = nc['directory'] + directory_source = nc['directory'] if directory_override is None else directory_override + directory = (directory_source or '').strip('/') if not base_url or not directory: return False safe_remote_name = Path(remote_filename).name - remote_url = f"{base_url}/{directory}/{safe_remote_name}" + remote_url = _nextcloud_remote_url(base_url, directory, safe_remote_name) retries = max(0, int(getattr(settings, 'NEXTCLOUD_UPLOAD_RETRIES', 2))) timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30))) + auth = (nc['username'], nc['password']) + + if not _ensure_nextcloud_directory(base_url, directory, auth, timeout): + return False for attempt in range(retries + 1): try: @@ -79,7 +112,7 @@ def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool: response = requests.put( remote_url, data=handle, - auth=(nc['username'], nc['password']), + auth=auth, timeout=timeout, ) if response.status_code in (200, 201, 204): @@ -102,3 +135,21 @@ def upload_to_nextcloud(local_file: Path, remote_filename: str) -> bool: if attempt < retries: time.sleep(0.6 * (attempt + 1)) return False + + +def delete_from_nextcloud(remote_path: str, *, directory_override: str | None = None) -> bool: + nc = get_nextcloud_settings() + base_url = nc['base_url'] + directory_source = nc['directory'] if directory_override is None else directory_override + directory = (directory_source or '').strip('/') + if not base_url: + return False + if directory_override is None and not directory: + return False + timeout = max(5, int(getattr(settings, 'NEXTCLOUD_UPLOAD_TIMEOUT_SECONDS', 30))) + response = requests.delete( + _nextcloud_remote_url(base_url, directory, remote_path), + auth=(nc['username'], nc['password']), + timeout=timeout, + ) + return response.status_code in (200, 204, 404) diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index 0849a7d..99d5c76 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -1,10 +1,12 @@ body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; } +[hidden] { display: none !important; } .shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; } h1 { margin: 12px 0 6px; color: #000078; } .sub { margin: 0 0 12px; color: #54657c; } .app-messages { margin-bottom: 12px; } .card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; } +.backup-grid { grid-template-columns: minmax(280px, 720px); } label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; } input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index ec01873..3129592 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -191,6 +191,77 @@ display: none; } +.action-progress-modal[hidden] { + display: none; +} + +.action-progress-modal { + position: fixed; + inset: 0; + z-index: 1190; +} + +.action-progress-backdrop { + position: absolute; + inset: 0; + background: rgba(16, 32, 57, 0.34); + backdrop-filter: blur(5px); + animation: confirmFadeIn var(--motion-base) var(--motion-ease); +} + +.action-progress-panel { + position: relative; + width: min(520px, calc(100% - 32px)); + margin: min(16vh, 120px) auto 0; + padding: 22px; + border: 1px solid rgba(217, 227, 238, 0.94); + border-radius: 24px; + background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.97)); + box-shadow: 0 26px 64px rgba(18, 34, 56, 0.20); + animation: confirmPopIn var(--motion-slow) var(--motion-ease); +} + +.action-progress-kicker { + margin: 0 0 8px; + color: #5a6d87; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.action-progress-title { + margin: 0 0 8px; + color: #16315b; + font-size: 24px; + letter-spacing: -0.03em; +} + +.action-progress-copy { + margin: 0 0 16px; + color: #5a6d87; + font-size: 14px; + line-height: 1.55; +} + +.action-progress-track { + overflow: hidden; + position: relative; + height: 12px; + border-radius: 999px; + background: linear-gradient(180deg, #dce6f2, #d1dce9); + box-shadow: inset 0 1px 2px rgba(16, 32, 57, 0.10); +} + +.action-progress-bar { + width: 40%; + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, #000078, #3b70ea); + box-shadow: 0 0 18px rgba(59, 112, 234, 0.28); + animation: actionProgressSlide 1.05s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + .confirm-modal { position: fixed; inset: 0; @@ -259,6 +330,11 @@ body.confirm-open { } } +@keyframes actionProgressSlide { + 0% { transform: translateX(-120%); } + 100% { transform: translateX(270%); } +} + @media (prefers-reduced-motion: reduce) { :root { --motion-fast: 1ms; diff --git a/backend/workflows/static/workflows/js/action_progress.js b/backend/workflows/static/workflows/js/action_progress.js new file mode 100644 index 0000000..00f747a --- /dev/null +++ b/backend/workflows/static/workflows/js/action_progress.js @@ -0,0 +1,35 @@ +(function () { + const modal = document.getElementById('app-action-progress'); + const title = document.getElementById('app-action-progress-title'); + const copy = document.getElementById('app-action-progress-copy'); + if (!modal || !title || !copy) return; + + function showProgress(source) { + const nextTitle = source?.dataset?.progressTitle; + const nextCopy = source?.dataset?.progressCopy; + if (nextTitle) title.textContent = nextTitle; + if (nextCopy) copy.textContent = nextCopy; + modal.hidden = false; + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('confirm-open'); + } + + window.AppActionProgress = { + show: showProgress, + }; + + document.addEventListener('submit', function (event) { + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; + + const submitter = event.submitter; + if (submitter?.dataset?.progressTitle || submitter?.dataset?.progressCopy) { + showProgress(submitter); + return; + } + + if (form.dataset.progressTitle || form.dataset.progressCopy) { + showProgress(form); + } + }); +})(); diff --git a/backend/workflows/static/workflows/js/confirm_dialog.js b/backend/workflows/static/workflows/js/confirm_dialog.js index 3041f80..ad6b06f 100644 --- a/backend/workflows/static/workflows/js/confirm_dialog.js +++ b/backend/workflows/static/workflows/js/confirm_dialog.js @@ -58,6 +58,9 @@ open(message).then(function (confirmed) { if (!confirmed) return; form.dataset.confirmBypass = '1'; + if (window.AppActionProgress && (form.dataset.progressTitle || form.dataset.progressCopy)) { + window.AppActionProgress.show(form); + } if (typeof form.requestSubmit === 'function') { form.requestSubmit(); } else { @@ -79,6 +82,9 @@ open(button.dataset.confirm).then(function (confirmed) { if (!confirmed) return; button.dataset.confirmBypass = '1'; + if (window.AppActionProgress && (button.dataset.progressTitle || button.dataset.progressCopy || form.dataset.progressTitle || form.dataset.progressCopy)) { + window.AppActionProgress.show(button.dataset.progressTitle || button.dataset.progressCopy ? button : form); + } if (typeof form.requestSubmit === 'function') { form.requestSubmit(button); } else { diff --git a/backend/workflows/templates/workflows/backup_recovery.html b/backend/workflows/templates/workflows/backup_recovery.html new file mode 100644 index 0000000..9edda46 --- /dev/null +++ b/backend/workflows/templates/workflows/backup_recovery.html @@ -0,0 +1,114 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Backup & Recovery" %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block shell_body %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %} +

{% trans "Backup & Recovery" %}

+

{% trans "Datenbank- und Media-Backups erstellen und vorhandene Bundles sicher verifizieren." %}

+ +{% include 'workflows/includes/messages.html' %} + +
+
+
+

{% trans "Aktionen" %}

+
{% trans "Erstellung und Verifikation laufen im App-Kontext. Restore bleibt bewusst CLI-only." %}
+
+
+ {% csrf_token %} + +
+
+
+ +
+

{% trans "Verfügbare Backup-Bundles" %}

+ {% if rows %} +
+ + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + {% endfor %} + +
{% trans "Bundle" %}{% trans "Erstellt" %}{% trans "Verifiziert" %}{% trans "Status" %}{% trans "Inhalt" %}{% trans "Remote" %}{% trans "Aktion" %}
{{ row.name }}{% if row.created_at %}{{ row.created_at|slice:":16"|cut:"T" }}{% else %}-{% endif %}{% if row.verified_at %}{{ row.verified_at|slice:":16"|cut:"T" }}{% else %}-{% endif %} + {% if row.verify_status == 'verified' %} + {% trans "Verifiziert" %} + {% else %} + {% trans "Nicht geprüft" %} + {% endif %} + + {% if row.db_dump_exists %}DB{% endif %} + {% if row.media_archive_exists %}Media{% endif %} + {% if row.summary %} +
{{ row.summary }}
+ {% endif %} +
+ {% if row.remote_status == 'uploaded' %} + {% trans "Hochgeladen" %} + {% elif row.remote_status == 'failed' %} + {% trans "Fehlgeschlagen" %} + {% elif row.remote_status == 'not_implemented' %} + {% trans "Vorbereitet" %} + {% elif row.remote_status == 'disabled' %} + {% trans "Deaktiviert" %} + {% else %} + {% trans "Lokal" %} + {% endif %} + {% if row.db_dump_exists or row.media_archive_exists %} +
{% trans "Lokal gespeichert" %}
+ {% else %} +
{% trans "Lokal nicht vorhanden" %}
+ {% endif %} + {% if row.remote_target_type %} +
{{ row.remote_target_type|upper }}
+ {% endif %} + {% if row.remote_path %} +
{{ row.remote_path }}
+ {% endif %} + {% if row.remote_summary %} +
{{ row.remote_summary }}
+ {% endif %} +
+
+
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
+
+
+ {% else %} +
{% trans "Noch keine Backup-Bundles vorhanden." %}
+ {% endif %} +
+{% endblock %} diff --git a/backend/workflows/templates/workflows/base_shell.html b/backend/workflows/templates/workflows/base_shell.html index 67c7159..a5d167b 100644 --- a/backend/workflows/templates/workflows/base_shell.html +++ b/backend/workflows/templates/workflows/base_shell.html @@ -30,7 +30,19 @@ + + {% block extra_scripts %}{% endblock %} diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 20269da..0f13616 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -31,6 +31,7 @@ Nextcloud Builders Testing + Backup Deployment Troubleshooting Security @@ -152,6 +153,8 @@ docker compose exec -T web django-admin compilemessages
  • Upload logic lives in backend/workflows/services.py.
  • Feature can be globally toggled without changing environment variables.
  • Failures should degrade gracefully and not block request persistence.
  • +
  • Remote backup currently reuses the configured Nextcloud connection, but writes into a separate backup directory configured under Integrationen → Backup-Ziel.
  • +
  • Do not point remote backup at the same Nextcloud directory used for normal onboarding/offboarding document uploads.
  • 10) Builder Architecture

    @@ -176,6 +179,7 @@ docker compose exec -T web django-admin compilemessages
  • Current hooks include builder edits, PDF generation, welcome-email actions, integration changes, mode toggles, tests, and request deletions.
  • Staff UI page: /admin-tools/audit-log/
  • The current UI supports filtering by action, user, and date range. Keep filters server-side to avoid loading unbounded audit rows into the browser.
  • +
  • Backup UI page: /admin-tools/backups/ for create, verify, and delete actions. Keep real restore CLI-only.
  • 11) Testing and Validation

    @@ -193,7 +197,24 @@ docker compose exec -T web python manage.py run_staging_e2e_check
  • The Requests Dashboard includes a retry action for failed requests. Retries reset the error text, set the request back to submitted, and enqueue the appropriate Celery task again.
  • -

    12) Deployment and Release Checklist

    +

    12) Backup and Restore

    +
    make backup-create
    +make backup-verify BACKUP_DIR=backups/backup_YYYYmmdd_HHMMSS
    +
      +
    • Backups are stored under backend/backups/ and ignored by git.
    • +
    • Each backup contains a PostgreSQL custom-format dump, a compressed media/ archive, metadata, and SHA256 checksums.
    • +
    • Backup metadata records both local availability and remote backup status.
    • +
    • Remote backup target configuration is managed in Integrationen → Backup-Ziel.
    • +
    • Current remote target support: nextcloud implemented, s3 and nfs config-ready but not yet implemented.
    • +
    • Verification is non-destructive: it restores into a temporary verification database and extracts media into a temporary directory.
    • +
    • Real restore is explicit and destructive by design: +
      ./scripts/backup_restore.sh --yes-restore backend/backups/backup_YYYYmmdd_HHMMSS
      +
    • +
    • Do not run the restore script casually against a live working dataset.
    • +
    • The staff UI uses the shared action-progress overlay for backup creation and verification so long-running actions present one standard app behavior.
    • +
    + +

    13) Deployment and Release Checklist

    1. Run manage.py check
    2. Run tests or targeted verification
    3. @@ -206,7 +227,7 @@ docker compose exec -T web python manage.py run_staging_e2e_check
    4. Take a snapshot commit before major next-phase work
    -

    13) Troubleshooting

    +

    14) Troubleshooting

    • Page looks stale: restart web and hard-refresh browser
    • Second request hangs: inspect web logs and verify health endpoint
    • @@ -217,7 +238,7 @@ docker compose exec -T web python manage.py run_staging_e2e_check
    • Requests dependency warning appears: verify chardet==5.2.0 is installed in the rebuilt image and restart web/worker
    -

    14) Security and Maintenance Notes

    +

    15) Security and Maintenance Notes

    • Containers run as non-root app user.
    • Keep secrets in .env, not in tracked files.
    • diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index afbbc86..dd98d39 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -122,6 +122,11 @@ {% trans "Öffnen" %}
      +

      {% trans "Backup & Recovery" %}

      +

      {% trans "Backups erstellen und sicher verifizieren." %}

      +{% trans "Öffnen" %} +
      +

      {% trans "Welcome E-Mails" %}

      {% trans "Geplante Welcome Mails verwalten." %}

      {% trans "Öffnen" %} diff --git a/backend/workflows/templates/workflows/integrations_setup.html b/backend/workflows/templates/workflows/integrations_setup.html index 662d648..dc6cd46 100644 --- a/backend/workflows/templates/workflows/integrations_setup.html +++ b/backend/workflows/templates/workflows/integrations_setup.html @@ -10,7 +10,7 @@ {% endblock %} {% block shell_body %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}

      {% trans "Integrationen Setup" %}

      {% trans "Verwalten Sie Nextcloud- und Mail-Konfiguration ohne Backend-Wechsel." %}

      @@ -19,6 +19,7 @@ {% trans "Setup Mail" %} {% trans "E-Mail Routing & Vorlagen" %} {% trans "Workflow-Regeln" %} + {% trans "Backup-Ziel" %} {% include 'workflows/includes/messages.html' %} @@ -51,7 +52,7 @@
      - +
      @@ -113,7 +114,7 @@
      - +
      @@ -363,4 +364,67 @@ {% endif %} + {% if kind == 'backup' %} +
      + {% csrf_token %} +
      +
      + +
      + +
      +
      +
      +
      + + +
      + + + +
      +
      +
      + +
      +
      {% trans "Empfehlung: Nextcloud als erstes Remote-Ziel verwenden. S3 und NFS sind als Zieltypen vorbereitet, aber noch nicht aktiv implementiert." %}
      + +
      + {% endif %} + +{% endblock %} + +{% block extra_scripts %} + {{ block.super }} + {% if kind == 'backup' %} + + {% endif %} {% endblock %} diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html index 8cbdc07..6478a96 100644 --- a/backend/workflows/templates/workflows/project_wiki.html +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -174,7 +174,7 @@
      • Form Builder: manage field visibility/order/options.
      • Einweisungs-Builder: manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.
      • -
      • Integrations: Nextcloud, SMTP, default routing addresses, notification rules.
      • +
      • Integrations: Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.
      • Welcome Emails: scheduled jobs, pause/resume/cancel/trigger now.
      • Audit Log: staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.
      • Requests Dashboard: search records, open PDFs, delete records (single/bulk for staff).
      • @@ -197,6 +197,12 @@
        • Container path: /app/media/pdfs/
        • Host path: project backend/media/pdfs/ via mounted volume.
        • +
        • Backup standard: create DB+media bundles under backups/ and verify them with a temporary restore before using a real restore.
        • +
        • Staff UI shortcut: Admin Apps → Backup & Recovery for create, verify, and delete actions.
        • +
        • Each backup row shows both local bundle availability and remote backup state.
        • +
        • Remote backup target configuration lives under Admin Apps → IntegrationenBackup-Ziel.
        • +
        • Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.
        • +
        • Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.

        Deployment Notes

        diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index c907e26..665395b 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ path('admin-tools/integrations/save-emails/', views.save_email_routing_settings, name='save_email_routing_settings'), path('admin-tools/integrations/save-rules/', views.save_notification_rules, name='save_notification_rules'), path('admin-tools/integrations/save-workflow-rules/', views.save_workflow_rules, name='save_workflow_rules'), + path('admin-tools/integrations/save-backup-settings/', views.save_backup_settings, name='save_backup_settings'), path('admin-tools/welcome-emails/', views.welcome_emails_page, name='welcome_emails_page'), path('admin-tools/welcome-emails/settings/', views.save_welcome_email_settings, name='save_welcome_email_settings'), path('admin-tools/welcome-emails/bulk-action/', views.bulk_welcome_email_action, name='bulk_welcome_email_action'), @@ -33,6 +34,10 @@ urlpatterns = [ path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'), path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'), path('admin-tools/audit-log/', views.audit_log_page, name='audit_log_page'), + path('admin-tools/backups/', views.backup_recovery_page, name='backup_recovery_page'), + path('admin-tools/backups/create/', views.create_backup_from_admin, name='create_backup_from_admin'), + path('admin-tools/backups//verify/', views.verify_backup_from_admin, name='verify_backup_from_admin'), + path('admin-tools/backups//delete/', views.delete_backup_from_admin, name='delete_backup_from_admin'), path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'), path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'), path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 0437311..19906de 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -18,6 +18,7 @@ from django.utils import timezone from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import get_language, override +from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle from .forms import OffboardingRequestForm, OnboardingRequestForm from .form_builder import ( DEFAULT_FIELD_ORDER, @@ -217,6 +218,10 @@ def _audit_action_label(action: str) -> str: 'mail_settings_saved': _('Mail-Einstellungen gespeichert'), 'email_routing_saved': _('E-Mail-Routing gespeichert'), 'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'), + 'backup_created': _('Backup erstellt'), + 'backup_verified': _('Backup verifiziert'), + 'backup_deleted': _('Backup gelöscht'), + 'backup_settings_saved': _('Backup-Einstellungen gespeichert'), } return labels.get(action, action.replace('_', ' ').strip().capitalize()) @@ -375,6 +380,75 @@ def audit_log_page(request): ) +@login_required +@user_passes_test(_is_staff) +def backup_recovery_page(request): + return render( + request, + 'workflows/backup_recovery.html', + { + 'rows': list_backup_bundles(), + }, + ) + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def create_backup_from_admin(request): + try: + result = create_backup_bundle() + _audit( + request, + 'backup_created', + target_type='backup_bundle', + target_label=result['name'], + details={'path': result['path']}, + ) + messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']}) + except Exception as exc: + messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc}) + return redirect('backup_recovery_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def verify_backup_from_admin(request, backup_name: str): + try: + result = verify_backup_bundle(backup_name) + _audit( + request, + 'backup_verified', + target_type='backup_bundle', + target_label=backup_name, + details={'summary': result['summary']}, + ) + messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']}) + except Exception as exc: + messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc}) + return redirect('backup_recovery_page') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def delete_backup_from_admin(request, backup_name: str): + try: + result = delete_backup_bundle(backup_name) + _audit( + request, + 'backup_deleted', + target_type='backup_bundle', + target_label=backup_name, + details={}, + ) + messages.success(request, _('Backup wurde gelöscht: %(name)s') % {'name': result['name']}) + except Exception as exc: + messages.error(request, _('Backup konnte nicht gelöscht werden: %(error)s') % {'error': exc}) + return redirect('backup_recovery_page') + + @login_required @user_passes_test(_is_staff) def request_timeline_page(request, kind: str, request_id: int): @@ -1242,7 +1316,7 @@ def intro_builder_page(request): def integrations_setup_page(request): config, _ = WorkflowConfig.objects.get_or_create(name='Default') kind = (request.GET.get('kind') or 'nextcloud').strip().lower() - if kind not in {'nextcloud', 'mail', 'emails', 'rules'}: + if kind not in {'nextcloud', 'mail', 'emails', 'rules', 'backup'}: kind = 'nextcloud' templates = list(NotificationTemplate.objects.all().order_by('key')) system_email_config = ( @@ -1263,6 +1337,7 @@ def integrations_setup_page(request): 'rule_event_choices': NotificationRule.EVENT_CHOICES, 'rule_operator_choices': NotificationRule.OPERATOR_CHOICES, 'template_choices': NotificationTemplate.TEMPLATE_CHOICES, + 'remote_backup_target_choices': WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES, }, ) @@ -1794,12 +1869,67 @@ def save_workflow_rules(request): 'workflow_rules_saved', target_type='workflow_config', target_label='workflow_rules', - details={'device_handover_lead_days': config.device_handover_lead_days}, + details={ + 'device_handover_lead_days': config.device_handover_lead_days, + }, ) messages.success(request, 'Workflow-Regeln wurden gespeichert.') return redirect('/admin-tools/integrations/?kind=rules') +@login_required +@user_passes_test(_is_staff) +@require_POST +def save_backup_settings(request): + config, _ = WorkflowConfig.objects.get_or_create(name='Default') + target_type = (request.POST.get('remote_backup_target_type') or config.remote_backup_target_type or 'nextcloud').strip().lower() + if target_type not in {choice for choice, _ in WorkflowConfig.REMOTE_BACKUP_TARGET_CHOICES}: + target_type = 'nextcloud' + remote_backup_enabled = request.POST.get('remote_backup_enabled') == 'on' + remote_backup_nextcloud_directory = request.POST.get('remote_backup_nextcloud_directory', '').strip() + primary_nextcloud_directory = ( + (config.nextcloud_directory_override or '').strip() + or settings.NEXTCLOUD_DIRECTORY.strip() + ).strip('/') + + if remote_backup_enabled and target_type == 'nextcloud': + if not remote_backup_nextcloud_directory: + messages.error(request, 'Bitte ein separates Nextcloud Backup-Verzeichnis angeben.') + return redirect('/admin-tools/integrations/?kind=backup') + if remote_backup_nextcloud_directory.strip('/') == primary_nextcloud_directory: + messages.error(request, 'Das Backup-Verzeichnis muss vom normalen Nextcloud Dokumentenordner getrennt sein.') + return redirect('/admin-tools/integrations/?kind=backup') + + config.remote_backup_enabled = remote_backup_enabled + config.remote_backup_target_type = target_type + config.remote_backup_nextcloud_directory = remote_backup_nextcloud_directory + config.remote_backup_s3_bucket = request.POST.get('remote_backup_s3_bucket', '').strip() + config.remote_backup_nfs_path = request.POST.get('remote_backup_nfs_path', '').strip() + config.save( + update_fields=[ + 'device_handover_lead_days', + 'remote_backup_enabled', + 'remote_backup_target_type', + 'remote_backup_nextcloud_directory', + 'remote_backup_s3_bucket', + 'remote_backup_nfs_path', + ] + ) + _audit( + request, + 'backup_settings_saved', + target_type='workflow_config', + target_label='backup_settings', + details={ + 'remote_backup_enabled': config.remote_backup_enabled, + 'remote_backup_target_type': config.remote_backup_target_type, + 'remote_backup_nextcloud_directory': config.remote_backup_nextcloud_directory, + }, + ) + messages.success(request, 'Backup-Einstellungen wurden gespeichert.') + return redirect('/admin-tools/integrations/?kind=backup') + + @login_required @user_passes_test(_is_staff) @require_POST diff --git a/scripts/backup_create.sh b/scripts/backup_create.sh new file mode 100644 index 0000000..73a6c86 --- /dev/null +++ b/scripts/backup_create.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +set -a +. ./.env +set +a + +timestamp="$(date +"%Y%m%d_%H%M%S")" +backup_dir="${1:-$ROOT_DIR/backend/backups/backup_${timestamp}}" +mkdir -p "$backup_dir" + +db_dump_path="$backup_dir/db.dump" +media_archive_path="$backup_dir/media.tar.gz" +meta_path="$backup_dir/backup_meta.env" +checksums_path="$backup_dir/SHA256SUMS" + +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_dump -U '$POSTGRES_USER' -d '$POSTGRES_DB' -Fc --no-owner --no-privileges" > "$db_dump_path" +tar -C "$ROOT_DIR/backend" -czf "$media_archive_path" media + +cat > "$meta_path" < "$checksums_path" +) + +printf '%s\n' "$backup_dir" diff --git a/scripts/backup_restore.sh b/scripts/backup_restore.sh new file mode 100644 index 0000000..69ff2ad --- /dev/null +++ b/scripts/backup_restore.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 2 || "$1" != "--yes-restore" ]]; then + echo "Usage: $0 --yes-restore " >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +backup_dir="$2" +db_dump_path="$backup_dir/db.dump" +media_archive_path="$backup_dir/media.tar.gz" + +if [[ ! -f "$db_dump_path" || ! -f "$media_archive_path" ]]; then + echo "Backup files missing in $backup_dir" >&2 + exit 1 +fi + +set -a +. ./.env +set +a + +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$POSTGRES_DB' AND pid <> pg_backend_pid();\"" +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"DROP DATABASE IF EXISTS \\\"$POSTGRES_DB\\\";\"" +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"CREATE DATABASE \\\"$POSTGRES_DB\\\";\"" + +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_restore -U '$POSTGRES_USER' -d '$POSTGRES_DB' --no-owner --no-privileges" < "$db_dump_path" + +rm -rf "$ROOT_DIR/backend/media" +mkdir -p "$ROOT_DIR/backend" +tar -C "$ROOT_DIR/backend" -xzf "$media_archive_path" + +echo "Restore completed from $backup_dir" diff --git a/scripts/backup_verify.sh b/scripts/backup_verify.sh new file mode 100644 index 0000000..8e7e8be --- /dev/null +++ b/scripts/backup_verify.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +backup_dir="$1" +db_dump_path="$backup_dir/db.dump" +media_archive_path="$backup_dir/media.tar.gz" + +if [[ ! -f "$db_dump_path" || ! -f "$media_archive_path" ]]; then + echo "Backup files missing in $backup_dir" >&2 + exit 1 +fi + +set -a +. ./.env +set +a + +verify_db="${POSTGRES_DB}_verify_$(date +%s)" +verify_media_dir="$(mktemp -d /tmp/tubco_backup_verify_media.XXXXXX)" + +cleanup() { + docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"DROP DATABASE IF EXISTS \\\"$verify_db\\\";\"" >/dev/null 2>&1 || true + chmod -R u+rwx "$verify_media_dir" >/dev/null 2>&1 || true + rm -rf "$verify_media_dir" +} +trap cleanup EXIT + +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d postgres -c \"CREATE DATABASE \\\"$verify_db\\\";\"" +docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' pg_restore -U '$POSTGRES_USER' -d '$verify_db' --no-owner --no-privileges" < "$db_dump_path" + +table_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM pg_tables WHERE schemaname='public';\"")" +onboarding_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM workflows_onboardingrequest;\"")" +offboarding_count="$(docker compose exec -T db sh -lc "PGPASSWORD='$POSTGRES_PASSWORD' psql -U '$POSTGRES_USER' -d '$verify_db' -t -A -c \"SELECT COUNT(*) FROM workflows_offboardingrequest;\"")" + +tar -C "$verify_media_dir" --no-same-owner --no-same-permissions -xzf "$media_archive_path" +if [[ ! -d "$verify_media_dir/media" ]]; then + echo "Media restore verification failed: extracted media directory missing" >&2 + exit 1 +fi + +media_file_count="$(find "$verify_media_dir/media" -type f | wc -l | tr -d ' ')" + +printf 'Verified backup: %s\n' "$backup_dir" +printf 'Restore DB: %s\n' "$verify_db" +printf 'Public tables: %s\n' "$table_count" +printf 'Onboarding rows: %s\n' "$onboarding_count" +printf 'Offboarding rows: %s\n' "$offboarding_count" +printf 'Media files: %s\n' "$media_file_count"