From 8926d6860c0481d20bbd080789ce3b54e1b50226 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 10:52:10 +0100 Subject: [PATCH 01/45] docs: add productization roadmap --- PRODUCTIZATION_ROADMAP.md | 215 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 PRODUCTIZATION_ROADMAP.md diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md new file mode 100644 index 0000000..ec9f3c2 --- /dev/null +++ b/PRODUCTIZATION_ROADMAP.md @@ -0,0 +1,215 @@ +# Productization Roadmap + +## Goal + +Turn the current TUBCO-specific onboarding/offboarding portal into a reusable company portal product while preserving the current TUBCO deployment as a stable customer-specific baseline. + +Current branch roles: + +- `main`: current TUBCO-oriented working baseline +- `product_company_portal_foundation`: generic product-development branch + +## Core Product Principles + +1. Do not build "TUBCO with exceptions". +2. Separate platform core from company configuration. +3. Start as single-tenant configurable, not full multi-tenant. +4. Make branding and document identity admin-managed, not code-managed. +5. Add new business apps only after the core platform layer is standardized. + +## Product Layers + +### 1. Platform Core + +Reusable across all customer deployments: + +- authentication +- roles and permissions +- page shell and navigation +- audit log +- backup and recovery +- integrations framework +- translations +- document engine +- app registry + +### 2. Company Configuration + +Per deployment / per company: + +- portal title +- company display name +- logos +- favicon +- brand colors +- support/contact email +- legal text +- default language +- PDF letterhead +- email sender labels +- notification routing defaults + +### 3. Business Apps + +Examples: + +- onboarding / offboarding +- welcome emails +- asset return +- approvals +- leave / request flows +- checklists +- document workflows + +## Delivery Strategy + +### Phase 0. Freeze Current Customer Baseline + +Status: completed + +Purpose: + +- keep the current TUBCO state stable +- allow future customer-specific fixes without mixing them with productization work + +Snapshot commit: + +- `08971ab` `snapshot: preserve current tubco portal baseline` + +### Phase 1. Product Core Standardization + +Status: next + +Purpose: + +- remove hardcoded company identity from the platform surface +- introduce config-driven branding and document identity + +Deliverables: + +1. `PortalBranding` or `TenantConfig` model + - portal title + - company name + - logo + - favicon + - primary / secondary colors + - support email + - default language + - PDF letterhead file + - legal / imprint / privacy text + +2. Branding management UI + - super-admin/admin controlled + - editable from frontend + +3. Shared context layer + - inject branding into templates + - replace hardcoded TUBCO title/logo references + +4. Tenant-aware document identity + - letterhead path from config + - company display name from config + - footer/legal text from config + +5. Documentation updates + - product setup flow + - branding flow + - PDF/letterhead override behavior + +### Phase 2. App Registry and Navigation + +Purpose: + +- stop hardcoding app cards and app visibility in the homepage template + +Deliverables: + +- app registry model or registry config +- title / subtitle / icon / route / required capability / enabled flag +- homepage and navigation driven by registry data +- ability to enable/disable apps per deployment + +### Phase 3. Trial Mode Lifecycle + +Purpose: + +- allow limited-time test environments for demos and sales + +Deliverables: + +- trial flag +- `trial_expires_at` +- trial banner +- safe default integrations behavior +- cleanup command / scheduled deletion +- DB/media cleanup policy + +### Phase 4. New Business Apps + +Only start after phases 1-3 are stable. + +Candidate apps: + +- asset management +- leave / absence requests +- approval workflows +- internal purchase requests +- visitor onboarding +- contractor onboarding +- policy acknowledgements + +## Recommended Deployment Model + +Initial product model: + +- single-tenant configurable deployment +- one company per deployment +- shared codebase +- company identity controlled through admin-managed config + +Do not start with: + +- true multi-tenant shared-data SaaS + +Reason: + +- tenant isolation affects auth, media, PDFs, routing, backups, audit, and cleanup +- it is much more complex than the current product stage requires + +## What Must Be Removed From Hardcoded Product Core + +Examples already identified: + +- `TUBCO Onboarding & Offboarding Portal` +- logo asset references +- invitation email wording mentioning TUBCO +- welcome-email defaults mentioning TUB/CO +- fixed letterhead file assumptions + +These should move into configuration progressively, not all at once in one risky rewrite. + +## Immediate Next Slice + +Implement first: + +1. `PortalBranding` model +2. branding management page +3. shared branding context processor +4. replace header/logo/title references on: + - home + - shared header + - login/auth pages +5. make PDF letterhead configurable + +This is the first productization slice because it gives: + +- generic portal identity +- customer-specific configurability +- a cleaner base for every future app + +## Guardrails + +- keep current TUBCO defaults as the initial values +- keep migrations backward-compatible +- update both wiki and developer handbook for every architecture change +- snapshot at the end of each major phase From 51700cfa8bbb1ca5d60f8de2222c48a4c7d88bc1 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 11:43:54 +0100 Subject: [PATCH 02/45] snapshot: preserve branding foundation and platform owner split --- backend/locale/en/LC_MESSAGES/django.mo | Bin 31194 -> 31810 bytes backend/locale/en/LC_MESSAGES/django.po | 572 ++++++++++++------ backend/workflows/admin.py | 7 +- backend/workflows/branding.py | 106 ++++ backend/workflows/context_processors.py | 5 +- backend/workflows/forms.py | 64 +- .../commands/bootstrap_initial_users.py | 4 +- .../migrations/0036_portalbranding.py | 33 + ...lter_portalbranding_logo_image_and_more.py | 24 + backend/workflows/models.py | 35 ++ backend/workflows/roles.py | 50 +- .../workflows/static/workflows/css/home.css | 28 + backend/workflows/tasks.py | 11 +- .../workflows/branding_settings.html | 70 +++ .../workflows/developer_handbook.html | 15 +- .../workflows/templates/workflows/home.html | 26 +- .../workflows/includes/app_header.html | 4 +- .../templates/workflows/project_wiki.html | 4 +- .../workflows/requests_dashboard.html | 4 +- .../templates/workflows/user_management.html | 4 +- backend/workflows/urls.py | 2 + backend/workflows/views.py | 140 ++++- 22 files changed, 966 insertions(+), 242 deletions(-) create mode 100644 backend/workflows/branding.py create mode 100644 backend/workflows/migrations/0036_portalbranding.py create mode 100644 backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py create mode 100644 backend/workflows/templates/workflows/branding_settings.html diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index f69d1a631a88264e9e713cee9cf78c091bc88852..46923b302d4e4c50f173911bca2a1ce4cd9592bc 100644 GIT binary patch delta 9595 zcmZwL3tUun-pBES3J8MY4exSP6h$C!s1c?jpe87J#Y^e{156GxI1HMWPN`+7WoBks znWoit-moM2g98d;XNgK917({7fPj-TNWOz3V|y)Ykj{XFEKbt^ym z;FH)M-^G4-7Il3}4+0BO^_9rKkhPJYu~h8FINXQL@DL{A`7HAO$^e8S28-*b3Jfw__Xf7f=nnjaT4@CU4)< zvWAlP#@6V^q)%4Q0nVnGg6jDK+J=nRx#xnB0*b48!$kd^(e;oCt+lhP}ts^G? z4)s>W^Qvg1eNY`4f|`jDs2MI&z21L61&!c3)B{(Ve2ei})Qzv0`~d2K@1r_$(&XoG zF8Kwl!s&b@^xQqhqo}>|394gXVn|bS-dxc1a%ZINQ1$5;hgYJ$^<$84ljTNjs@0f| z&tevyF!gbK1d_=!QF|vJb$t*!;Jv6Ncs_^uSK(zU+TecFjUV9%JdfJ7*?b&iA!u2^;(2U~F}cjYrm}Oc^WdJSjts()IM(E=F^zl+ zYHIhRmZaY>XQpzoGkFQB;TqJ^1h57}sE(b%F4%;fs)2M5QRqmaKk5NRsLkd`bq!9Ca>-$r%x3oOSACZEN$bt3npu3L>-n(e5me+if3VdQxst2objP$_CD z=9xT*+AJ$g{Z`aWJ%Q@^>!=Q#FrGoppf$=FP#286eyF7wV(RlveF-M({hv)i4cDU{ zxD?gEI_!;)n))NCsXvLC_%rJ5NaMS$`}?C_uN>6KN1~>B2C4&PSb%<$@4$H4xAsy{ zPY;bmWy4!mgcJ*cTahS~$?%=wOdMn{l$ z!;nT=MnNN3XuJ{C&~2!OH{o1-3bh9kM?0IYJt|Mb7B~R)zUP_x09KGML2c5LsDXZm zn(2RzHt&C{G0up(VB~?QDI9?6;5bwRb1?>MaS|@XSUiaL;$N@~yQr$ZAkn%s{XNImw&n^4z1jXWo09iyNR#wTdwuO{zY!e2q;Ls6UX8f=b!Ov9z9 z`yNGgXfO8111A3mzDeHXYUee68?(rNKpRshYDQT90t%YKIjE@#MNY6bm`?sGYU;m1 zU6;Vl8-N+8FXJ3k2bQ3g=4RBAtufw%3FKRhPou7X5o2iI+G{G_LXGep)LMOt@%RgB zDx)Vmc{5Z)$=C|JpdQ%I)DOiZ^5NJLORzPTp_X(34#X80QUkjv48eVN9*;*g zI2YBR5A{0Ug!(SrhkEUv$2NEfb^S@q!EZ4Q`%ZJ}C!sc>3opYO)Dk{8jrrGuj+zTk zqk8r;Y9_Kuod$-YMo^5}y+Q1Un{fmlK|MHWy3^1I)F^t+9Z<_o)<2R_c;CIZ!jx(MAHoOKqlHY^L_#A4p?#B-J zB?d8M&2k=Gk6ODGsGf$g7=K1B!Dv2{8u<*=2v(r>&N0+fpTt~@pY6OQqj4m86>9fC zj$LpkHpRap?S!n4C}?;0nB%N{AJmLof$Hg4?26^62A82`=yuc+-HmEsGiujAi7EIJ z+V~-A)BT3+u=`x6ogD11_rHjOHqlbd#`U-y-$FH5>~hw$6jzZ~pgMRC)$!j@Z$o2# z^DqfkGt|;~Q0JGR8eWe1ct2_YpXxmATc;>!?a!b#-*4C*2b4PpdPRQwN`6T7e0Yaa2IN% zFJm`+9krIHP}hBn+HAk0HfvkAV=C&pfhNy2`8YT8uQi-(DrVyZ@+zE*FQDGr_La^| zq@o^l8EOWGqDEGLnxRtE9;ikw!6MZ4x1*N$9@NY}iJkFqCG+2l!fA7&Rh9GFWTQq> zf?AR)?12xUX6!Z8-gw{mIjVtks2OZh?fkva5p|sl)nTu3k@1!g1x?+0RD)YlBie;c z@E~ea9>r*k@i|#$cAovr#iK28UuPHpO*l<0jN5-Hm!)sKd3+RHdMH=|EJ& z<1ijekX5i|qDHU>H3NrDehM}6^QaF_+&pIqQc-&#igoP+B4VvN`Oe>(*|_+H~9 zs2iTg#druc^<%uw?)Rd4z7+MK71#_nn)BO`)wFh?_CV(vr^Ee_2CcCspNvU*|7$2{ zgiBEm{2cY+`3bd_Eq%_+q@iBF;kXp9Mm_K->b?(9Q+?Ld|Agv5BfnFhj2c)9>iQf^ z(EDFZp*hYl7kE$)USV8|3FMnl4LpXr{*R~*zG~{?*1jJOARmm{d>&K-ON}d0_uYx==%Y9p z|AZQFT%Gflw5VhL)uR+sk&c?09Bho0$j8jO7B#{nsD?hqR(J{{Gk|()qJz$6j7JS1 z8CzmMOu{_W04L#ioDrfBOW|?66Q4tVHCuW0&V`#%BYDK+yHUG+FKT3mQ4jhMwYmB( za6VXtsMq;=)Kss>LVOU_(a%x$hrXtuwf+fPW70zN{-S!CjT&*Tu>ds_<57Fz8q|Xx z!?w5sTi`*|eIFP*3 z6O$>wL1_Kk>jXzfg4g@v@iC>NL=$sfC+1#K)@B_<da-JVV8;L>hIAh;@{2 z##EvMWzB*P{cYf(U-gkb^M+9AyRUF{26W{J~4IIMrr=du@k2g-NS-AB6x!%#~=9Fo%-g)^ORX-%O-k} zHym?9Cege9G%=jqb;$*~@Q*|%Q>PuQqwOWvuuU!=f1~uO$%5vZWya5_zd(#6{}~@9 zLfT=O=7L33q)?uT)i@Y`A*P%26s^{jpHhLNovD9<@@Ardygha!bUaF=5k4j6*os-i zm&8|UUthmY#MMMM^T5ln1>qq&6Nfm*_sMFQt#2sn*hn`u0*Q$c!ZU?I;b@x)PCF+TP5c%yNG(qA4KM#M8_1OkQhn4L+Cg_+{%R+ zI8YVlm_Xfs9yQeI7gV-6w*)58-jRr=Jcrmq+(^u(?h145T3kU?Q$Lp&5*2cO z)#i~j9Bn9c;i41R2xHKVIu_t>PVwT;Db$@L^Wt?lk+_N|CvS%Xh`$m#ZXy1g=&D2q z?ORb?s3QZ9;CkXG;uWId=t*G)@vEsQ=Gr)7mC65tMdXvP73w%h42zWbO^FkTI3nBB z3GD%^kQ<_jvBVg2^GfOmN6O(h>=TW8%nR1LydMik8WM8Sy?bFD#2Ib3RKk923!m^{955%QJz{ll39>h?DBZ6$y4%1O|Z+nZckaz zc6saUNkOKGJ8XBLIx?6HyUbH(S9_S@Y3@MKZCAPXZTD4BwEg_F^Ibu=hxw@XmseN0 zy{-z%3*7<83hy3sS4`}_ot2e7w=Y~YVNT<2qv`_z#$kKi!5}TTT@^Xj#k$B^Ju&fY zbaKNQS(97~+;*8+vGB&yUz;Y3^4HY5e2eVJTCH`ITA$EJ2O`6ly}xV_n>aeR#h08U?a$UBoW<6L!NE9WAQ|4o5w3 zJ}Oi1pfa)>m66X({XA;r*Dwl0$a`~agAPo_wm2D;;Y##nQCLPndwd!--~-eH!sF~c zZ;!*N=b$FI1+}uH*bgtDKG(9dJ%I$QnK&lVJ|EL@CyvIOsEiKjLjJY##f+*4tj3{u z0taC;W|fJPP^sUBLHI5z#e0m$Q7gWL+KM~IMhP{C%!HZHVHCIb|Y%9x1c8cAK-33@p}xVgkS{N5uBop@y*}GfD?Er=*(p>8 z&ZAO$#k7a^wignG8n1(?4=`rnBEA1P6g0q>=Efz|z}Ha|x^LQl(3pzr=X&FdmO!BHl3V?H{$@ zn&GIkGXvFsEw;pWP^ms@JcCixFJd#ig((=;kNh{Gkjb}*%tg(#+~_l|!{)TVgc{&I zFdR=}Q~VBCnTw#L|+YCJ0CBTy;NG3{>DgxB~eXrL{qlzo62=z#Gg zwxfO#S789JfVN~cYJm4puh~)5N>8B{as~Ckn^=G$gY9}LwxPZZ^*rBZ3N0wSj+*HK z%*QjP-Y>=etu_YL&xP8G<*3v@k4x|k)PM=(S>yCZZN*4a&%&0}ou=J~(R%+^QP9k{ zqCWV6@hB>V7f>s@k2=-CsrFVhMYXp_WiS!-z+tFyGEw8^VP~u|?VC~kcVRcZ|DRIO zTW}jSKtP)P8Z|(zI2@JYc+?7$P!k!5IujY#8>gVQWIZY~uc8k1`>64cAZxHrqWVP* z{d!jXJ!i$Zourc)w zsMmHY`qbeRg#x^QI(%t-4{L>4s8r5E_4A-svc%L^p)#}qHL*7^2>0UzJc7zx>k*c< z31e|K?!|iONGJbVd8c&yg9A`|nudJV8jBtAS!{(LVj_Np%2e=3dqV9{{kvcwmZLJ^ zMGl#@0`;6Ns0F@{+PXs{$-f?WiU!T}Yt!K}k__t>>VpGD+28$Hs8r8GeQr6{$8{Kk zn=lCff=cy%Y=<2YnRO@iWxxaSpZOo0x*pqdCDi4VB`(*cDHrw&;N| zY>d6KR;Y!a0nCdB&y@@sDV0X+Ntl34(g*#y%b}q zKZ#29Ti6KqVH_SuZP{(qIFV!RzX@C6Vd`VBv)=!vw*n14VA)7Q=f=>P%cJbA?ouBO#3oyLVXp6<8#=P=UY1|Xuz+r4_-yhv_0=o z7~CZk$B^lPG*b zg9f^7Iz~*ktX|X|s7y>kJs=mg0x#;2zJT5FAg16g)WChR?FSVhFNozqei2&xa2P&7 zl57o|LjD_5*fhn?zz&S0{%7L>)ZTw->i;s{L!ItMQ!OhMa1KwrS>}Vw}%xy-F^$QaR~Kl zY>2y26F7t+_#Nsw*HDMo*K>xw*N>w1C<8Uqsn`}hs0TicIwLQkwqzUX0dJws%pTNh zcnlr*BkGWa&9u)<66#Ei!X7vs*;1dijzTgGTk$bGi+bRUTze0TaW!=>YJv|?GY^|( zpZ-YXSyn65)-6EYUyIFg0}jQVs0I9tx_=uR>;3bsJA7GUv+YR1=4hvlBBN4o5lI#8KPMx}flYNC0jz1+A4wME-eAM z3JWRRMWxz1$3E?wP&0oWHPG9r@9qQU{&6IW)e9!o-4ivO^15v4&fO_5L;u2hp8t_-t2ZKxPR7a!QJD?_zXxdXzE6YIjFT{qp z0F|i~=KeF-K=1$CH3eP=RL4UYf~QauJ8$aO&HekRfkNE&?||kQM!hGheF!GtI8+7~ zq0Y{7+;oT0u-d!|{a6c(dCIN!Jo^$ociYchly zXcsoc{g{Ja;uK7(vbSaps^3$n3BHW2@LlYWe@A`J*Ra}ta1nN;q1x298Q(=6#!pZa zIDy*xGnkC$QK^iWZ~y-9i^{+h)IxGmXQCLp;6~H_5i%j4b(n&K8<*?`tL_5(4+jTo z?}wpMm5my}iBY&7HKA?T9bdx$9LSFsu_Zo>8N^?R+ABnzxaMK=0QP?zg;*N&e?U&+ z1VSmQLyV%WjF>|?27gCgrG%Gy3jTrM*jUljYcK7wt_Wf-?XTbz;$vbw(Sf$6#7BDn z&k(6p&f|Wf_R_asKJB_zU?{PaV5=WqI?aP=Ta80d*JNXoUA2}`-$c0|4#jxWz7Ktk zY0M({3b4B3AVROy4&qfpSACm@KPQ{gEc%8Ky2j%S;zzq`rJMHGP5lRan`lJ)W9Hry z+W(%W!+#myLmix_h;xJvSv%qxLI+D%F$3fhdgX?re%0ogLG??ln8-ABiq@l)TN4Y3 zEHym5$p0x*d4ikHhd1~~2j%C8Z;7*}J&Hd3O1HXU?e#ATr%k0ER&%ccmzlQC#yv*y zcj7x0wEvNOKo;S3yiZh7uDuE=%s2JvrcrN#{$|{4>Q}IYNTc6NrcDeppPOykrco~> zrcwWb@aapZhq*bO8^4(HCA>=f2k~cO0r$q>ABm$xJoRoEi|=6V^|>j$ivvx$Gkt=H z+G`kvNz_|u-G`|(q!K|aA|_JLCRPxwsDF>To+M@wqluZc|BAX^BmS3g(bk>Fquh|t zwUYW+qW01^qOLVWGupc=?}-$e((pWypbub2qW1F8cAUyY?21c?X2d@UFOf`pd#uC~ zq7I>JHtH+(1!4m+g}Qz#)?ODVH1N?FXLCD>ROaSQQpQ}_Lq!Y*Pjk!Bi26!C_se}MB$InG$iiO7< zKA&O>Tx8l8pr5!&IB5S*)4mp$P4`b`r^$AAF;g_v`7 z{Qr!t7wrE%;aYwFt>jMwLR{`@r_0mNv0-y!O3wmk-dwM%%H^zZx*cT&&I(6fWue3A zayuPvm$RV4Rm2@fR#{1j)8TP?7dqXE{=I$f1p0sOpC9bcORE#$KQJ`A&W0FIOMmK! zm;k>geO-W$VTKfxy4)T|m9wHcuf)sXsZMt#pRbr(=BC=!9@FipbQd@ZxLq?1%&9D@ za1|CNS`#Owrj2!sb8M3P{<`C52KpbLc&(m)RnEN-|N8uG0YRB%UMDNRJ3S$ZmFKhaJg?K?Eq0cA zoF!FGkE4tPcs#BmCgdn7t7g=SGM3Kp&P0Eb^Mycvh-+uihL6im`|p=GtK*OHu;vNg yB>}lgoxSGLJa=A^(^2VBQ+|F~rQ7T2S~CvhvEG^oJ9W?L@%%qm>W{CA4EjH%0#r!= diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index af32b22..8758ccb 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 09:25+0000\n" +"POT-Creation-Date: 2026-03-26 10:38+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -57,6 +57,7 @@ msgstr "" #: workflows/forms.py:103 workflows/forms.py:128 #: workflows/templates/workflows/user_management.html:72 +#: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" @@ -89,118 +90,163 @@ msgstr "" #: workflows/forms.py:130 workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 +#: workflows/templates/workflows/user_management.html:171 #, fuzzy #| msgid "Rolle:" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:143 +#: workflows/forms.py:144 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:152 workflows/views.py:472 +#: workflows/forms.py:153 workflows/views.py:567 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:408 +#: workflows/forms.py:155 workflows/views.py:570 +msgid "Nur Platform Owner dürfen diese Rolle vergeben." +msgstr "" + +#: workflows/forms.py:186 +msgid "Portal-Titel" +msgstr "Portal title" + +#: workflows/forms.py:187 +msgid "Firmenname" +msgstr "Company name" + +#: workflows/forms.py:188 +msgid "Support-E-Mail" +msgstr "Support email" + +#: workflows/forms.py:189 +msgid "Standardsprache" +msgstr "Default language" + +#: workflows/forms.py:190 +msgid "Logo" +msgstr "Logo" + +#: workflows/forms.py:191 +msgid "PDF-Briefkopf" +msgstr "PDF letterhead" + +#: workflows/forms.py:192 +msgid "Primärfarbe" +msgstr "Primary color" + +#: workflows/forms.py:193 +msgid "Sekundärfarbe" +msgstr "Secondary color" + +#: workflows/forms.py:207 +msgid "Das Logo darf maximal 5 MB groß sein." +msgstr "" + +#: workflows/forms.py:215 +msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." +msgstr "" + +#: workflows/forms.py:458 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:55 workflows/views.py:199 +#: workflows/models.py:90 workflows/views.py:199 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:56 workflows/views.py:200 +#: workflows/models.py:91 workflows/views.py:200 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:57 workflows/models.py:372 workflows/views.py:201 +#: workflows/models.py:92 workflows/models.py:407 workflows/views.py:201 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:58 workflows/models.py:312 +#: workflows/models.py:93 workflows/models.py:347 #: workflows/templates/workflows/backup_recovery.html:70 #: workflows/templates/workflows/requests_dashboard.html:222 #: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:202 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:65 +#: workflows/models.py:100 msgid "Herr" msgstr "" -#: workflows/models.py:65 +#: workflows/models.py:100 msgid "Frau" msgstr "" -#: workflows/models.py:65 +#: workflows/models.py:100 msgid "Divers" msgstr "" -#: workflows/models.py:75 +#: workflows/models.py:110 msgid "befristet" msgstr "" -#: workflows/models.py:75 +#: workflows/models.py:110 msgid "unbefristet" msgstr "" -#: workflows/models.py:138 +#: workflows/models.py:173 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/models.py:139 +#: workflows/models.py:174 msgid "Geräte" msgstr "" -#: workflows/models.py:140 +#: workflows/models.py:175 msgid "Software" msgstr "" -#: workflows/models.py:141 +#: workflows/models.py:176 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:142 +#: workflows/models.py:177 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:143 +#: workflows/models.py:178 msgid "Ressourcen" msgstr "" -#: workflows/models.py:144 +#: workflows/models.py:179 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:170 +#: workflows/models.py:205 msgid "Automatisch" msgstr "" -#: workflows/models.py:171 workflows/views.py:94 +#: workflows/models.py:206 workflows/views.py:94 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:172 workflows/views.py:95 +#: workflows/models.py:207 workflows/views.py:95 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:173 workflows/views.py:96 +#: workflows/models.py:208 workflows/views.py:96 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:174 workflows/views.py:97 +#: workflows/models.py:209 workflows/views.py:97 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:177 workflows/models.py:258 +#: workflows/models.py:212 workflows/models.py:293 #: workflows/templates/workflows/home.html:62 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 @@ -208,259 +254,263 @@ msgstr "Finish" msgid "Onboarding" msgstr "Onboarding" -#: workflows/models.py:178 workflows/models.py:259 +#: workflows/models.py:213 workflows/models.py:294 #: workflows/templates/workflows/home.html:78 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" msgstr "Offboarding" -#: workflows/models.py:216 +#: workflows/models.py:251 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:217 +#: workflows/models.py:252 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:218 +#: workflows/models.py:253 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:219 +#: workflows/models.py:254 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:220 +#: workflows/models.py:255 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:221 +#: workflows/models.py:256 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:222 +#: workflows/models.py:257 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:223 +#: workflows/models.py:258 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:224 +#: workflows/models.py:259 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:225 +#: workflows/models.py:260 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:226 +#: workflows/models.py:261 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:262 +#: workflows/models.py:297 msgid "Immer" msgstr "" -#: workflows/models.py:263 workflows/models.py:341 +#: workflows/models.py:298 workflows/models.py:376 msgid "Enthält" msgstr "" -#: workflows/models.py:264 workflows/models.py:342 +#: workflows/models.py:299 workflows/models.py:377 msgid "Ist gleich" msgstr "" -#: workflows/models.py:265 +#: workflows/models.py:300 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:266 +#: workflows/models.py:301 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:308 +#: workflows/models.py:343 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:309 +#: workflows/models.py:344 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:310 +#: workflows/models.py:345 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:311 +#: workflows/models.py:346 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:334 workflows/tasks.py:575 +#: workflows/models.py:369 workflows/tasks.py:576 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:335 workflows/tasks.py:576 +#: workflows/models.py:370 workflows/tasks.py:577 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:336 workflows/tasks.py:577 +#: workflows/models.py:371 workflows/tasks.py:578 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:337 workflows/tasks.py:578 +#: workflows/models.py:372 workflows/tasks.py:579 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:340 +#: workflows/models.py:375 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:343 +#: workflows/models.py:378 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:344 +#: workflows/models.py:379 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:371 +#: workflows/models.py:406 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:391 +#: workflows/models.py:426 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:392 +#: workflows/models.py:427 msgid "S3" msgstr "" -#: workflows/models.py:393 +#: workflows/models.py:428 msgid "NFS" msgstr "" -#: workflows/roles.py:20 +#: workflows/roles.py:22 +msgid "Platform Owner" +msgstr "" + +#: workflows/roles.py:23 msgid "Super Admin" msgstr "Super Admin" -#: workflows/roles.py:21 +#: workflows/roles.py:24 msgid "Admin" msgstr "Admin" -#: workflows/roles.py:22 +#: workflows/roles.py:25 msgid "IT Staff" msgstr "IT Staff" -#: workflows/roles.py:23 +#: workflows/roles.py:26 msgid "Mitarbeiter" msgstr "Staff" -#: workflows/tasks.py:591 +#: workflows/tasks.py:592 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" msgstr "%(item)s handed over and basic functions explained" -#: workflows/tasks.py:593 +#: workflows/tasks.py:594 #, python-format msgid "%(item)s gezeigt bzw. Nutzung erklärt" msgstr "%(item)s shown or usage explained" -#: workflows/tasks.py:595 +#: workflows/tasks.py:596 #, python-format msgid "Telefonnummer / Direktwahl erklärt: %(value)s" msgstr "Phone number / direct extension explained: %(value)s" -#: workflows/tasks.py:597 +#: workflows/tasks.py:598 msgid "Arbeitsplatz, Geräte und allgemeine Nutzung besprochen" msgstr "Workplace, devices, and general usage reviewed" -#: workflows/tasks.py:599 +#: workflows/tasks.py:600 #, python-format msgid "%(item)s Zugang erklärt" msgstr "%(item)s access explained" -#: workflows/tasks.py:600 +#: workflows/tasks.py:601 #, python-format msgid "%(item)s Gruppe / Berechtigung erläutert" msgstr "%(item)s group / permission explained" -#: workflows/tasks.py:602 +#: workflows/tasks.py:603 #, python-format msgid "Dienstliche E-Mail-Adresse erläutert: %(value)s" msgstr "Work email address explained: %(value)s" -#: workflows/tasks.py:604 +#: workflows/tasks.py:605 #, python-format msgid "Gruppenpostfach erklärt: %(item)s" msgstr "Group mailbox explained: %(item)s" -#: workflows/tasks.py:606 +#: workflows/tasks.py:607 msgid "Zugänge, Konten und Anmeldelogik besprochen" msgstr "Accesses, accounts, and login logic reviewed" -#: workflows/tasks.py:608 +#: workflows/tasks.py:609 #, python-format msgid "%(item)s Einführung durchgeführt" msgstr "%(item)s introduction completed" -#: workflows/tasks.py:609 +#: workflows/tasks.py:610 #, python-format msgid "%(item)s zusätzlich besprochen" msgstr "%(item)s discussed additionally" -#: workflows/tasks.py:611 +#: workflows/tasks.py:612 msgid "Benötigte Standardsoftware und tägliche Nutzung erklärt" msgstr "Required standard software and daily usage explained" -#: workflows/tasks.py:614 +#: workflows/tasks.py:615 msgid "Passwortregeln und sicherer Umgang besprochen" msgstr "Password rules and secure handling reviewed" -#: workflows/tasks.py:615 +#: workflows/tasks.py:616 msgid "Dateiablage, Nextcloud und Freigaben erklärt" msgstr "File storage, Nextcloud, and sharing explained" -#: workflows/tasks.py:616 +#: workflows/tasks.py:617 msgid "Kommunikationswege und Support-Prozess erklärt" msgstr "Communication channels and support process explained" -#: workflows/tasks.py:619 +#: workflows/tasks.py:620 #, python-format msgid "%(item)s als zusätzliche Ausstattung besprochen" msgstr "%(item)s discussed as additional equipment" -#: workflows/tasks.py:621 +#: workflows/tasks.py:622 #, python-format msgid "Zusätzlicher Zugang besprochen: %(item)s" msgstr "Additional access discussed: %(item)s" -#: workflows/tasks.py:623 +#: workflows/tasks.py:624 #, python-format msgid "Übergabe-/Nachfolgekontext besprochen: %(value)s" msgstr "Handover / successor context reviewed: %(value)s" @@ -507,13 +557,15 @@ msgstr "Password saved" msgid "" "Ihr Passwort wurde erfolgreich gesetzt. Sie können sich jetzt mit Ihrem " "Benutzerkonto anmelden." -msgstr "Your password has been set successfully. You can now sign in with your account." +msgstr "" +"Your password has been set successfully. You can now sign in with your " +"account." #: workflows/templates/registration/password_reset_complete.html:19 #: workflows/templates/registration/password_reset_confirm.html:41 #: workflows/templates/registration/password_reset_done.html:19 #: workflows/templates/workflows/auth/password_reset_complete.html:19 -#: workflows/templates/workflows/auth/password_reset_confirm.html:44 +#: workflows/templates/workflows/auth/password_reset_confirm.html:43 #: workflows/templates/workflows/auth/password_reset_done.html:19 msgid "Zur Anmeldung" msgstr "Back to sign in" @@ -541,17 +593,17 @@ msgid "Bitte prüfen Sie die beiden Passwortfelder und versuchen Sie es erneut." msgstr "Please check both password fields and try again." #: workflows/templates/registration/password_reset_confirm.html:36 -#: workflows/templates/workflows/auth/password_reset_confirm.html:39 +#: workflows/templates/workflows/auth/password_reset_confirm.html:38 msgid "Passwort speichern" msgstr "Save password" #: workflows/templates/registration/password_reset_confirm.html:39 -#: workflows/templates/workflows/auth/password_reset_confirm.html:42 +#: workflows/templates/workflows/auth/password_reset_confirm.html:41 msgid "Link ungültig" msgstr "Invalid link" #: workflows/templates/registration/password_reset_confirm.html:40 -#: workflows/templates/workflows/auth/password_reset_confirm.html:43 +#: workflows/templates/workflows/auth/password_reset_confirm.html:42 msgid "" "Dieser Link ist nicht mehr gültig. Bitte fordern Sie einen neuen Passwort-" "Link an." @@ -569,7 +621,9 @@ msgstr "Email sent" msgid "" "Wenn ein passendes Konto existiert, wurde ein Passwort-Link an die " "hinterlegte E-Mail-Adresse verschickt." -msgstr "If a matching account exists, a password link has been sent to the stored email address." +msgstr "" +"If a matching account exists, a password link has been sent to the stored " +"email address." #: workflows/templates/registration/password_reset_form.html:4 #: workflows/templates/registration/password_reset_form.html:17 @@ -583,7 +637,9 @@ msgstr "Reset password" msgid "" "Geben Sie Ihre E-Mail-Adresse ein. Wenn ein Konto vorhanden ist, erhalten " "Sie einen Passwort-Link." -msgstr "Enter your email address. If an account exists, you will receive a password link." +msgstr "" +"Enter your email address. If an account exists, you will receive a password " +"link." #: workflows/templates/registration/password_reset_form.html:24 #: workflows/templates/workflows/auth/password_reset_form.html:25 @@ -592,7 +648,7 @@ msgstr "Request link" #: workflows/templates/workflows/audit_log.html:4 #: workflows/templates/workflows/audit_log.html:15 -#: workflows/templates/workflows/home.html:132 +#: workflows/templates/workflows/home.html:148 msgid "Audit Log" msgstr "" @@ -604,6 +660,7 @@ msgstr "" #: workflows/templates/workflows/audit_log.html:54 #: workflows/templates/workflows/backup_recovery.html:43 #: workflows/templates/workflows/requests_dashboard.html:193 +#: workflows/templates/workflows/user_management.html:156 #: workflows/templates/workflows/welcome_emails.html:87 msgid "Aktion" msgstr "Action" @@ -644,6 +701,7 @@ msgid "Zurücksetzen" msgstr "Reset" #: workflows/templates/workflows/audit_log.html:52 +#: workflows/templates/workflows/user_management.html:155 msgid "Zeit" msgstr "" @@ -659,6 +717,7 @@ msgid "Ziel" msgstr "" #: workflows/templates/workflows/audit_log.html:57 +#: workflows/templates/workflows/user_management.html:159 msgid "Details" msgstr "" @@ -688,7 +747,7 @@ msgstr "No requests available yet." #: workflows/templates/workflows/backup_recovery.html:4 #: workflows/templates/workflows/backup_recovery.html:12 -#: workflows/templates/workflows/home.html:139 +#: workflows/templates/workflows/home.html:155 msgid "Backup & Recovery" msgstr "Backup & Recovery" @@ -855,9 +914,53 @@ msgstr "Action in progress" msgid "Die Aktion wird im aktuellen Tab ausgeführt." msgstr "The action is running in the current tab." +#: workflows/templates/workflows/branding_settings.html:4 +#: workflows/templates/workflows/branding_settings.html:12 +#: workflows/templates/workflows/home.html:118 +msgid "Branding" +msgstr "Branding" + +#: workflows/templates/workflows/branding_settings.html:13 +msgid "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." +msgstr "" +"Manage portal name, company branding, logo, and PDF letterhead centrally." + +#: workflows/templates/workflows/branding_settings.html:48 +msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." +msgstr "" + +#: workflows/templates/workflows/branding_settings.html:51 +msgid "Aktuelles Logo:" +msgstr "Current logo:" + +#: workflows/templates/workflows/branding_settings.html:51 +#: workflows/templates/workflows/branding_settings.html:60 +msgid "öffnen" +msgstr "open" + +#: workflows/templates/workflows/branding_settings.html:57 +msgid "Erlaubtes Format: PDF. Maximal 10 MB." +msgstr "" + +#: workflows/templates/workflows/branding_settings.html:60 +msgid "Aktueller Briefkopf:" +msgstr "Current letterhead:" + +#: workflows/templates/workflows/branding_settings.html:65 +msgid "" +"TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " +"hochgeladen werden." +msgstr "" +"TUBCO remains the default until values are changed or files are uploaded " +"here." + +#: workflows/templates/workflows/branding_settings.html:66 +msgid "Branding speichern" +msgstr "Save branding" + #: workflows/templates/workflows/form_builder.html:4 #: workflows/templates/workflows/form_builder.html:14 -#: workflows/templates/workflows/home.html:153 +#: workflows/templates/workflows/home.html:169 msgid "Form Builder" msgstr "Form Builder" @@ -982,7 +1085,7 @@ msgstr "Save field text" #: workflows/templates/workflows/handbook.html:4 #: workflows/templates/workflows/handbook.html:15 -#: workflows/templates/workflows/home.html:165 +#: workflows/templates/workflows/home.html:181 msgid "Handbook" msgstr "Handbook" @@ -1103,12 +1206,6 @@ msgstr "" msgid "Open Release Checklist" msgstr "" -#: workflows/templates/workflows/home.html:4 -#: workflows/templates/workflows/home.html:35 -#: workflows/templates/workflows/requests_dashboard.html:303 -msgid "TUBCO Onboarding & Offboarding Portal" -msgstr "TUBCO Onboarding & Offboarding Portal" - #: workflows/templates/workflows/home.html:26 msgid "Abmelden" msgstr "Log out" @@ -1244,88 +1341,103 @@ msgstr "PDF access" msgid "Dashboard öffnen" msgstr "Open dashboard" -#: workflows/templates/workflows/home.html:112 -msgid "Admin Apps" -msgstr "Admin Apps" - #: workflows/templates/workflows/home.html:113 -msgid "Konfiguration, Tests und Steuerung." +msgid "Platform Apps" +msgstr "" + +#: workflows/templates/workflows/home.html:114 +#, fuzzy +#| msgid "Konfiguration, Tests und Steuerung." +msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/templates/workflows/home.html:118 -msgid "Integrationen" -msgstr "Integrations" - #: workflows/templates/workflows/home.html:119 -msgid "Nextcloud- und E-Mail-Setup." -msgstr "Nextcloud and email setup." +msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." +msgstr "Manage logo, portal name, colors, and PDF letterhead." #: workflows/templates/workflows/home.html:120 -#: workflows/templates/workflows/home.html:127 -#: workflows/templates/workflows/home.html:134 -#: workflows/templates/workflows/home.html:141 -#: workflows/templates/workflows/home.html:148 -#: workflows/templates/workflows/home.html:155 -#: workflows/templates/workflows/home.html:160 -#: workflows/templates/workflows/home.html:167 -#: workflows/templates/workflows/home.html:174 +#: workflows/templates/workflows/home.html:136 +#: workflows/templates/workflows/home.html:143 +#: workflows/templates/workflows/home.html:150 +#: workflows/templates/workflows/home.html:157 +#: workflows/templates/workflows/home.html:164 +#: workflows/templates/workflows/home.html:171 +#: workflows/templates/workflows/home.html:176 +#: workflows/templates/workflows/home.html:183 +#: workflows/templates/workflows/home.html:190 msgid "Öffnen" msgstr "Open" -#: workflows/templates/workflows/home.html:125 +#: workflows/templates/workflows/home.html:128 +msgid "Admin Apps" +msgstr "Admin Apps" + +#: workflows/templates/workflows/home.html:129 +msgid "Konfiguration, Tests und Steuerung." +msgstr "Configuration, tests, and controls." + +#: workflows/templates/workflows/home.html:134 +msgid "Integrationen" +msgstr "Integrations" + +#: workflows/templates/workflows/home.html:135 +msgid "Nextcloud- und E-Mail-Setup." +msgstr "Nextcloud and email setup." + +#: workflows/templates/workflows/home.html:141 #: workflows/templates/workflows/user_management.html:4 #: workflows/templates/workflows/user_management.html:14 msgid "Benutzer & Rollen" msgstr "Users & roles" -#: workflows/templates/workflows/home.html:126 +#: workflows/templates/workflows/home.html:142 msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." msgstr "Create users, assign roles, and control access." -#: workflows/templates/workflows/home.html:133 +#: workflows/templates/workflows/home.html:149 msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." msgstr "" -#: workflows/templates/workflows/home.html:140 +#: workflows/templates/workflows/home.html:156 msgid "Backups erstellen und sicher verifizieren." msgstr "" -#: workflows/templates/workflows/home.html:146 +#: workflows/templates/workflows/home.html:162 #: workflows/templates/workflows/welcome_emails.html:4 msgid "Welcome E-Mails" msgstr "Welcome Emails" -#: workflows/templates/workflows/home.html:147 +#: workflows/templates/workflows/home.html:163 msgid "Geplante Welcome Mails verwalten." msgstr "Manage scheduled welcome emails." -#: workflows/templates/workflows/home.html:154 +#: workflows/templates/workflows/home.html:170 msgid "Felder, Schritte und Optionen verwalten." msgstr "Manage fields, steps, and options." -#: workflows/templates/workflows/home.html:158 +#: workflows/templates/workflows/home.html:174 #: 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:159 +#: workflows/templates/workflows/home.html:175 msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." msgstr "Configure checklist items for the introduction protocol." -#: workflows/templates/workflows/home.html:166 +#: workflows/templates/workflows/home.html:182 msgid "Project wiki and developer documentation in one place." msgstr "Project wiki and developer documentation in one place." -#: workflows/templates/workflows/home.html:172 +#: workflows/templates/workflows/home.html:188 msgid "Django Admin" msgstr "Django Admin" -#: workflows/templates/workflows/home.html:173 +#: workflows/templates/workflows/home.html:189 msgid "Vollständige Datenverwaltung." msgstr "Full data management." -#: workflows/templates/workflows/home.html:181 +#: workflows/templates/workflows/home.html:197 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." @@ -1890,7 +2002,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:708 +#: workflows/views.py:820 msgid "Vertragsbeginn" msgstr "Contract start" @@ -2151,6 +2263,7 @@ msgstr "" #: workflows/templates/workflows/request_timeline.html:74 #: workflows/templates/workflows/requests_dashboard.html:190 #: workflows/templates/workflows/user_management.html:73 +#: workflows/templates/workflows/user_management.html:172 msgid "E-Mail" msgstr "Email" @@ -2330,7 +2443,12 @@ msgid "Noch keine Vorgänge vorhanden." msgstr "No requests available yet." #: workflows/templates/workflows/user_management.html:15 -msgid "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." +#, fuzzy +#| msgid "" +#| "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." +msgid "" +"Platform Owner und Super Admins verwalten Benutzerkonten, Rollen und den " +"aktiven Zugriff." msgstr "Super admins manage user accounts, roles, and active access." #: workflows/templates/workflows/user_management.html:22 @@ -2394,13 +2512,59 @@ msgid "Es sind noch keine Benutzer vorhanden." msgstr "No users exist yet." #: workflows/templates/workflows/user_management.html:140 +#, fuzzy +#| msgid "" +#| "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst " +#| "deaktivieren oder auf eine niedrigere Rolle setzen." msgid "" -"Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst " -"deaktivieren oder auf eine niedrigere Rolle setzen." +"Hinweis: Der letzte aktive Platform Owner oder Super Admin kann sich hier " +"nicht selbst entfernen oder auf eine niedrigere Rolle setzen." msgstr "" "Note: The currently signed-in super admin cannot deactivate themselves or " "assign a lower role here." +#: workflows/templates/workflows/user_management.html:146 +#, fuzzy +#| msgid "Benutzer anlegen" +msgid "Letzte Benutzeraktionen" +msgstr "Create user" + +#: workflows/templates/workflows/user_management.html:147 +msgid "Die letzten Änderungen an Benutzerkonten und Rollen." +msgstr "" + +#: workflows/templates/workflows/user_management.html:149 +msgid "Zum Audit Log" +msgstr "" + +#: workflows/templates/workflows/user_management.html:157 +#, fuzzy +#| msgid "Vorgänge" +msgid "Betroffen" +msgstr "Requests" + +#: workflows/templates/workflows/user_management.html:158 +msgid "Durch" +msgstr "" + +#: workflows/templates/workflows/user_management.html:173 +#, fuzzy +#| msgid "E-Mail versendet" +msgid "Einladung versendet" +msgstr "Email sent" + +#: workflows/templates/workflows/user_management.html:174 +#, fuzzy +#| msgid "Passwort gespeichert" +msgid "Passwort geändert" +msgstr "Password saved" + +#: workflows/templates/workflows/user_management.html:180 +#, fuzzy +#| msgid "Es sind noch keine Benutzer vorhanden." +msgid "Noch keine Benutzeraktionen vorhanden." +msgstr "No users exist yet." + #: workflows/templates/workflows/welcome_emails.html:14 msgid "Geplante Welcome E-Mails" msgstr "Scheduled welcome emails" @@ -2503,7 +2667,7 @@ msgstr "Devices, software, and access" msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:128 workflows/views.py:794 workflows/views.py:799 +#: workflows/views.py:128 workflows/views.py:906 workflows/views.py:911 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." @@ -2715,22 +2879,21 @@ msgstr "Request saved" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:400 +#: workflows/views.py:436 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:408 +#: workflows/views.py:445 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:410 +#: workflows/views.py:447 #, python-format msgid "" "Hallo %(name)s,\n" "\n" -"für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal " -"angelegt.\n" +"für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n" "Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n" "%(url)s\n" "\n" @@ -2738,12 +2901,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:420 +#: workflows/views.py:458 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:422 +#: workflows/views.py:460 #, python-format msgid "" "Hallo %(name)s,\n" @@ -2756,24 +2919,60 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:445 +#: workflows/views.py:498 +#, fuzzy +#| msgid "" +#| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." +msgid "" +"Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." +msgstr "User could not be created. Please check the input." + +#: workflows/views.py:524 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Portal-Branding wurde gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:540 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:458 +#: workflows/views.py:553 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:476 +#: workflows/views.py:575 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren " +"oder herabstufen." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:578 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:479 +#: workflows/views.py:581 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft " +"werden." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:584 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2784,18 +2983,28 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:496 +#: workflows/views.py:601 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:518 +#: workflows/views.py:623 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:529 +#: workflows/views.py:635 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "" +"Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:638 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2805,7 +3014,16 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:532 +#: workflows/views.py:641 +#, fuzzy +#| msgid "" +#| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " +#| "oder herabstufen." +msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." +msgstr "" +"The currently signed-in super admin cannot lock or downgrade themselves here." + +#: workflows/views.py:644 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2814,124 +3032,132 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:545 +#: workflows/views.py:657 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:632 +#: workflows/views.py:744 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:634 +#: workflows/views.py:746 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:650 +#: workflows/views.py:762 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:652 +#: workflows/views.py:764 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:668 +#: workflows/views.py:780 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:670 +#: workflows/views.py:782 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:696 +#: workflows/views.py:808 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:698 +#: workflows/views.py:810 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:710 +#: workflows/views.py:822 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:720 +#: workflows/views.py:832 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:722 +#: workflows/views.py:834 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:731 +#: workflows/views.py:843 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:757 +#: workflows/views.py:869 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:769 +#: workflows/views.py:881 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:808 +#: workflows/views.py:920 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:851 +#: workflows/views.py:963 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:853 +#: workflows/views.py:965 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:855 +#: workflows/views.py:967 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1082 +#: workflows/views.py:1194 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1099 +#: workflows/views.py:1211 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1128 +#: workflows/views.py:1240 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1142 +#: workflows/views.py:1254 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1155 +#: workflows/views.py:1267 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." +#, fuzzy +#~| msgid "Produktion" +#~ msgid "Product Owner" +#~ msgstr "Production" + +#~ msgid "TUBCO Onboarding & Offboarding Portal" +#~ msgstr "TUBCO Onboarding & Offboarding Portal" + #~ msgid "Die Passwörter stimmen nicht überein." #~ msgstr "The passwords do not match." diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index 5895ee7..d142cb2 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig @admin.register(EmployeeProfile) @@ -20,6 +20,11 @@ class AdminAuditLogAdmin(admin.ModelAdmin): ordering = ('-created_at', '-id') +@admin.register(PortalBranding) +class PortalBrandingAdmin(admin.ModelAdmin): + list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at') + + @admin.register(OnboardingRequest) class OnboardingRequestAdmin(admin.ModelAdmin): list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at') diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py new file mode 100644 index 0000000..b25a984 --- /dev/null +++ b/backend/workflows/branding.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from pathlib import Path + +from django.conf import settings +from django.templatetags.static import static + +from .models import PortalBranding + + +def get_portal_branding() -> PortalBranding: + branding, _ = PortalBranding.objects.get_or_create( + name='Default', + defaults={ + 'portal_title': 'TUBCO Onboarding & Offboarding Portal', + 'company_name': 'TUBCO', + 'support_email': 'info@tub.co', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + ) + return branding + + +def get_portal_logo_url() -> str: + branding = get_portal_branding() + if branding.logo_image: + try: + return branding.logo_image.url + except ValueError: + pass + return static('workflows/img/tubco-logo.svg') + + +def get_portal_letterhead_path() -> Path: + branding = get_portal_branding() + if branding.pdf_letterhead: + try: + candidate = Path(branding.pdf_letterhead.path) + if candidate.exists(): + return candidate + except (ValueError, NotImplementedError): + pass + return settings.PDF_TEMPLATES_DIR / 'templates.pdf' + + +def get_branding_context() -> dict[str, object]: + branding = get_portal_branding() + return { + 'portal_branding': branding, + 'portal_title': branding.portal_title, + 'portal_company_name': branding.company_name, + 'portal_support_email': branding.support_email, + 'portal_default_language': branding.default_language, + 'portal_primary_color': branding.primary_color, + 'portal_secondary_color': branding.secondary_color, + 'portal_logo_url': get_portal_logo_url(), + 'portal_has_custom_logo': bool(branding.logo_image), + 'portal_has_custom_letterhead': bool(branding.pdf_letterhead), + } + + +def get_branding_email_copy() -> dict[str, str]: + branding = get_portal_branding() + company_name = (branding.company_name or 'TUBCO').strip() + portal_title = (branding.portal_title or f'{company_name} Portal').strip() + return { + 'company_name': company_name, + 'portal_title': portal_title, + 'support_email': (branding.support_email or '').strip(), + } + + +def get_default_notification_templates() -> dict[str, dict[str, str]]: + from copy import deepcopy + + from .tasks import DEFAULT_NOTIFICATION_TEMPLATES + + templates = deepcopy(DEFAULT_NOTIFICATION_TEMPLATES) + company_name = get_branding_email_copy()['company_name'] + welcome = templates.get('onboarding_welcome') + if welcome: + welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}' + welcome['subject_en'] = f'Welcome to {company_name}, {{ VORNAME }}' + welcome['body'] = ( + 'Hallo {{ FULL_NAME }},\n\n' + f'herzlich willkommen bei {company_name}.\n' + 'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n' + 'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n' + 'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n' + 'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n' + 'Viele Grüße\n' + f'{company_name} IT' + ) + welcome['body_en'] = ( + 'Hello {{ FULL_NAME }},\n\n' + f'welcome to {company_name}.\n' + 'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n' + 'Your work email address is: {{ EMAIL }}.\n' + 'You will find your onboarding documents attached as a PDF.\n\n' + 'If you have any questions, feel free to contact us anytime.\n\n' + 'Best regards,\n' + f'{company_name} IT' + ) + return templates diff --git a/backend/workflows/context_processors.py b/backend/workflows/context_processors.py index 64bad7f..162e55b 100644 --- a/backend/workflows/context_processors.py +++ b/backend/workflows/context_processors.py @@ -1,5 +1,8 @@ +from .branding import get_branding_context from .roles import template_role_context def role_context(request): - return template_role_context(getattr(request, 'user', None)) + context = template_role_context(getattr(request, 'user', None)) + context.update(get_branding_context()) + return context diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 2853db5..d5ca11b 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -7,8 +7,8 @@ from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy from .form_builder import apply_form_field_config -from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig -from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role +from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, WorkflowConfig +from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -129,12 +129,13 @@ class UserManagementCreateForm(forms.Form): email = forms.EmailField(label=_('E-Mail-Adresse')) role_key = forms.ChoiceField(label=_('Rolle')) - def __init__(self, *args, **kwargs): + def __init__(self, *args, include_product_owner: bool = False, **kwargs): super().__init__(*args, **kwargs) - self.fields['role_key'].choices = [ - (role_key, str(ROLE_LABELS[role_key])) - for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF) - ] + self.include_product_owner = include_product_owner + role_order = [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF] + if include_product_owner: + role_order = [ROLE_PLATFORM_OWNER] + role_order + self.fields['role_key'].choices = [(role_key, str(ROLE_LABELS[role_key])) for role_key in role_order] def clean_username(self): username = (self.cleaned_data.get('username') or '').strip() @@ -150,6 +151,8 @@ class UserManagementCreateForm(forms.Form): role_key = (self.cleaned_data.get('role_key') or '').strip() if role_key not in ROLE_GROUP_NAMES: raise forms.ValidationError(_('Ungültige Rolle.')) + if role_key == ROLE_PLATFORM_OWNER and not self.include_product_owner: + raise forms.ValidationError(_('Nur Platform Owner dürfen diese Rolle vergeben.')) return role_key def save(self): @@ -166,6 +169,53 @@ class UserManagementCreateForm(forms.Form): return user +class PortalBrandingForm(forms.ModelForm): + class Meta: + model = PortalBranding + fields = [ + 'portal_title', + 'company_name', + 'support_email', + 'default_language', + 'logo_image', + 'pdf_letterhead', + 'primary_color', + 'secondary_color', + ] + labels = { + 'portal_title': gettext_lazy('Portal-Titel'), + 'company_name': gettext_lazy('Firmenname'), + 'support_email': gettext_lazy('Support-E-Mail'), + 'default_language': gettext_lazy('Standardsprache'), + 'logo_image': gettext_lazy('Logo'), + 'pdf_letterhead': gettext_lazy('PDF-Briefkopf'), + 'primary_color': gettext_lazy('Primärfarbe'), + 'secondary_color': gettext_lazy('Sekundärfarbe'), + } + widgets = { + 'primary_color': forms.TextInput(attrs={'type': 'color'}), + 'secondary_color': forms.TextInput(attrs={'type': 'color'}), + 'logo_image': forms.ClearableFileInput(attrs={'accept': '.svg,.png,.jpg,.jpeg,.webp'}), + 'pdf_letterhead': forms.ClearableFileInput(attrs={'accept': '.pdf'}), + } + + def clean_logo_image(self): + logo = self.cleaned_data.get('logo_image') + if not logo: + return logo + if getattr(logo, 'size', 0) > 5 * 1024 * 1024: + raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.')) + return logo + + def clean_pdf_letterhead(self): + letterhead = self.cleaned_data.get('pdf_letterhead') + if not letterhead: + return letterhead + if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024: + raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.')) + return letterhead + + class OnboardingRequestForm(forms.ModelForm): first_name = forms.CharField(label='Vorname', required=False) last_name = forms.CharField(label='Nachname', required=False) diff --git a/backend/workflows/management/commands/bootstrap_initial_users.py b/backend/workflows/management/commands/bootstrap_initial_users.py index 5adfb7f..aa35bce 100644 --- a/backend/workflows/management/commands/bootstrap_initial_users.py +++ b/backend/workflows/management/commands/bootstrap_initial_users.py @@ -1,7 +1,7 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from workflows.roles import ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, ensure_role_groups +from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role, ensure_role_groups DEFAULT_USERS = [ { @@ -45,7 +45,7 @@ class Command(BaseCommand): is_superuser=item['is_superuser'], ) ensure_role_groups() - assign_user_role(user, ROLE_SUPER_ADMIN if item['username'] == 'admin_test' else ROLE_STAFF) + assign_user_role(user, ROLE_PLATFORM_OWNER if item['username'] == 'admin_test' else ROLE_STAFF) self.stdout.write(f'created {user.username}') self.stdout.write(self.style.SUCCESS('initial users created')) diff --git a/backend/workflows/migrations/0036_portalbranding.py b/backend/workflows/migrations/0036_portalbranding.py new file mode 100644 index 0000000..a06b44b --- /dev/null +++ b/backend/workflows/migrations/0036_portalbranding.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0035_workflowconfig_remote_backup_enabled_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalBranding', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=80, unique=True)), + ('portal_title', models.CharField(default='TUBCO Onboarding & Offboarding Portal', max_length=255)), + ('company_name', models.CharField(default='TUBCO', max_length=255)), + ('support_email', models.EmailField(blank=True, default='info@tub.co', max_length=254)), + ('default_language', models.CharField(choices=[('de', 'Deutsch'), ('en', 'English')], default='de', max_length=10)), + ('logo_image', models.ImageField(blank=True, null=True, upload_to='branding/')), + ('pdf_letterhead', models.FileField(blank=True, null=True, upload_to='branding/')), + ('primary_color', models.CharField(blank=True, default='#000078', max_length=20)), + ('secondary_color', models.CharField(blank=True, default='#c0002b', max_length=20)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal Branding', + 'verbose_name_plural': 'Portal Branding', + }, + ), + ] diff --git a/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py b/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py new file mode 100644 index 0000000..0fb23eb --- /dev/null +++ b/backend/workflows/migrations/0037_alter_portalbranding_logo_image_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:25 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0036_portalbranding'), + ] + + operations = [ + migrations.AlterField( + model_name='portalbranding', + name='logo_image', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])]), + ), + migrations.AlterField( + model_name='portalbranding', + name='pdf_letterhead', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])]), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 7f6498c..f986b51 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.validators import FileExtensionValidator from django.db import models from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ @@ -24,6 +25,40 @@ class EmployeeProfile(models.Model): return f"{self.full_name} <{self.work_email}>" +class PortalBranding(models.Model): + name = models.CharField(max_length=80, default='Default', unique=True) + portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal') + company_name = models.CharField(max_length=255, default='TUBCO') + support_email = models.EmailField(blank=True, default='info@tub.co') + default_language = models.CharField( + max_length=10, + choices=[('de', 'Deutsch'), ('en', 'English')], + default='de', + ) + logo_image = models.FileField( + upload_to='branding/', + blank=True, + null=True, + validators=[FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])], + ) + pdf_letterhead = models.FileField( + upload_to='branding/', + blank=True, + null=True, + validators=[FileExtensionValidator(allowed_extensions=['pdf'])], + ) + primary_color = models.CharField(max_length=20, blank=True, default='#000078') + secondary_color = models.CharField(max_length=20, blank=True, default='#c0002b') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Portal Branding' + verbose_name_plural = 'Portal Branding' + + def __str__(self) -> str: + return self.portal_title or self.company_name or self.name + + class AdminAuditLog(models.Model): actor = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index f487490..890d2d0 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -4,12 +4,14 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.utils.translation import gettext_lazy as _ +ROLE_PLATFORM_OWNER = 'platform_owner' ROLE_SUPER_ADMIN = 'super_admin' ROLE_ADMIN = 'admin' ROLE_IT_STAFF = 'it_staff' ROLE_STAFF = 'staff' ROLE_GROUP_NAMES = { + ROLE_PLATFORM_OWNER: 'Platform Owner', ROLE_SUPER_ADMIN: 'Super Admin', ROLE_ADMIN: 'Admin', ROLE_IT_STAFF: 'IT Staff', @@ -17,6 +19,7 @@ ROLE_GROUP_NAMES = { } ROLE_LABELS = { + ROLE_PLATFORM_OWNER: _('Platform Owner'), ROLE_SUPER_ADMIN: _('Super Admin'), ROLE_ADMIN: _('Admin'), ROLE_IT_STAFF: _('IT Staff'), @@ -24,19 +27,20 @@ ROLE_LABELS = { } CAPABILITIES = { - 'manage_users': {ROLE_SUPER_ADMIN}, - 'access_requests_dashboard': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'run_intro_session': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'generate_intro_pdfs': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'retry_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, - 'delete_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_integrations': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_welcome_emails': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_builders': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'view_audit_log': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'manage_backups': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'view_docs': {ROLE_SUPER_ADMIN, ROLE_ADMIN}, - 'access_django_admin_link': {ROLE_SUPER_ADMIN}, + 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, + 'manage_product_branding': {ROLE_PLATFORM_OWNER}, + 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'run_intro_session': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'generate_intro_pdfs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'retry_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'delete_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_builders': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'view_audit_log': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'manage_backups': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'view_docs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN}, + 'access_django_admin_link': {ROLE_PLATFORM_OWNER}, } @@ -54,16 +58,17 @@ def assign_user_role(user, role_key: str) -> None: user.groups.remove(*role_groups) user.groups.add(Group.objects.get(name=ROLE_GROUP_NAMES[role_key])) + is_product_owner = role_key == ROLE_PLATFORM_OWNER is_super_admin = role_key == ROLE_SUPER_ADMIN - user.is_staff = is_super_admin - user.is_superuser = is_super_admin + user.is_staff = is_product_owner or is_super_admin + user.is_superuser = is_product_owner user.save(update_fields=['is_staff', 'is_superuser']) def ensure_bootstrap_role_assignments() -> None: user_model = get_user_model() bootstrap_roles = { - 'admin_test': ROLE_SUPER_ADMIN, + 'admin_test': ROLE_PLATFORM_OWNER, 'user_test': ROLE_STAFF, } role_group_names = set(ROLE_GROUP_NAMES.values()) @@ -72,6 +77,12 @@ def ensure_bootstrap_role_assignments() -> None: user = user_model.objects.get(username=username) except user_model.DoesNotExist: continue + if role_key == ROLE_PLATFORM_OWNER and not any( + get_user_role_key(existing_user) == ROLE_PLATFORM_OWNER + for existing_user in user_model.objects.all() + ): + assign_user_role(user, ROLE_PLATFORM_OWNER) + continue if user.groups.filter(name__in=role_group_names).exists(): continue assign_user_role(user, role_key) @@ -81,15 +92,15 @@ def get_user_role_key(user) -> str: if not getattr(user, 'is_authenticated', False): return ROLE_STAFF if getattr(user, 'is_superuser', False): - return ROLE_SUPER_ADMIN + return ROLE_PLATFORM_OWNER group_names = set(user.groups.values_list('name', flat=True)) - for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF): + for role_key in (ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF): if ROLE_GROUP_NAMES[role_key] in group_names: return role_key if getattr(user, 'is_staff', False): - return ROLE_ADMIN + return ROLE_SUPER_ADMIN return ROLE_STAFF @@ -111,6 +122,7 @@ def template_role_context(user) -> dict[str, object]: return { 'role_key': role_key, 'role_label': str(ROLE_LABELS[role_key]), + 'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'), 'can_manage_users': user_has_capability(user, 'manage_users'), 'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'), 'can_run_intro_session': user_has_capability(user, 'run_intro_session'), diff --git a/backend/workflows/static/workflows/css/home.css b/backend/workflows/static/workflows/css/home.css index a9976a1..11817de 100644 --- a/backend/workflows/static/workflows/css/home.css +++ b/backend/workflows/static/workflows/css/home.css @@ -362,6 +362,8 @@ gap: 10px; align-items: flex-end; flex-wrap: wrap; + position: relative; + padding-left: 16px; } .section-head h2 { @@ -376,6 +378,32 @@ font-size: 13px; } + .section-divider { + height: 1px; + margin: 24px 0 14px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(0, 0, 120, 0.18), rgba(0, 0, 120, 0.05) 40%, rgba(140, 29, 29, 0.10)); + } + + .section-head::before { + content: ""; + position: absolute; + left: 0; + top: 2px; + width: 4px; + height: 34px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(0, 0, 120, 0.95), rgba(0, 0, 120, 0.30)); + } + + .section-head-platform::before { + background: linear-gradient(180deg, rgba(140, 29, 29, 0.90), rgba(140, 29, 29, 0.28)); + } + + .section-head-admin::before { + background: linear-gradient(180deg, rgba(159, 118, 33, 0.92), rgba(159, 118, 33, 0.28)); + } + .apps-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 56c305d..9e804b8 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -13,6 +13,7 @@ from jinja2 import Template from pypdf import PageObject, PdfReader, PdfWriter from xhtml2pdf import pisa +from .branding import get_default_notification_templates, get_portal_letterhead_path from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .emailing import send_system_email from .services import upload_to_nextcloud @@ -678,7 +679,7 @@ def _render_notification_template(template_key: str, context: dict, language_cod subject_template = db_template.translated_subject_template(lang) body_template = db_template.translated_body_template(lang) else: - fallback = DEFAULT_NOTIFICATION_TEMPLATES[template_key] + fallback = get_default_notification_templates()[template_key] subject_template = fallback.get(f'subject_{lang}', '') or fallback['subject'] body_template = fallback.get(f'body_{lang}', '') or fallback['body'] @@ -865,7 +866,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_{safe_name}.pdf' template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_template.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + letterhead_path = get_portal_letterhead_path() devices = _split_multiline(request_obj.needed_devices) software = _split_multiline(request_obj.needed_software) @@ -998,7 +999,7 @@ def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf' template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + letterhead_path = get_portal_letterhead_path() salutation = (request_obj.get_gender_display() or '').strip() display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name @@ -1048,7 +1049,7 @@ def _generate_onboarding_intro_session_pdf( temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf' template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + letterhead_path = get_portal_letterhead_path() salutation = (request_obj.get_gender_display() or '').strip() display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name @@ -1109,7 +1110,7 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_offboarding_{safe_name}.pdf' template_path = settings.PDF_TEMPLATES_DIR / 'offboarding_template.html' - letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + letterhead_path = get_portal_letterhead_path() latest_onboarding = ( OnboardingRequest.objects.filter(work_email=request_obj.work_email) .order_by('-created_at') diff --git a/backend/workflows/templates/workflows/branding_settings.html b/backend/workflows/templates/workflows/branding_settings.html new file mode 100644 index 0000000..4f9138e --- /dev/null +++ b/backend/workflows/templates/workflows/branding_settings.html @@ -0,0 +1,70 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "Branding" %}{% 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 "Branding" %}

+

{% trans "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." %}

+ +{% include 'workflows/includes/messages.html' %} + +
+
+ {% csrf_token %} +
+
+ + {{ form.portal_title }} +
+
+ + {{ form.company_name }} +
+
+ + {{ form.support_email }} +
+
+ + {{ form.default_language }} +
+
+ + {{ form.primary_color }} +
+
+ + {{ form.secondary_color }} +
+
+ + {{ form.logo_image }} +
{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}
+ {% for error in form.logo_image.errors %}
{{ error }}
{% endfor %} + {% if branding.logo_image %} +
{% trans "Aktuelles Logo:" %} {% trans "öffnen" %}
+ {% endif %} +
+
+ + {{ form.pdf_letterhead }} +
{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}
+ {% for error in form.pdf_letterhead.errors %}
{{ error }}
{% endfor %} + {% if branding.pdf_letterhead %} +
{% trans "Aktueller Briefkopf:" %} {% trans "öffnen" %}
+ {% endif %} +
+
+
+
{% trans "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}
+ +
+
+
+{% endblock %} diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 94841e4..76e4552 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -17,7 +17,7 @@ Project Wiki -

Engineering runbook for development, deployment, maintenance, and extension of the TUBCO Onboarding & Offboarding Portal.

+

Engineering runbook for development, deployment, maintenance, and extension of the current company portal deployment.

Overview @@ -133,7 +133,7 @@ docker compose exec -T web django-admin compilemessages

7) PDF Pipeline

  • PDF generation is HTML-to-PDF using xhtml2pdf.
  • -
  • Letterhead overlay is applied from templates.pdf.
  • +
  • Letterhead overlay defaults to templates.pdf, but can now be replaced from Admin Apps → Branding.
  • Main logic lives in backend/workflows/tasks.py.
  • Fixed PDF labels/headings are rendered from task-level DE/EN text dictionaries, not hard-coded directly in request processing logic.
  • PDF language follows the normalized request preferred_language, with German fallback.
  • @@ -171,7 +171,16 @@ docker compose exec -T web django-admin compilemessages
  • Do not point remote backup at the same Nextcloud directory used for normal onboarding/offboarding document uploads.
-

10) Builder Architecture

+

10) Branding

+
    +
  • Portal-level branding is stored in the singleton model PortalBranding.
  • +
  • Configured from Admin Apps → Branding.
  • +
  • Current scope: portal title, company name, support email, default language, logo, PDF letterhead, and primary/secondary colors.
  • +
  • Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.
  • +
  • User invitation emails and welcome-template fallbacks also use the configured branding defaults.
  • +
+ +

11) Builder Architecture

Form Builder

  • Model: FormFieldConfig + FormOption
  • diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 568ed9a..8f194c0 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -1,7 +1,7 @@ {% extends 'workflows/base_shell.html' %} {% load static i18n %} -{% block title %}{% trans "TUBCO Onboarding & Offboarding Portal" %}{% endblock %} +{% block title %}{{ portal_title }}{% endblock %} @@ -12,7 +12,7 @@ {% block shell_body %}
    - +
    @@ -32,7 +32,7 @@
    {% trans "Operations Console" %} -

    {% trans "TUBCO Onboarding & Offboarding Portal" %}

    +

    {{ portal_title }}

    {% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}

    {% trans "Rolle:" %} {{ role_label }} @@ -51,7 +51,7 @@
    {% include 'workflows/includes/messages.html' %} -
    +

    {% trans "Apps" %}

    {% trans "Wählen Sie den gewünschten Prozess." %}

    @@ -107,8 +107,24 @@ {% endif %}
    + {% if can_manage_product_branding %} + +
    +

    {% trans "Platform Apps" %}

    +

    {% trans "Produktweite Konfiguration und Produktsteuerung." %}

    +
    +
    +
    +

    {% trans "Branding" %}

    +

    {% trans "Logo, Portalname, Farben und PDF-Briefkopf verwalten." %}

    +{% trans "Öffnen" %} +
    +
    + {% endif %} + {% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %} -
    + +

    {% trans "Admin Apps" %}

    {% trans "Konfiguration, Tests und Steuerung." %}

    diff --git a/backend/workflows/templates/workflows/includes/app_header.html b/backend/workflows/templates/workflows/includes/app_header.html index 7c70bf4..9bd189e 100644 --- a/backend/workflows/templates/workflows/includes/app_header.html +++ b/backend/workflows/templates/workflows/includes/app_header.html @@ -1,8 +1,8 @@ -{% load static i18n %} +{% load i18n %} {% get_current_language as CURRENT_LANGUAGE %}
    - +
    {% if header_show_lang %} diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html index 31e8982..3810443 100644 --- a/backend/workflows/templates/workflows/project_wiki.html +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -120,7 +120,7 @@
    • Template source: /backend/media/templates/onboarding_template.html and offboarding_template.html.
    • Additional onboarding intro template: /backend/media/templates/onboarding_intro_template.html.
    • -
    • Letterhead: /backend/media/templates/templates.pdf.
    • +
    • Letterhead defaults to /backend/media/templates/templates.pdf, but can be overridden from Admin Apps → Branding.
    • Output folder: /backend/media/pdfs/.
    • Fixed PDF labels and notes are rendered through task-level DE/EN text maps and follow the request language with German fallback.
    • Signature images are embedded for compatibility with xhtml2pdf rendering.
    • @@ -178,6 +178,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, workflow rules, and remote backup target settings.
    • +
    • Branding: portal title, company name, logo, support email, default language, PDF letterhead, and basic brand colors.
    • Benutzer & Rollen: super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.
    • 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.
    • @@ -207,6 +208,7 @@
    • 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.
    • +
    • Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → Branding.

    Deployment Notes

    diff --git a/backend/workflows/templates/workflows/requests_dashboard.html b/backend/workflows/templates/workflows/requests_dashboard.html index ec06e1c..501a469 100644 --- a/backend/workflows/templates/workflows/requests_dashboard.html +++ b/backend/workflows/templates/workflows/requests_dashboard.html @@ -12,7 +12,7 @@ {% block shell_body %}
    - +
    @@ -300,7 +300,7 @@ {% endblock %} diff --git a/backend/workflows/templates/workflows/user_management.html b/backend/workflows/templates/workflows/user_management.html index f74769f..5a68b68 100644 --- a/backend/workflows/templates/workflows/user_management.html +++ b/backend/workflows/templates/workflows/user_management.html @@ -12,7 +12,7 @@

    {% trans "Benutzer & Rollen" %}

    -

    {% trans "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}

    +

    {% trans "Platform Owner und Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}

    @@ -137,7 +137,7 @@
    -

    {% trans "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst deaktivieren oder auf eine niedrigere Rolle setzen." %}

    +

    {% trans "Hinweis: Der letzte aktive Platform Owner oder Super Admin kann sich hier nicht selbst entfernen oder auf eine niedrigere Rolle setzen." %}

    diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 88062a2..2b7afca 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -30,6 +30,8 @@ urlpatterns = [ path('admin-tools/welcome-emails//resume/', views.resume_welcome_email, name='resume_welcome_email'), path('admin-tools/welcome-emails//cancel/', views.cancel_welcome_email, name='cancel_welcome_email'), path('admin-tools/handbook/', views.handbook_page, name='handbook_page'), + path('admin-tools/branding/', views.portal_branding_page, name='portal_branding_page'), + path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'), path('admin-tools/users/', views.user_management_page, name='user_management_page'), path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'), path('admin-tools/users//update/', views.update_user_from_admin, name='update_user_from_admin'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 7e97f97..d966876 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -25,7 +25,8 @@ from django.utils.translation import get_language, override from django.urls import reverse from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle -from .forms import OffboardingRequestForm, OnboardingRequestForm, UserManagementCreateForm +from .branding import get_branding_email_copy, get_default_notification_templates +from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, LOCKED_FIELD_RULES, @@ -34,12 +35,11 @@ from .form_builder import ( ONBOARDING_PAGE_ORDER, ensure_form_field_configs, ) -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .emailing import send_system_email -from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability +from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud from .tasks import ( - DEFAULT_NOTIFICATION_TEMPLATES, _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, build_intro_sections_for_request, @@ -341,6 +341,7 @@ def home(request): def _user_management_rows(): user_model = get_user_model() role_order = { + ROLE_PLATFORM_OWNER: 0, ROLE_SUPER_ADMIN: 0, 'admin': 1, 'it_staff': 2, @@ -372,19 +373,30 @@ def _render_user_management(request, create_form=None, status_code: int = 200): row.action_label = _audit_action_label(row.action) role_key = (row.details or {}).get('role') row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key + include_product_owner = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER return render( request, 'workflows/user_management.html', { - 'create_form': create_form or UserManagementCreateForm(), + 'create_form': create_form or UserManagementCreateForm(include_product_owner=include_product_owner), 'rows': _user_management_rows(), - 'role_choices': [(key, str(ROLE_LABELS[key])) for key in ROLE_GROUP_NAMES], + 'role_choices': [ + (key, str(ROLE_LABELS[key])) + for key in ROLE_GROUP_NAMES + if include_product_owner or key != ROLE_PLATFORM_OWNER + ], + 'include_product_owner': include_product_owner, 'recent_user_events': recent_user_events, }, status=status_code, ) +def _platform_owner_user_count() -> int: + user_model = get_user_model() + return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_PLATFORM_OWNER and user.is_active) + + def _super_admin_user_count() -> int: user_model = get_user_model() return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active) @@ -404,6 +416,20 @@ def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is return False +def _would_remove_last_platform_owner(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool: + if get_user_role_key(user) != ROLE_PLATFORM_OWNER or not user.is_active: + return False + if _platform_owner_user_count() > 1: + return False + if deleting: + return True + if new_role_key is not None and new_role_key != ROLE_PLATFORM_OWNER: + return True + if new_is_active is not None and not new_is_active: + return True + return False + + def _send_user_access_email(request, target_user, *, invitation: bool) -> None: email = (target_user.email or '').strip() if not email: @@ -413,17 +439,19 @@ def _send_user_access_email(request, target_user, *, invitation: bool) -> None: token = default_token_generator.make_token(target_user) reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) reset_url = request.build_absolute_uri(reset_path) + branding_copy = get_branding_email_copy() if invitation: subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username} body = _( 'Hallo %(name)s,\n\n' - 'für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal angelegt.\n' + 'für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n' 'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n' '%(url)s\n\n' 'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.' ) % { 'name': _display_user_name(target_user), + 'portal_title': branding_copy['portal_title'], 'url': reset_url, } else: @@ -447,10 +475,67 @@ def user_management_page(request): return _render_user_management(request) +@_require_capability('manage_product_branding') +def portal_branding_page(request): + branding, created = PortalBranding.objects.get_or_create(name='Default') + form = PortalBrandingForm(instance=branding) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': form, + 'branding': branding, + }, + ) + + +@_require_capability('manage_product_branding') +@require_POST +def save_portal_branding(request): + branding, created = PortalBranding.objects.get_or_create(name='Default') + form = PortalBrandingForm(request.POST, request.FILES, instance=branding) + if not form.is_valid(): + messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': form, + 'branding': branding, + }, + status=400, + ) + + branding = form.save() + _audit( + request, + 'portal_branding_saved', + target_type='portal_branding', + target_id=branding.id, + target_label=branding.portal_title, + details={ + 'company_name': branding.company_name, + 'support_email': branding.support_email, + 'default_language': branding.default_language, + 'has_custom_logo': bool(branding.logo_image), + 'has_custom_letterhead': bool(branding.pdf_letterhead), + }, + ) + messages.success(request, _('Portal-Branding wurde gespeichert.')) + return render( + request, + 'workflows/branding_settings.html', + { + 'form': PortalBrandingForm(instance=branding), + 'branding': branding, + }, + ) + + @_require_capability('manage_users') @require_POST def create_user_from_admin(request): - form = UserManagementCreateForm(request.POST) + form = UserManagementCreateForm(request.POST, include_product_owner=(get_user_role_key(request.user) == ROLE_PLATFORM_OWNER)) if not form.is_valid(): messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.')) return _render_user_management(request, create_form=form, status_code=400) @@ -481,10 +566,20 @@ def update_user_from_admin(request, user_id: int): if role_key not in ROLE_GROUP_NAMES: messages.error(request, _('Ungültige Rolle.')) return redirect('user_management_page') + if role_key == ROLE_PLATFORM_OWNER and get_user_role_key(request.user) != ROLE_PLATFORM_OWNER: + messages.error(request, _('Nur Platform Owner dürfen diese Rolle vergeben.')) + return redirect('user_management_page') - if target_user == request.user and (role_key != ROLE_SUPER_ADMIN or not is_active): + current_role = get_user_role_key(request.user) + if target_user == request.user and current_role == ROLE_PLATFORM_OWNER and (role_key != ROLE_PLATFORM_OWNER or not is_active): + messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren oder herabstufen.')) + return redirect('user_management_page') + if target_user == request.user and current_role == ROLE_SUPER_ADMIN and (role_key != ROLE_SUPER_ADMIN or not is_active): messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.')) return redirect('user_management_page') + if _would_remove_last_platform_owner(target_user, new_role_key=role_key, new_is_active=is_active): + messages.error(request, _('Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft werden.')) + return redirect('user_management_page') if _would_remove_last_super_admin(target_user, new_role_key=role_key, new_is_active=is_active): messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.')) return redirect('user_management_page') @@ -535,9 +630,16 @@ def delete_user_from_admin(request, user_id: int): user_model = get_user_model() target_user = get_object_or_404(user_model, id=user_id) + current_role = get_user_role_key(request.user) + if target_user == request.user and current_role == ROLE_PLATFORM_OWNER: + messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen.')) + return redirect('user_management_page') if target_user == request.user: messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.')) return redirect('user_management_page') + if _would_remove_last_platform_owner(target_user, deleting=True): + messages.error(request, _('Der letzte aktive Platform Owner kann nicht gelöscht werden.')) + return redirect('user_management_page') if _would_remove_last_super_admin(target_user, deleting=True): messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.')) return redirect('user_management_page') @@ -1584,11 +1686,11 @@ def welcome_emails_page(request): rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200] config, _ = WorkflowConfig.objects.get_or_create(name='Default') welcome_template = NotificationTemplate.objects.filter(key='onboarding_welcome').first() - default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) - default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() - default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip() - default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip() - default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip() + default_welcome = get_default_notification_templates().get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or '').strip() + default_body = (default_welcome.get('body') or '').strip() + default_subject_en = (default_welcome.get('subject_en') or '').strip() + default_body_en = (default_welcome.get('body_en') or '').strip() subject_value = (welcome_template.subject_template if welcome_template else '').strip() or default_subject body_value = (welcome_template.body_template if welcome_template else '').strip() or default_body subject_value_en = (welcome_template.subject_template_en if welcome_template else '').strip() or default_subject_en @@ -1650,11 +1752,11 @@ def save_welcome_email_settings(request): subject_en = request.POST.get('welcome_subject_en') body_en = request.POST.get('welcome_body_en') if subject is not None or body is not None or subject_en is not None or body_en is not None: - default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {}) - default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip() - default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip() - default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip() - default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip() + default_welcome = get_default_notification_templates().get('onboarding_welcome', {}) + default_subject = (default_welcome.get('subject') or '').strip() + default_body = (default_welcome.get('body') or '').strip() + default_subject_en = (default_welcome.get('subject_en') or '').strip() + default_body_en = (default_welcome.get('body_en') or '').strip() subject_clean = (subject or '').strip() or default_subject body_clean = (body or '').strip() or default_body subject_clean_en = (subject_en or '').strip() or default_subject_en From c195efe3399a2ea00c510ce903acec4f5fb49fb5 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 11:59:06 +0100 Subject: [PATCH 03/45] snapshot: preserve app registry and branding domain foundation --- backend/locale/en/LC_MESSAGES/django.mo | Bin 31810 -> 31745 bytes backend/locale/en/LC_MESSAGES/django.po | 917 ++++++++++-------- backend/workflows/admin.py | 11 +- backend/workflows/app_registry.py | 255 +++++ backend/workflows/branding.py | 17 +- backend/workflows/forms.py | 22 +- .../migrations/0038_portalappconfig.py | 35 + .../0039_portalbranding_company_domain.py | 18 + backend/workflows/models.py | 49 + backend/workflows/roles.py | 2 + .../static/workflows/css/admin_tools.css | 6 + .../static/workflows/js/offboarding_form.js | 4 +- .../static/workflows/js/onboarding_form.js | 3 +- backend/workflows/tasks.py | 8 +- .../templates/workflows/app_registry.html | 83 ++ .../workflows/branding_settings.html | 5 + .../workflows/developer_handbook.html | 14 +- .../workflows/templates/workflows/home.html | 165 +--- .../templates/workflows/offboarding_form.html | 5 +- .../templates/workflows/onboarding_form.html | 3 +- .../templates/workflows/project_wiki.html | 1 + backend/workflows/urls.py | 2 + backend/workflows/views.py | 58 +- 23 files changed, 1122 insertions(+), 561 deletions(-) create mode 100644 backend/workflows/app_registry.py create mode 100644 backend/workflows/migrations/0038_portalappconfig.py create mode 100644 backend/workflows/migrations/0039_portalbranding_company_domain.py create mode 100644 backend/workflows/templates/workflows/app_registry.html diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index 46923b302d4e4c50f173911bca2a1ce4cd9592bc..1f6f36c3922d82ff390a587d53c5aa2892e8bb6d 100644 GIT binary patch delta 6602 zcmXZf30&4i9>?)fQ4YBj0RfQ*5{}%Sy9tSuad8uT8_(dgV*4U2Huzt2K*EYd>FSe5vpG&GUa|e)F4|Kf15J!NU3m z-i77?s|>$ZHZZ0Y?g}*~v7s?zVM{!K z9q|I{`G|XrNy0o-e-ZNAGmH2;kdAjS7`J0n+>K#)7$fmCHp9ypgtt)7H)>-I!wBM7 zjK?(B-v=8L55^EI#3uMKhB3b>rJ(^UP!CpOb6n_Li7kl#jGAB<=HowH9F%HISK^i! zg(EN;%P;{eUH_}7ao<7Bw+Dln-yEb7fVHR+pF(!Qgi|+_Gy(6$e%J|TqbA&ps@w%s zg}z2r=qDEkr`bxxqZW{hu{a2QI03!ZG@hlQl5fO9d=Itf4bts^nWzbRp%NU1{c#a0 z!DFbE)nj*TN~aR(kJ^f2yqh?7CfJllpUx%rrU*a0P0jbEwSYse26O zVj>PkU5qI>51&J=^fvO_glE|b_Q27^%TNhkL4AH3OE9B@?SILmq3f_7wbJ8AbxaF7 zM`AAw#aB=XY(TAiHx9#3Q7ew2zFJu#hGRA=(LSgJj>SlP1_N;gHbrj@jSw1}QHNtI za%IgSR3g`qoJ=$u=fi%e6+MdlHuL#QrQU+sxEB}U52$gUqEizs!X;RRVc0t7ZX%w^ zq@fk%;7II=ZE*!Q#%fezdr)WM6l#Tux%Sp%qT)`dt@C3#dZ=;V!YJJ4Jb+5%c$1-v9Q?l$LM)LA)> zO6(MRDovexpdPi-z;3o=@fb{;h5Fgh6?r$A2T_Nr5;JfEX5kUn|1-uAC-O4T*~vjY zUy5<~B5DgZcBlSoyh}$5tVVtDGxTE}>eQz4a*%nb6%B9}ImY(?xxP5dxw zYbM|{oPkR0JSO4~sD(85II{6Hl2HTnKyATj)YeRO{m-B>UWXcJJF0R=Py?NG)?pIy z4SW%!?z3C87WMsus6%`nwNUR84XxxBYQiRd_6g%rv4_gI3N_(2Y>B&2iJruXc-h5+ zDO&>ZIMj2MsI6IvD*YB*fP0YfJkzs}9cUnGE5^9E6m?kUx&9TXO8pU)`Cm~99C4mU zRq#91g2L{1*AKN7X{i1j*YC#|-T$F9G;tYf!0D(77GgTCcKtP|(x1S4@e1m8#P+q{ zPexs@4Ajayp-Mdnl|Ui(!*MR&j3LZ#KA@rdxZ8bj6!VDBp!O_+Z>e;ts9VqlHSs{C zilz|t+)7jeZ@YLKs`Ljj124MILwJq)iJPIPl@`)a1Q4`HaO}qq4a4qT#+(8|> zpaC|H#R%dQ)P3*l`X^v1@if%)Cr}Ih3RUTE2e|v+aG+gL7;1u8R0&g13Ff0F7>-S_ z1V>^y2I5Zq1MbHOIJUsv_Zn1X4xqkw4s{0pg-Ymp0rh{F#vM9zYR3$+Z?bu)(!Ghn zxCdFZIfRWc;&-+(QK&VHmD=ZozQk9jKKYcKx-OOMDUAVe~MY(0$mLcpNsxGHj2tP|vMH#_`NS8Y=y9 z^x-uZhYsg8Oq`B7ghMbK$6*>yMHht z*)&v=k$AU6m_b~HD)}B%iND0osFSBRVm2y)5qNh?P+L{voPwdmbDRrN&%ca%ezjue zH|uCuq`e|ZRJ+X!IP-x zLW-!rGHzC6Crm*lkb|1A59%5|h=(fl6)eaKH> zGY$LU7A(a7VG#Cv$j&>|qY**JSX4=-qV{aIa}nxnyyoJ!oqJHX<^=Y^>(~`Ljj=16 zf-%I;qYmp@jKdu`2mgs0*DDxn_pTU~=^`A0pP{xOf!Cu}o`qULG3xNVgDUk$*aI)1 zZb`x;_J>kF>hwR2iMRx{^0!d)Y(oyWXMUog!*&O?6%pfXris{^xC?5+B2c~#YEjqkG_rVe5w&$a`3a=YN1!Gig?(`bY5^a*&v#-3^P7)p=KKPoB zcnmJLD`|t;l6<@eXJSiSiAv~QXEkbq{iq6_!TxyNeV#kXChT_(aXy5qT)9U>rF$H; zqNNys8&HSx4Qz~Gpi28C>bY;R2{xQ;<6u-JTA|(pnHYp+=)>u#L%Iw#-Z!X9c{gb2 zlm3}+Pt56l#=;EEIl^;S??mTJ>>QRXXKW6(|I#n==dbDz&ccF`J@nuwU?JrmH7pA{i3GZ|GA{1R`3Sud+(!4z0dU@MI~^`^;~%j z@ENwE$r!HtpG89t^h6C@?3{?9#M4m|RAM+jhf1)@_5TI++z0OS8cZNQhGBTs_19y2 z;=q}FVQ7f#$EIf(Y`+y4j^E(7}_S4F%#c&@j2%;)ZzREl|aywcKk5R)%}m6k;(@Jn1nM>6QQu#SO5{`Qs1J_1j@y;nr^KCV++bx@@1$QE naw~fI@}gg9z`w8gqhI;_(Fj%B7yA-BZr?Y&WBaPWr}F;~q8Si) delta 6667 zcmXZf3sjax9>?*Az zuGwYny6I9LwM^SiripfT%`~(0m{zP>9a=TlcE3MnJnCou^FHs)fBrM`pv$lO9K7!1 zo{jKrbokZi<2XI=A0dttbDQH-byct9eAmly67d`k!I0jL(-*T*&(A@AJCE{Le|!VG z;0a8|E2!sV`|t;IQT?-!->$QnzXRyli-Gtt2IEl-#ZwrDmoOZ!V|xsWGtYOj#$ZSK z6R;Z&vHcU!k9aBuVHx^kHHI?3Q%gewu0TDw79((@^<|7Cejhc#=Qs?{*tko);|wM4 zhn=t#qp%LU<67JQ8fx6VsQH>PkoldHG<@+Ks>BzOU2u9*H_0OY`!fYyOJ!<89P!oKL+1Me;aqh&4 zSb$Grdpw7l_$SoX+(Lg09bmR1(%KJIflSoa+-ayS&QdzY;x^PoE!b8%>K;v; zgE2T2bupIUB76$9(h#rNqMoP<=Hb2gA}YZf_VbY29jAmi1J(bmOGDS;ZPZG?MXKY( z(K!(dFa%#hCGZYvan^A#$Qn@CPhe5yG#lTn#3M1DJu@t02hK1{>oxEg&4H10aoM4NF9ZpTpc4r)up zbuwvaMcFtJ^RX{(Lq9x(N~{@mCN82@I56GZnoLwY618=O*bmpE#@&sb@PPFMDzP82 zo$h}t4VCyhYR~-!n+H5tMx2BZ_yo3<4)y#F)SK=-8*TdFt2wuc4t8JcJr>wT-u0-$8xxLmMAP4R{Kb$ax!I#S-G{Sb_KQlF+yZtjAGj zAW0dE^0;hSZ7)5urvLuP~U$J^+w%= z?5eXHb(XGRIJROG`e#yqm9j^s88{x5$RHevV{N<^6NtB>N_z;kCCS50r82PxaUp8r zYSh-u$7*y@iCxAR^yQ>#A-!E1-Dsqs2FODlwo23%JYxGdpfY|7HPFYX%AH0H^u4te zV~N|1;72m{L2c2SsPCUd9pcNVg}T4d&`N@`%!JW6lQ_}F>rokRM@@JDyWr=jM8C%w zc-_WRDO-2q8q{-ZQCsses`UG?9-EQzTql2&8K?-g6?1G{i#jZ;ZT~h@rCvv6{s}69 z)7HzV3OaY01;t?7^+RpN5Zj+^`wKBz_y2wxnz#-%U_EMrjo1%gwf)CXr9Y1Y@doO4 zB=FwW_ft^UD+9Iik*HD^qY@~?9IUZ%69zHAbC8BIJ!(Jr8V3{qfZDTIzNONoqi(@y z)Wk(d6`eBFb1$P3*kj`ZsM4QAoq?;1>KRmSNRcbKj4lDsuw$y%yAA{}q+cA7iQiG#a6QGN*P9dWlz~ zO7|WHVl%RM=PPW7vExi-`k)SFI%=Xq)WEY)dtQgZxB`{v6R6KOA-mzc;?mF;Td*hk z(G_ti-L>pnUTpszp7w76SA9f%KuVG=G!J@*zej_aJH zp%=!t=)s#d?or6Uf{2Hr4&gNHfHjza^{DT?ic07prr=>4{}(?c_PyI&@K+n2AC6n_Ds(M-o?{PX7*!!S}E|{tKDMb-tmY)7@vf+57&eiVQ<#Iu?844Ag`x zP!)O{wM9>%CU^mL>fgXv+=m`KgF1A#uru~9G4o_#itc|N4IQF-OveUXiJze+%r7;2 zT7+weXQC3kic0(z>Nd3FCl5t%f>B#niTZpgYT}icjnASM@SQ$qe&-Sm?fqrc;k$*s zF?EJXU^43S64dROhg$Ip)CB8o+=x-cFQF#bjlArf{irPrpJ^%FBLtWo90IMj1#HqNy1xN_>RJ)C4a z?#J=O6t|k=i8Cpbed3SphvYyI2oDH z8EfN77^eGQO+zcJM-6xZ_2RjP+RN~{rZNer>o)@H@ov7@gv8d-WFhuu1pGF5PwhvUI23}=dk0Hd*peA??_59yZ3GTQ3Cs5CwwV$_OcjDhM z6r<;v@5NyfaT>ZR<#ZZ4MCH~h)E+NDO}Gv~4LF%vTwZ?w1AC<^q)E0b!T44)%(PzHd`(#Wd9*jDCm8c2ot*cSr z+ki^+Rh)!>M=dyTfw?6e7f^p?6l*&YQKiYib~p=pnK=)jR(K3G(OHbZOW0Nc)UENW zHHR?>wSZ_0$7Bq{QK$t@!~!gKX#~*NfgA8$!yRtQj@X z8PwrQT4-Kaxv1;>FsjrIn2XP&61{->zWXB$?e#V6gkg*9{Y7P(j#_c1H3wCR0@PWU zh8pNKjKU`Dh(}Q0``UT|_3FKhN+9f^wsBl1iiW-zhsBtIMc9bit1GApTTuzNTWn5s zXB#s00R~pYH!1G}7r9f$>;}vG^>i0((&_IfyzN zN3cI$v;DmuHVGx7hyGj}S703RO4J$nE2>g^P~SgZN4 zu3!53G&ZEf-n{Kep9ZgIa8!d2|105-ehuS~MtW-3VoywR_a$D>D6glw^dWC`-GbWk r`PHR!=VsN`m3e2>>}o7bNs27@R(L%R)Xbjiomo?EKWX^n;mrR7Ymy{( diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 8758ccb..8bc10ab 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,13 +2,243 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 10:38+0000\n" +"POT-Creation-Date: 2026-03-26 10:55+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/app_registry.py:32 workflows/models.py:261 workflows/models.py:342 +#: workflows/templates/workflows/onboarding_form.html:25 +#: workflows/templates/workflows/requests_dashboard.html:68 +#: workflows/templates/workflows/requests_dashboard.html:131 +msgid "Onboarding" +msgstr "Onboarding" + +#: workflows/app_registry.py:33 +msgid "" +"Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen " +"senden und in Nextcloud ablegen." +msgstr "" +"Capture new employees, generate a PDF with letterhead, send notifications, " +"and store it in Nextcloud." + +#: workflows/app_registry.py:34 +msgid "Onboarding starten" +msgstr "Start onboarding" + +#: workflows/app_registry.py:36 +msgid "Mehrschritt-Formular" +msgstr "Multi-step form" + +#: workflows/app_registry.py:36 +msgid "E-Mail Routing" +msgstr "Email routing" + +#: workflows/app_registry.py:43 workflows/models.py:262 workflows/models.py:343 +#: workflows/templates/workflows/requests_dashboard.html:78 +#: workflows/templates/workflows/requests_dashboard.html:132 +msgid "Offboarding" +msgstr "Offboarding" + +#: workflows/app_registry.py:44 +msgid "" +"Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und " +"Rückgabe-Prozess starten." +msgstr "" +"Search employees, prefill data, generate offboarding documents, and start " +"the return process." + +#: workflows/app_registry.py:45 +msgid "Offboarding starten" +msgstr "Start offboarding" + +#: workflows/app_registry.py:47 +msgid "Profile-Suche" +msgstr "Profile search" + +#: workflows/app_registry.py:47 +msgid "Hardware-Liste" +msgstr "Hardware list" + +#: workflows/app_registry.py:47 +msgid "IT-Rückgabe" +msgstr "IT return" + +#: workflows/app_registry.py:54 +#: workflows/templates/workflows/requests_dashboard.html:4 +#: workflows/templates/workflows/requests_dashboard.html:33 +msgid "Anfragen Dashboard" +msgstr "Requests Dashboard" + +#: workflows/app_registry.py:55 +msgid "" +"Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-" +"Anfragen." +msgstr "" +"Status, search, PDF links, and history of all onboarding/offboarding " +"requests." + +#: workflows/app_registry.py:56 +msgid "Dashboard öffnen" +msgstr "Open dashboard" + +#: workflows/app_registry.py:59 +msgid "Suche" +msgstr "Search" + +#: workflows/app_registry.py:59 +#: workflows/templates/workflows/backup_recovery.html:40 +#: 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/app_registry.py:59 +msgid "PDF Zugriff" +msgstr "PDF access" + +#: workflows/app_registry.py:65 +#: workflows/templates/workflows/branding_settings.html:4 +#: workflows/templates/workflows/branding_settings.html:12 +msgid "Branding" +msgstr "Branding" + +#: workflows/app_registry.py:66 +msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." +msgstr "Manage logo, portal name, colors, and PDF letterhead." + +#: workflows/app_registry.py:67 workflows/app_registry.py:76 +#: workflows/app_registry.py:85 workflows/app_registry.py:94 +#: workflows/app_registry.py:103 workflows/app_registry.py:112 +#: workflows/app_registry.py:121 workflows/app_registry.py:130 +#: workflows/app_registry.py:139 workflows/app_registry.py:148 +#: workflows/app_registry.py:157 +msgid "Öffnen" +msgstr "Open" + +#: workflows/app_registry.py:74 +#: workflows/templates/workflows/app_registry.html:4 +#: workflows/templates/workflows/app_registry.html:12 +msgid "App Registry" +msgstr "" + +#: workflows/app_registry.py:75 +msgid "Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten." +msgstr "" + +#: workflows/app_registry.py:83 +msgid "Integrationen" +msgstr "Integrations" + +#: workflows/app_registry.py:84 +msgid "Nextcloud- und E-Mail-Setup." +msgstr "Nextcloud and email setup." + +#: workflows/app_registry.py:92 +#: workflows/templates/workflows/user_management.html:4 +#: workflows/templates/workflows/user_management.html:14 +msgid "Benutzer & Rollen" +msgstr "Users & roles" + +#: workflows/app_registry.py:93 +msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." +msgstr "Create users, assign roles, and control access." + +#: workflows/app_registry.py:101 workflows/templates/workflows/audit_log.html:4 +#: workflows/templates/workflows/audit_log.html:15 +msgid "Audit Log" +msgstr "" + +#: workflows/app_registry.py:102 +msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." +msgstr "" + +#: workflows/app_registry.py:110 +#: workflows/templates/workflows/backup_recovery.html:4 +#: workflows/templates/workflows/backup_recovery.html:12 +msgid "Backup & Recovery" +msgstr "Backup & Recovery" + +#: workflows/app_registry.py:111 +msgid "Backups erstellen und sicher verifizieren." +msgstr "" + +#: workflows/app_registry.py:119 +#: workflows/templates/workflows/welcome_emails.html:4 +msgid "Welcome E-Mails" +msgstr "Welcome Emails" + +#: workflows/app_registry.py:120 +msgid "Geplante Welcome Mails verwalten." +msgstr "Manage scheduled welcome emails." + +#: workflows/app_registry.py:128 +#: workflows/templates/workflows/form_builder.html:4 +#: workflows/templates/workflows/form_builder.html:14 +msgid "Form Builder" +msgstr "Form Builder" + +#: workflows/app_registry.py:129 +msgid "Felder, Schritte und Optionen verwalten." +msgstr "Manage fields, steps, and options." + +#: workflows/app_registry.py:137 +#: workflows/templates/workflows/intro_builder.html:4 +#: workflows/templates/workflows/intro_builder.html:17 +msgid "Einweisungs-Builder" +msgstr "Introduction Builder" + +#: workflows/app_registry.py:138 +msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." +msgstr "Configure checklist items for the introduction protocol." + +#: workflows/app_registry.py:146 workflows/templates/workflows/handbook.html:4 +#: workflows/templates/workflows/handbook.html:15 +msgid "Handbook" +msgstr "Handbook" + +#: workflows/app_registry.py:147 +msgid "Project wiki and developer documentation in one place." +msgstr "Project wiki and developer documentation in one place." + +#: workflows/app_registry.py:155 +msgid "Django Admin" +msgstr "Django Admin" + +#: workflows/app_registry.py:156 +msgid "Vollständige Datenverwaltung." +msgstr "Full data management." + +#: workflows/app_registry.py:165 workflows/models.py:68 +msgid "Apps" +msgstr "Apps" + +#: workflows/app_registry.py:166 +msgid "Wählen Sie den gewünschten Prozess." +msgstr "Choose the desired process." + +#: workflows/app_registry.py:171 workflows/models.py:69 +msgid "Platform Apps" +msgstr "" + +#: workflows/app_registry.py:172 +#, fuzzy +#| msgid "Konfiguration, Tests und Steuerung." +msgid "Produktweite Konfiguration und Produktsteuerung." +msgstr "Configuration, tests, and controls." + +#: workflows/app_registry.py:177 workflows/models.py:70 +msgid "Admin Apps" +msgstr "Admin Apps" + +#: workflows/app_registry.py:178 +msgid "Konfiguration, Tests und Steuerung." +msgstr "Configuration, tests, and controls." + #: workflows/backup_ops.py:141 msgid "Remote Backup ist deaktiviert." msgstr "" @@ -55,40 +285,40 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:103 workflows/forms.py:128 +#: workflows/forms.py:104 workflows/forms.py:129 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:104 +#: workflows/forms.py:105 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:108 workflows/forms.py:129 +#: workflows/forms.py:109 workflows/forms.py:130 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:113 workflows/templates/workflows/user_management.html:77 +#: workflows/forms.py:114 workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:119 +#: workflows/forms.py:120 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:126 +#: workflows/forms.py:127 msgid "Vorname" msgstr "" -#: workflows/forms.py:127 +#: workflows/forms.py:128 msgid "Nachname" msgstr "" -#: workflows/forms.py:130 workflows/templates/workflows/user_management.html:74 +#: workflows/forms.py:131 workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 #, fuzzy @@ -96,318 +326,319 @@ msgstr "" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:144 +#: workflows/forms.py:145 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:153 workflows/views.py:567 +#: workflows/forms.py:154 workflows/views.py:616 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:155 workflows/views.py:570 +#: workflows/forms.py:156 workflows/views.py:619 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:186 +#: workflows/forms.py:188 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:187 +#: workflows/forms.py:189 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:188 +#: workflows/forms.py:190 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmen-Domain" +msgstr "Company name" + +#: workflows/forms.py:191 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:189 +#: workflows/forms.py:192 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:190 +#: workflows/forms.py:193 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:191 +#: workflows/forms.py:194 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:192 +#: workflows/forms.py:195 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:193 +#: workflows/forms.py:196 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:207 +#: workflows/forms.py:210 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:215 +#: workflows/forms.py:218 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:458 +#: workflows/forms.py:357 workflows/forms.py:542 +#, python-format +msgid "Bitte nutzen Sie das Format name@%(domain)s." +msgstr "" + +#: workflows/forms.py:379 workflows/forms.py:556 +#, python-format +msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." +msgstr "" + +#: workflows/forms.py:464 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:90 workflows/views.py:199 +#: workflows/models.py:139 workflows/views.py:200 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:91 workflows/views.py:200 +#: workflows/models.py:140 workflows/views.py:201 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:92 workflows/models.py:407 workflows/views.py:201 +#: workflows/models.py:141 workflows/models.py:456 workflows/views.py:202 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:93 workflows/models.py:347 +#: workflows/models.py:142 workflows/models.py:396 #: workflows/templates/workflows/backup_recovery.html:70 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:202 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:203 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:100 +#: workflows/models.py:149 msgid "Herr" msgstr "" -#: workflows/models.py:100 +#: workflows/models.py:149 msgid "Frau" msgstr "" -#: workflows/models.py:100 +#: workflows/models.py:149 msgid "Divers" msgstr "" -#: workflows/models.py:110 +#: workflows/models.py:159 msgid "befristet" msgstr "" -#: workflows/models.py:110 +#: workflows/models.py:159 msgid "unbefristet" msgstr "" -#: workflows/models.py:173 +#: workflows/models.py:222 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/models.py:174 +#: workflows/models.py:223 msgid "Geräte" msgstr "" -#: workflows/models.py:175 +#: workflows/models.py:224 msgid "Software" msgstr "" -#: workflows/models.py:176 +#: workflows/models.py:225 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:177 +#: workflows/models.py:226 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:178 +#: workflows/models.py:227 msgid "Ressourcen" msgstr "" -#: workflows/models.py:179 +#: workflows/models.py:228 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:205 +#: workflows/models.py:254 msgid "Automatisch" msgstr "" -#: workflows/models.py:206 workflows/views.py:94 +#: workflows/models.py:255 workflows/views.py:95 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:207 workflows/views.py:95 +#: workflows/models.py:256 workflows/views.py:96 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:208 workflows/views.py:96 +#: workflows/models.py:257 workflows/views.py:97 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:209 workflows/views.py:97 +#: workflows/models.py:258 workflows/views.py:98 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:212 workflows/models.py:293 -#: 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:131 -msgid "Onboarding" -msgstr "Onboarding" - -#: workflows/models.py:213 workflows/models.py:294 -#: workflows/templates/workflows/home.html:78 -#: workflows/templates/workflows/requests_dashboard.html:78 -#: workflows/templates/workflows/requests_dashboard.html:132 -msgid "Offboarding" -msgstr "Offboarding" - -#: workflows/models.py:251 +#: workflows/models.py:300 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:252 +#: workflows/models.py:301 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:253 +#: workflows/models.py:302 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:254 +#: workflows/models.py:303 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:255 +#: workflows/models.py:304 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:256 +#: workflows/models.py:305 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:257 +#: workflows/models.py:306 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:258 +#: workflows/models.py:307 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:259 +#: workflows/models.py:308 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:260 +#: workflows/models.py:309 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:261 +#: workflows/models.py:310 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:297 +#: workflows/models.py:346 msgid "Immer" msgstr "" -#: workflows/models.py:298 workflows/models.py:376 +#: workflows/models.py:347 workflows/models.py:425 msgid "Enthält" msgstr "" -#: workflows/models.py:299 workflows/models.py:377 +#: workflows/models.py:348 workflows/models.py:426 msgid "Ist gleich" msgstr "" -#: workflows/models.py:300 +#: workflows/models.py:349 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:301 +#: workflows/models.py:350 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:343 +#: workflows/models.py:392 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:344 +#: workflows/models.py:393 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:345 +#: workflows/models.py:394 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:346 +#: workflows/models.py:395 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:369 workflows/tasks.py:576 +#: workflows/models.py:418 workflows/tasks.py:576 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:370 workflows/tasks.py:577 +#: workflows/models.py:419 workflows/tasks.py:577 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:371 workflows/tasks.py:578 +#: workflows/models.py:420 workflows/tasks.py:578 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:372 workflows/tasks.py:579 +#: workflows/models.py:421 workflows/tasks.py:579 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:375 +#: workflows/models.py:424 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:378 +#: workflows/models.py:427 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:379 +#: workflows/models.py:428 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:406 +#: workflows/models.py:455 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:426 +#: workflows/models.py:475 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:427 +#: workflows/models.py:476 msgid "S3" msgstr "" -#: workflows/models.py:428 +#: workflows/models.py:477 msgid "NFS" msgstr "" @@ -646,12 +877,80 @@ msgstr "" msgid "Link anfordern" msgstr "Request link" -#: workflows/templates/workflows/audit_log.html:4 -#: workflows/templates/workflows/audit_log.html:15 -#: workflows/templates/workflows/home.html:148 -msgid "Audit Log" +#: workflows/templates/workflows/app_registry.html:13 +msgid "" +"Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf " +"der Landing Page ausspielen." msgstr "" +#: workflows/templates/workflows/app_registry.html:19 +msgid "" +"Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier " +"steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:20 +#, fuzzy +#| msgid "Produktion" +msgid "Produktkern" +msgstr "Production" + +#: workflows/templates/workflows/app_registry.html:28 +msgid "Key" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:29 +#: workflows/templates/workflows/form_builder.html:91 +#: workflows/templates/workflows/integrations_setup.html:263 +#: workflows/templates/workflows/intro_builder.html:65 +#: workflows/templates/workflows/user_management.html:75 +msgid "Aktiv" +msgstr "Active" + +#: workflows/templates/workflows/app_registry.html:30 +#, fuzzy +#| msgid "Eingereicht" +msgid "Bereich" +msgstr "Submitted" + +#: workflows/templates/workflows/app_registry.html:31 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Reihenfolge" +msgstr "Save order" + +#: workflows/templates/workflows/app_registry.html:32 +msgid "Titel DE" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:33 +msgid "Titel EN" +msgstr "" + +#: workflows/templates/workflows/app_registry.html:34 +#, fuzzy +#| msgid "Aktion" +msgid "Aktion DE" +msgstr "Action" + +#: workflows/templates/workflows/app_registry.html:35 +#, fuzzy +#| msgid "Aktion" +msgid "Aktion EN" +msgstr "Action" + +#: workflows/templates/workflows/app_registry.html:78 +msgid "" +"Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter " +"Apps oder Admin Apps einordnen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:79 +#, fuzzy +#| msgid "Regeln speichern" +msgid "App Registry speichern" +msgstr "Save rules" + #: workflows/templates/workflows/audit_log.html:16 msgid "Nachvollziehbarkeit aller wichtigen Admin-Aktionen im Portal." msgstr "" @@ -745,12 +1044,6 @@ 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:155 -msgid "Backup & Recovery" -msgstr "Backup & Recovery" - #: workflows/templates/workflows/backup_recovery.html:13 msgid "" "Datenbank- und Media-Backups erstellen und vorhandene Bundles sicher " @@ -803,15 +1096,6 @@ msgstr "Created" msgid "Verifiziert" msgstr "Verified" -#: workflows/templates/workflows/backup_recovery.html:40 -#: workflows/templates/workflows/home.html:99 -#: 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" @@ -914,39 +1198,39 @@ msgstr "Action in progress" msgid "Die Aktion wird im aktuellen Tab ausgeführt." msgstr "The action is running in the current tab." -#: workflows/templates/workflows/branding_settings.html:4 -#: workflows/templates/workflows/branding_settings.html:12 -#: workflows/templates/workflows/home.html:118 -msgid "Branding" -msgstr "Branding" - #: workflows/templates/workflows/branding_settings.html:13 msgid "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." msgstr "" "Manage portal name, company branding, logo, and PDF letterhead centrally." -#: workflows/templates/workflows/branding_settings.html:48 +#: workflows/templates/workflows/branding_settings.html:32 +msgid "" +"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " +"B. tub.co." +msgstr "" + +#: workflows/templates/workflows/branding_settings.html:53 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/templates/workflows/branding_settings.html:51 +#: workflows/templates/workflows/branding_settings.html:56 msgid "Aktuelles Logo:" msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:51 -#: workflows/templates/workflows/branding_settings.html:60 +#: workflows/templates/workflows/branding_settings.html:56 +#: workflows/templates/workflows/branding_settings.html:65 msgid "öffnen" msgstr "open" -#: workflows/templates/workflows/branding_settings.html:57 +#: workflows/templates/workflows/branding_settings.html:62 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/templates/workflows/branding_settings.html:60 +#: workflows/templates/workflows/branding_settings.html:65 msgid "Aktueller Briefkopf:" msgstr "Current letterhead:" -#: workflows/templates/workflows/branding_settings.html:65 +#: workflows/templates/workflows/branding_settings.html:70 msgid "" "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " "hochgeladen werden." @@ -954,16 +1238,10 @@ msgstr "" "TUBCO remains the default until values are changed or files are uploaded " "here." -#: workflows/templates/workflows/branding_settings.html:66 +#: workflows/templates/workflows/branding_settings.html:71 msgid "Branding speichern" msgstr "Save branding" -#: workflows/templates/workflows/form_builder.html:4 -#: workflows/templates/workflows/form_builder.html:14 -#: workflows/templates/workflows/home.html:169 -msgid "Form Builder" -msgstr "Form Builder" - #: workflows/templates/workflows/form_builder.html:15 msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." msgstr "Sort fields by drag and drop and group them by step." @@ -1020,13 +1298,6 @@ msgstr "Sort order" msgid "Label (EN)" msgstr "Label (EN)" -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/integrations_setup.html:263 -#: workflows/templates/workflows/intro_builder.html:65 -#: workflows/templates/workflows/user_management.html:75 -msgid "Aktiv" -msgstr "Active" - #: workflows/templates/workflows/form_builder.html:100 msgid "Ziehen zum Sortieren" msgstr "Drag to reorder" @@ -1083,12 +1354,6 @@ msgstr "No field configurations available." msgid "Feldtexte speichern" msgstr "Save field text" -#: workflows/templates/workflows/handbook.html:4 -#: workflows/templates/workflows/handbook.html:15 -#: workflows/templates/workflows/home.html:181 -msgid "Handbook" -msgstr "Handbook" - #: workflows/templates/workflows/handbook.html:17 msgid "" "Single documentation entry point for both operational knowledge and long-" @@ -1263,181 +1528,7 @@ msgstr "Production" msgid "PDF + E-Mail Workflow bereit" msgstr "PDF + Email Workflow Ready" -#: workflows/templates/workflows/home.html:55 -msgid "Apps" -msgstr "Apps" - -#: workflows/templates/workflows/home.html:56 -msgid "Wählen Sie den gewünschten Prozess." -msgstr "Choose the desired process." - -#: workflows/templates/workflows/home.html:63 -msgid "" -"Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen " -"senden und in Nextcloud ablegen." -msgstr "" -"Capture new employees, generate a PDF with letterhead, send notifications, " -"and store it in Nextcloud." - -#: workflows/templates/workflows/home.html:65 -msgid "Mehrschritt-Formular" -msgstr "Multi-step form" - -#: workflows/templates/workflows/home.html:67 -msgid "E-Mail Routing" -msgstr "Email routing" - -#: workflows/templates/workflows/home.html:71 -msgid "Onboarding starten" -msgstr "Start onboarding" - -#: workflows/templates/workflows/home.html:79 -msgid "" -"Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und " -"Rückgabe-Prozess starten." -msgstr "" -"Search employees, prefill data, generate offboarding documents, and start " -"the return process." - -#: workflows/templates/workflows/home.html:81 -msgid "Profile-Suche" -msgstr "Profile search" - -#: workflows/templates/workflows/home.html:82 -msgid "Hardware-Liste" -msgstr "Hardware list" - -#: workflows/templates/workflows/home.html:83 -msgid "IT-Rückgabe" -msgstr "IT return" - -#: workflows/templates/workflows/home.html:87 -msgid "Offboarding starten" -msgstr "Start offboarding" - -#: workflows/templates/workflows/home.html:95 -#: 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:96 -msgid "" -"Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-" -"Anfragen." -msgstr "" -"Status, search, PDF links, and history of all onboarding/offboarding " -"requests." - -#: workflows/templates/workflows/home.html:98 -msgid "Suche" -msgstr "Search" - -#: workflows/templates/workflows/home.html:100 -msgid "PDF Zugriff" -msgstr "PDF access" - -#: workflows/templates/workflows/home.html:104 -msgid "Dashboard öffnen" -msgstr "Open dashboard" - -#: workflows/templates/workflows/home.html:113 -msgid "Platform Apps" -msgstr "" - -#: workflows/templates/workflows/home.html:114 -#, fuzzy -#| msgid "Konfiguration, Tests und Steuerung." -msgid "Produktweite Konfiguration und Produktsteuerung." -msgstr "Configuration, tests, and controls." - -#: workflows/templates/workflows/home.html:119 -msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." -msgstr "Manage logo, portal name, colors, and PDF letterhead." - -#: workflows/templates/workflows/home.html:120 -#: workflows/templates/workflows/home.html:136 -#: workflows/templates/workflows/home.html:143 -#: workflows/templates/workflows/home.html:150 -#: workflows/templates/workflows/home.html:157 -#: workflows/templates/workflows/home.html:164 -#: workflows/templates/workflows/home.html:171 -#: workflows/templates/workflows/home.html:176 -#: workflows/templates/workflows/home.html:183 -#: workflows/templates/workflows/home.html:190 -msgid "Öffnen" -msgstr "Open" - -#: workflows/templates/workflows/home.html:128 -msgid "Admin Apps" -msgstr "Admin Apps" - -#: workflows/templates/workflows/home.html:129 -msgid "Konfiguration, Tests und Steuerung." -msgstr "Configuration, tests, and controls." - -#: workflows/templates/workflows/home.html:134 -msgid "Integrationen" -msgstr "Integrations" - -#: workflows/templates/workflows/home.html:135 -msgid "Nextcloud- und E-Mail-Setup." -msgstr "Nextcloud and email setup." - -#: workflows/templates/workflows/home.html:141 -#: workflows/templates/workflows/user_management.html:4 -#: workflows/templates/workflows/user_management.html:14 -msgid "Benutzer & Rollen" -msgstr "Users & roles" - -#: workflows/templates/workflows/home.html:142 -msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." -msgstr "Create users, assign roles, and control access." - -#: workflows/templates/workflows/home.html:149 -msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." -msgstr "" - -#: workflows/templates/workflows/home.html:156 -msgid "Backups erstellen und sicher verifizieren." -msgstr "" - -#: workflows/templates/workflows/home.html:162 -#: workflows/templates/workflows/welcome_emails.html:4 -msgid "Welcome E-Mails" -msgstr "Welcome Emails" - -#: workflows/templates/workflows/home.html:163 -msgid "Geplante Welcome Mails verwalten." -msgstr "Manage scheduled welcome emails." - -#: workflows/templates/workflows/home.html:170 -msgid "Felder, Schritte und Optionen verwalten." -msgstr "Manage fields, steps, and options." - -#: workflows/templates/workflows/home.html:174 -#: 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:175 -msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." -msgstr "Configure checklist items for the introduction protocol." - -#: workflows/templates/workflows/home.html:182 -msgid "Project wiki and developer documentation in one place." -msgstr "Project wiki and developer documentation in one place." - -#: workflows/templates/workflows/home.html:188 -msgid "Django Admin" -msgstr "Django Admin" - -#: workflows/templates/workflows/home.html:189 -msgid "Vollständige Datenverwaltung." -msgstr "Full data management." - -#: workflows/templates/workflows/home.html:197 +#: workflows/templates/workflows/home.html:94 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." @@ -1842,7 +1933,9 @@ msgid "Mitarbeitende suchen (Name oder E-Mail)" msgstr "Search employees (name or email)" #: workflows/templates/workflows/offboarding_form.html:31 -msgid "z. B. max.mustermann@tub.co" +#, fuzzy, python-format +#| msgid "z. B. max.mustermann@tub.co" +msgid "z. B. max.mustermann@%(domain)s" msgstr "e.g. john.doe@tub.co" #: workflows/templates/workflows/offboarding_form.html:33 @@ -2002,7 +2095,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:820 +#: workflows/views.py:869 msgid "Vertragsbeginn" msgstr "Contract start" @@ -2651,244 +2744,256 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:94 +#: workflows/views.py:95 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:95 +#: workflows/views.py:96 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:96 +#: workflows/views.py:97 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:97 +#: workflows/views.py:98 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:128 workflows/views.py:906 workflows/views.py:911 +#: workflows/views.py:129 workflows/views.py:955 workflows/views.py:960 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:209 +#: workflows/views.py:210 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:210 +#: workflows/views.py:211 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:211 +#: workflows/views.py:212 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:212 +#: workflows/views.py:213 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:213 +#: workflows/views.py:214 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:214 +#: workflows/views.py:215 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:215 +#: workflows/views.py:216 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:216 +#: workflows/views.py:217 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:217 +#: workflows/views.py:218 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:218 +#: workflows/views.py:219 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:219 +#: workflows/views.py:220 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:220 +#: workflows/views.py:221 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:221 +#: workflows/views.py:222 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:222 +#: workflows/views.py:223 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:223 +#: workflows/views.py:224 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:224 +#: workflows/views.py:225 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:225 +#: workflows/views.py:226 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:226 +#: workflows/views.py:227 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:227 +#: workflows/views.py:228 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:228 +#: workflows/views.py:229 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:229 +#: workflows/views.py:230 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:230 +#: workflows/views.py:231 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:231 +#: workflows/views.py:232 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:232 +#: workflows/views.py:233 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:233 +#: workflows/views.py:234 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:234 +#: workflows/views.py:235 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:235 +#: workflows/views.py:236 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:236 +#: workflows/views.py:237 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:237 +#: workflows/views.py:238 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:238 +#: workflows/views.py:239 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:239 +#: workflows/views.py:240 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:240 +#: workflows/views.py:241 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:241 +#: workflows/views.py:242 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:242 +#: workflows/views.py:243 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:243 +#: workflows/views.py:244 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:244 +#: workflows/views.py:245 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:245 +#: workflows/views.py:246 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:246 +#: workflows/views.py:247 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:436 +#: workflows/views.py:248 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "App-Registry gespeichert" +msgstr "Request saved" + +#: workflows/views.py:386 +#, fuzzy +#| msgid "Anfrage gespeichert" +msgid "App-Registry gespeichert." +msgstr "Request saved" + +#: workflows/views.py:485 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:445 +#: workflows/views.py:494 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:447 +#: workflows/views.py:496 #, python-format msgid "" "Hallo %(name)s,\n" @@ -2901,12 +3006,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:458 +#: workflows/views.py:507 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:460 +#: workflows/views.py:509 #, python-format msgid "" "Hallo %(name)s,\n" @@ -2919,7 +3024,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:498 +#: workflows/views.py:547 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -2927,23 +3032,23 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:524 +#: workflows/views.py:573 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:540 +#: workflows/views.py:589 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:553 +#: workflows/views.py:602 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:575 +#: workflows/views.py:624 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2954,14 +3059,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:578 +#: workflows/views.py:627 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:581 +#: workflows/views.py:630 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2972,7 +3077,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:584 +#: workflows/views.py:633 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -2983,18 +3088,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:601 +#: workflows/views.py:650 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:623 +#: workflows/views.py:672 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:635 +#: workflows/views.py:684 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3004,7 +3109,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:638 +#: workflows/views.py:687 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3014,7 +3119,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:641 +#: workflows/views.py:690 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3023,7 +3128,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:644 +#: workflows/views.py:693 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3032,121 +3137,121 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:657 +#: workflows/views.py:706 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:744 +#: workflows/views.py:793 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:746 +#: workflows/views.py:795 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:762 +#: workflows/views.py:811 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:764 +#: workflows/views.py:813 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:780 +#: workflows/views.py:829 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:782 +#: workflows/views.py:831 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:808 +#: workflows/views.py:857 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:810 +#: workflows/views.py:859 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:822 +#: workflows/views.py:871 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:832 +#: workflows/views.py:881 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:834 +#: workflows/views.py:883 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:843 +#: workflows/views.py:892 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:869 +#: workflows/views.py:918 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:881 +#: workflows/views.py:930 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:920 +#: workflows/views.py:969 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:963 +#: workflows/views.py:1012 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:965 +#: workflows/views.py:1014 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:967 +#: workflows/views.py:1016 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1194 +#: workflows/views.py:1244 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1211 +#: workflows/views.py:1261 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1240 +#: workflows/views.py:1290 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1254 +#: workflows/views.py:1304 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1267 +#: workflows/views.py:1317 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index d142cb2..31a851f 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig @admin.register(EmployeeProfile) @@ -25,6 +25,15 @@ class PortalBrandingAdmin(admin.ModelAdmin): list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at') +@admin.register(PortalAppConfig) +class PortalAppConfigAdmin(admin.ModelAdmin): + list_display = ('key', 'section', 'sort_order', 'is_enabled', 'updated_at') + list_filter = ('section', 'is_enabled') + search_fields = ('key', 'title_override', 'title_override_en') + ordering = ('section', 'sort_order', 'key') + list_editable = ('section', 'sort_order', 'is_enabled') + + @admin.register(OnboardingRequest) class OnboardingRequestAdmin(admin.ModelAdmin): list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at') diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py new file mode 100644 index 0000000..73290e9 --- /dev/null +++ b/backend/workflows/app_registry.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from .models import PortalAppConfig +from .roles import user_has_capability + + +@dataclass(frozen=True) +class AppDefinition: + key: str + section: str + route_name: str + title: object + description: object + action_label: object + capability: str | None = None + accent: str = '' + accent_label: str = 'APP' + style_variant: str = '' + tags: tuple[object, ...] = () + + +APP_DEFINITIONS: tuple[AppDefinition, ...] = ( + AppDefinition( + key='onboarding', + section=PortalAppConfig.SECTION_APP, + route_name='onboarding_create', + title=_('Onboarding'), + description=_('Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.'), + action_label=_('Onboarding starten'), + accent='ON', + tags=(_('Mehrschritt-Formular'), 'PDF', _('E-Mail Routing')), + style_variant='primary', + ), + AppDefinition( + key='offboarding', + section=PortalAppConfig.SECTION_APP, + route_name='offboarding_create', + title=_('Offboarding'), + description=_('Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.'), + action_label=_('Offboarding starten'), + accent='OFF', + tags=(_('Profile-Suche'), _('Hardware-Liste'), _('IT-Rückgabe')), + style_variant='red', + ), + AppDefinition( + key='requests_dashboard', + section=PortalAppConfig.SECTION_APP, + route_name='requests_dashboard', + title=_('Anfragen Dashboard'), + description=_('Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.'), + action_label=_('Dashboard öffnen'), + capability='access_requests_dashboard', + accent='APP', + tags=(_('Suche'), _('Status'), _('PDF Zugriff')), + ), + AppDefinition( + key='branding', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_branding_page', + title=_('Branding'), + description=_('Logo, Portalname, Farben und PDF-Briefkopf verwalten.'), + action_label=_('Öffnen'), + capability='manage_product_branding', + ), + AppDefinition( + key='app_registry', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_app_registry_page', + title=_('App Registry'), + description=_('Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten.'), + action_label=_('Öffnen'), + capability='manage_app_registry', + ), + AppDefinition( + key='integrations', + section=PortalAppConfig.SECTION_ADMIN, + route_name='integrations_setup_page', + title=_('Integrationen'), + description=_('Nextcloud- und E-Mail-Setup.'), + action_label=_('Öffnen'), + capability='manage_integrations', + ), + AppDefinition( + key='users', + section=PortalAppConfig.SECTION_ADMIN, + route_name='user_management_page', + title=_('Benutzer & Rollen'), + description=_('Benutzer anlegen, Rollen zuweisen und Zugriffe steuern.'), + action_label=_('Öffnen'), + capability='manage_users', + ), + AppDefinition( + key='audit_log', + section=PortalAppConfig.SECTION_ADMIN, + route_name='audit_log_page', + title=_('Audit Log'), + description=_('Wichtige Admin-Aktionen nachvollziehen und prüfen.'), + action_label=_('Öffnen'), + capability='view_audit_log', + ), + AppDefinition( + key='backups', + section=PortalAppConfig.SECTION_ADMIN, + route_name='backup_recovery_page', + title=_('Backup & Recovery'), + description=_('Backups erstellen und sicher verifizieren.'), + action_label=_('Öffnen'), + capability='manage_backups', + ), + AppDefinition( + key='welcome_emails', + section=PortalAppConfig.SECTION_ADMIN, + route_name='welcome_emails_page', + title=_('Welcome E-Mails'), + description=_('Geplante Welcome Mails verwalten.'), + action_label=_('Öffnen'), + capability='manage_welcome_emails', + ), + AppDefinition( + key='form_builder', + section=PortalAppConfig.SECTION_ADMIN, + route_name='form_builder_page', + title=_('Form Builder'), + description=_('Felder, Schritte und Optionen verwalten.'), + action_label=_('Öffnen'), + capability='manage_builders', + ), + AppDefinition( + key='intro_builder', + section=PortalAppConfig.SECTION_ADMIN, + route_name='intro_builder_page', + title=_('Einweisungs-Builder'), + description=_('Checklistenpunkte für das Einweisungsprotokoll konfigurieren.'), + action_label=_('Öffnen'), + capability='manage_builders', + ), + AppDefinition( + key='handbook', + section=PortalAppConfig.SECTION_ADMIN, + route_name='handbook_page', + title=_('Handbook'), + description=_('Project wiki and developer documentation in one place.'), + action_label=_('Öffnen'), + capability='view_docs', + ), + AppDefinition( + key='django_admin', + section=PortalAppConfig.SECTION_ADMIN, + route_name='admin:index', + title=_('Django Admin'), + description=_('Vollständige Datenverwaltung.'), + action_label=_('Öffnen'), + capability='access_django_admin_link', + ), +) + + +SECTION_META = { + PortalAppConfig.SECTION_APP: { + 'title': _('Apps'), + 'subtitle': _('Wählen Sie den gewünschten Prozess.'), + 'css_class': 'section-head-primary', + 'grid_class': 'apps-grid', + }, + PortalAppConfig.SECTION_PLATFORM: { + 'title': _('Platform Apps'), + 'subtitle': _('Produktweite Konfiguration und Produktsteuerung.'), + 'css_class': 'section-head-platform', + 'grid_class': 'admin-grid', + }, + PortalAppConfig.SECTION_ADMIN: { + 'title': _('Admin Apps'), + 'subtitle': _('Konfiguration, Tests und Steuerung.'), + 'css_class': 'section-head-admin', + 'grid_class': 'admin-grid', + }, +} + + +def ensure_portal_app_configs() -> None: + for index, definition in enumerate(APP_DEFINITIONS): + PortalAppConfig.objects.get_or_create( + key=definition.key, + defaults={ + 'section': definition.section, + 'sort_order': index, + 'is_enabled': True, + }, + ) + + +def get_portal_app_registry_rows() -> list[dict[str, object]]: + ensure_portal_app_configs() + config_map = {config.key: config for config in PortalAppConfig.objects.all()} + rows: list[dict[str, object]] = [] + for index, definition in enumerate(APP_DEFINITIONS): + config = config_map[definition.key] + rows.append( + { + 'definition': definition, + 'config': config, + 'default_section': definition.section, + 'default_sort_order': index, + } + ) + return rows + + +def build_portal_app_sections(user) -> list[dict[str, object]]: + ensure_portal_app_configs() + config_map = {config.key: config for config in PortalAppConfig.objects.all()} + grouped: dict[str, list[dict[str, object]]] = {key: [] for key in SECTION_META} + + for definition in APP_DEFINITIONS: + config = config_map.get(definition.key) + if not config or not config.is_enabled: + continue + if definition.capability and not user_has_capability(user, definition.capability): + continue + grouped[config.section].append( + { + 'key': definition.key, + 'href': reverse(definition.route_name), + 'title': config.translated_title_override() or str(definition.title), + 'description': config.translated_description_override() or str(definition.description), + 'action_label': config.translated_action_label_override() or str(definition.action_label), + 'accent': definition.accent, + 'accent_label': definition.accent_label, + 'style_variant': definition.style_variant, + 'tags': [str(tag) for tag in definition.tags], + 'sort_order': config.sort_order, + } + ) + + sections: list[dict[str, object]] = [] + for section_key, meta in SECTION_META.items(): + apps = sorted(grouped.get(section_key, []), key=lambda item: (item['sort_order'], item['title'])) + if not apps: + continue + sections.append( + { + 'key': section_key, + 'title': str(meta['title']), + 'subtitle': str(meta['subtitle']), + 'css_class': meta['css_class'], + 'grid_class': meta['grid_class'], + 'apps': apps, + } + ) + return sections diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py index b25a984..46d4ae6 100644 --- a/backend/workflows/branding.py +++ b/backend/workflows/branding.py @@ -14,6 +14,7 @@ def get_portal_branding() -> PortalBranding: defaults={ 'portal_title': 'TUBCO Onboarding & Offboarding Portal', 'company_name': 'TUBCO', + 'company_domain': 'tub.co', 'support_email': 'info@tub.co', 'default_language': 'de', 'primary_color': '#000078', @@ -23,6 +24,12 @@ def get_portal_branding() -> PortalBranding: return branding +def get_company_email_domain() -> str: + branding = get_portal_branding() + domain = (branding.company_domain or '').strip().lower().lstrip('@') + return domain or 'tub.co' + + def get_portal_logo_url() -> str: branding = get_portal_branding() if branding.logo_image: @@ -51,6 +58,7 @@ def get_branding_context() -> dict[str, object]: 'portal_branding': branding, 'portal_title': branding.portal_title, 'portal_company_name': branding.company_name, + 'portal_email_domain': get_company_email_domain(), 'portal_support_email': branding.support_email, 'portal_default_language': branding.default_language, 'portal_primary_color': branding.primary_color, @@ -67,6 +75,7 @@ def get_branding_email_copy() -> dict[str, str]: portal_title = (branding.portal_title or f'{company_name} Portal').strip() return { 'company_name': company_name, + 'company_domain': get_company_email_domain(), 'portal_title': portal_title, 'support_email': (branding.support_email or '').strip(), } @@ -78,7 +87,9 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]: from .tasks import DEFAULT_NOTIFICATION_TEMPLATES templates = deepcopy(DEFAULT_NOTIFICATION_TEMPLATES) - company_name = get_branding_email_copy()['company_name'] + branding_copy = get_branding_email_copy() + company_name = branding_copy['company_name'] + support_email = branding_copy['support_email'] or f"it@{branding_copy['company_domain']}" welcome = templates.get('onboarding_welcome') if welcome: welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}' @@ -89,7 +100,7 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]: 'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n' 'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n' 'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n' - 'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n' + f'Wenn du Fragen hast, melde dich gerne jederzeit unter {support_email}.\n\n' 'Viele Grüße\n' f'{company_name} IT' ) @@ -99,7 +110,7 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]: 'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n' 'Your work email address is: {{ EMAIL }}.\n' 'You will find your onboarding documents attached as a PDF.\n\n' - 'If you have any questions, feel free to contact us anytime.\n\n' + f'If you have any questions, feel free to contact {support_email}.\n\n' 'Best regards,\n' f'{company_name} IT' ) diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index d5ca11b..acd6060 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -6,6 +6,7 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, Set from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy +from .branding import get_company_email_domain from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role @@ -175,6 +176,7 @@ class PortalBrandingForm(forms.ModelForm): fields = [ 'portal_title', 'company_name', + 'company_domain', 'support_email', 'default_language', 'logo_image', @@ -185,6 +187,7 @@ class PortalBrandingForm(forms.ModelForm): labels = { 'portal_title': gettext_lazy('Portal-Titel'), 'company_name': gettext_lazy('Firmenname'), + 'company_domain': gettext_lazy('Firmen-Domain'), 'support_email': gettext_lazy('Support-E-Mail'), 'default_language': gettext_lazy('Standardsprache'), 'logo_image': gettext_lazy('Logo'), @@ -343,6 +346,7 @@ class OnboardingRequestForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower() super().__init__(*args, **kwargs) + self.email_domain = get_company_email_domain() config = WorkflowConfig.objects.order_by('id').first() self.handover_lead_days = max(0, int(getattr(config, 'device_handover_lead_days', 5) or 5)) @@ -350,6 +354,7 @@ class OnboardingRequestForm(forms.ModelForm): self.fields['handover_date'].widget.attrs['min'] = minimum_handover_date.isoformat() self.fields['full_name'].label = 'Name' + self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain} full_name_initial = (self.initial.get('full_name') or '').strip() if full_name_initial and not self.initial.get('first_name') and not self.initial.get('last_name'): name_parts = full_name_initial.split() @@ -369,8 +374,9 @@ class OnboardingRequestForm(forms.ModelForm): value = (self.cleaned_data.get('work_email') or '').strip().lower() if not value: return value - if not value.endswith('@tub.co'): - raise forms.ValidationError('Bitte verwenden Sie eine @tub.co E-Mail-Adresse.') + expected_suffix = f'@{self.email_domain}' + if self.email_domain and not value.endswith(expected_suffix): + raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain}) return value def clean_signature_image(self): @@ -531,11 +537,21 @@ class OffboardingRequestForm(forms.ModelForm): def __init__(self, *args, **kwargs): prefill_profile = kwargs.pop('prefill_profile', None) super().__init__(*args, **kwargs) + self.email_domain = get_company_email_domain() self.fields['full_name'].label = 'Vorname und Nachname' - self.fields['work_email'].help_text = '' + self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain} if prefill_profile: self.fields['full_name'].initial = prefill_profile.full_name self.fields['work_email'].initial = prefill_profile.work_email self.fields['department'].initial = prefill_profile.department self.fields['job_title'].initial = prefill_profile.job_title apply_form_field_config('offboarding', self) + + def clean_work_email(self): + value = (self.cleaned_data.get('work_email') or '').strip().lower() + if not value: + return value + expected_suffix = f'@{self.email_domain}' + if self.email_domain and not value.endswith(expected_suffix): + raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain}) + return value diff --git a/backend/workflows/migrations/0038_portalappconfig.py b/backend/workflows/migrations/0038_portalappconfig.py new file mode 100644 index 0000000..a2e0875 --- /dev/null +++ b/backend/workflows/migrations/0038_portalappconfig.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0037_alter_portalbranding_logo_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalAppConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=80, unique=True)), + ('section', models.CharField(choices=[('app', 'Apps'), ('platform', 'Platform Apps'), ('admin', 'Admin Apps')], default='app', max_length=20)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_enabled', models.BooleanField(default=True)), + ('title_override', models.CharField(blank=True, max_length=255)), + ('title_override_en', models.CharField(blank=True, max_length=255)), + ('description_override', models.TextField(blank=True)), + ('description_override_en', models.TextField(blank=True)), + ('action_label_override', models.CharField(blank=True, max_length=255)), + ('action_label_override_en', models.CharField(blank=True, max_length=255)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal App', + 'verbose_name_plural': 'Portal Apps', + 'ordering': ['section', 'sort_order', 'key'], + }, + ), + ] diff --git a/backend/workflows/migrations/0039_portalbranding_company_domain.py b/backend/workflows/migrations/0039_portalbranding_company_domain.py new file mode 100644 index 0000000..de83381 --- /dev/null +++ b/backend/workflows/migrations/0039_portalbranding_company_domain.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2026-03-26 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0038_portalappconfig'), + ] + + operations = [ + migrations.AddField( + model_name='portalbranding', + name='company_domain', + field=models.CharField(blank=True, default='tub.co', max_length=120), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index f986b51..7108549 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -29,6 +29,7 @@ class PortalBranding(models.Model): name = models.CharField(max_length=80, default='Default', unique=True) portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal') company_name = models.CharField(max_length=255, default='TUBCO') + company_domain = models.CharField(max_length=120, blank=True, default='tub.co') support_email = models.EmailField(blank=True, default='info@tub.co') default_language = models.CharField( max_length=10, @@ -59,6 +60,54 @@ class PortalBranding(models.Model): return self.portal_title or self.company_name or self.name +class PortalAppConfig(models.Model): + SECTION_APP = 'app' + SECTION_PLATFORM = 'platform' + SECTION_ADMIN = 'admin' + SECTION_CHOICES = [ + (SECTION_APP, _('Apps')), + (SECTION_PLATFORM, _('Platform Apps')), + (SECTION_ADMIN, _('Admin Apps')), + ] + + key = models.CharField(max_length=80, unique=True) + section = models.CharField(max_length=20, choices=SECTION_CHOICES, default=SECTION_APP) + sort_order = models.PositiveIntegerField(default=0) + is_enabled = models.BooleanField(default=True) + title_override = models.CharField(max_length=255, blank=True) + title_override_en = models.CharField(max_length=255, blank=True) + description_override = models.TextField(blank=True) + description_override_en = models.TextField(blank=True) + action_label_override = models.CharField(max_length=255, blank=True) + action_label_override_en = models.CharField(max_length=255, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['section', 'sort_order', 'key'] + verbose_name = 'Portal App' + verbose_name_plural = 'Portal Apps' + + def __str__(self) -> str: + return self.key + + def _translated_value(self, field_name: str, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en': + english_value = (getattr(self, f'{field_name}_en', '') or '').strip() + if english_value: + return english_value + return (getattr(self, field_name, '') or '').strip() + + def translated_title_override(self, language_code: str | None = None) -> str: + return self._translated_value('title_override', language_code) + + def translated_description_override(self, language_code: str | None = None) -> str: + return self._translated_value('description_override', language_code) + + def translated_action_label_override(self, language_code: str | None = None) -> str: + return self._translated_value('action_label_override', language_code) + + class AdminAuditLog(models.Model): actor = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index 890d2d0..e8841e5 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -29,6 +29,7 @@ ROLE_LABELS = { CAPABILITIES = { 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, 'manage_product_branding': {ROLE_PLATFORM_OWNER}, + 'manage_app_registry': {ROLE_PLATFORM_OWNER}, 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, 'run_intro_session': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, 'generate_intro_pdfs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, @@ -123,6 +124,7 @@ def template_role_context(user) -> dict[str, object]: 'role_key': role_key, 'role_label': str(ROLE_LABELS[role_key]), 'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'), + 'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'), 'can_manage_users': user_has_capability(user, 'manage_users'), 'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'), 'can_run_intro_session': user_has_capability(user, 'run_intro_session'), diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index 014198d..ada691d 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -35,6 +35,12 @@ textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, table { width: 100%; border-collapse: collapse; font-size: 14px; } th, td { border: 1px solid #dce5f1; padding: 8px; text-align: left; vertical-align: top; } th { background: #f6f9ff; color: #334155; } +.app-registry-wrap textarea { min-height: 86px; } +.app-registry-table input[type="text"], +.app-registry-table input[type="number"], +.app-registry-table select, +.app-registry-table textarea { min-width: 160px; } +.app-registry-table td { background: rgba(255,255,255,0.9); } .template-block { border: 1px solid #d8e3f0; border-radius: 10px; background: #fff; padding: 10px; margin-top: 10px; } .template-title, .rule-title { margin: 0 0 8px; color: #24344e; font-weight: 700; font-size: 14px; } .rule-card { margin-top: 12px; border: 1px solid #d8e3f0; border-radius: 12px; padding: 10px; background: #fff; } diff --git a/backend/workflows/static/workflows/js/offboarding_form.js b/backend/workflows/static/workflows/js/offboarding_form.js index c7dec47..bccbae1 100644 --- a/backend/workflows/static/workflows/js/offboarding_form.js +++ b/backend/workflows/static/workflows/js/offboarding_form.js @@ -22,6 +22,8 @@ const fullName = byName('full_name'); const workEmail = byName('work_email'); + const form = fullName ? fullName.closest('form') : null; + const emailDomain = (((form && form.dataset.emailDomain) || 'tub.co') + '').replace(/^@+/, '').trim(); if (!fullName || !workEmail) return; let lastSuggested = ''; @@ -31,7 +33,7 @@ const lastName = extractLastName(fullName.value); const slug = slugifyForEmail(lastName); if (!slug) return; - const suggestion = slug + '@tub.co'; + const suggestion = slug + '@' + emailDomain; const current = (workEmail.value || '').trim(); if (!userEditedEmail || current === '' || current === lastSuggested) { workEmail.value = suggestion; diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index 8ae6960..8a0ce0b 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -5,6 +5,7 @@ const btnNext = document.getElementById('btn-next'); const btnSubmit = document.getElementById('btn-submit'); const form = document.getElementById('onboarding-form'); + const emailDomain = ((form && form.dataset.emailDomain) || 'tub.co').replace(/^@+/, '').trim(); let current = 0; form.setAttribute('novalidate', 'novalidate'); @@ -82,7 +83,7 @@ function suggestEmail() { const slug = slugifyForEmail(lastName.value); if (!slug) return; - const suggestion = slug + '@tub.co'; + const suggestion = slug + '@' + emailDomain; if (!userEditedEmail || workEmail.value === '' || workEmail.value === lastSuggested) { workEmail.value = suggestion; lastSuggested = suggestion; diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 9e804b8..9adfd4b 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -101,7 +101,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = { 'Vertragsbeginn: {{ CONTRACT_START }}\n' 'E-Mail-Adresse: {{ EMAIL }}\n\n' '{% if PDF_LINK %}In 2 Minuten findest du alle Infos über den Mitarbeiter als PDF unter diesem Link: {{ PDF_LINK }}\n\n{% endif %}' - 'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei der it@tub.co melden!\n\n' + 'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei {{ SUPPORT_EMAIL }} melden!\n\n' 'Vielen Dank und schöne Grüße,\n' 'Die IT.' ), @@ -114,7 +114,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = { 'Contract start: {{ CONTRACT_START }}\n' 'Email address: {{ EMAIL }}\n\n' '{% if PDF_LINK %}You will find the employee PDF here in about 2 minutes: {{ PDF_LINK }}\n\n{% endif %}' - 'If you need any other information, please contact it@tub.co.\n\n' + 'If you need any other information, please contact {{ SUPPORT_EMAIL }}.\n\n' 'Thank you and best regards,\n' 'IT' ), @@ -1176,6 +1176,7 @@ def process_onboarding_request(onboarding_request_id: int) -> None: request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) try: + branding_copy = get_branding_email_copy() it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails() salutation = (request_obj.get_gender_display() or '').strip() display_name = f"{salutation} {request_obj.full_name}".strip() @@ -1204,6 +1205,7 @@ def process_onboarding_request(onboarding_request_id: int) -> None: 'CONTRACT_START': request_obj.contract_start, 'EMAIL': request_obj.work_email, 'REQUESTED_BY': request_obj.onboarded_by_email or '-', + 'SUPPORT_EMAIL': branding_copy['support_email'] or f"it@{branding_copy['company_domain']}", 'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name, 'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-', 'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email, @@ -1285,6 +1287,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None: request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) try: + branding_copy = get_branding_email_copy() it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails() pdf_path = _generate_offboarding_pdf(request_obj) @@ -1297,6 +1300,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None: 'LAST_WORKING_DAY': request_obj.last_working_day, 'REQUESTED_BY': request_obj.requested_by_email, 'EMAIL': request_obj.work_email, + 'SUPPORT_EMAIL': branding_copy['support_email'] or f"it@{branding_copy['company_domain']}", } _send_templated_email( diff --git a/backend/workflows/templates/workflows/app_registry.html b/backend/workflows/templates/workflows/app_registry.html new file mode 100644 index 0000000..dea7d93 --- /dev/null +++ b/backend/workflows/templates/workflows/app_registry.html @@ -0,0 +1,83 @@ +{% extends 'workflows/base_shell.html' %} +{% load static i18n %} + +{% block title %}{% trans "App Registry" %}{% 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 "App Registry" %}

    +

    {% trans "Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf der Landing Page ausspielen." %}

    + +{% include 'workflows/includes/messages.html' %} + +
    +
    +
    {% trans "Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." %}
    + {% trans "Produktkern" %} +
    + + {% csrf_token %} +
    + + + + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + + + {% endfor %} + +
    {% trans "Key" %}{% trans "Aktiv" %}{% trans "Bereich" %}{% trans "Reihenfolge" %}{% trans "Titel DE" %}{% trans "Titel EN" %}{% trans "Aktion DE" %}{% trans "Aktion EN" %}
    +
    {{ row.config.key }}
    +
    {{ row.definition.title }}
    +
    + + + + + + + + + + + + + + + +
    +
    +
    +
    {% trans "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter Apps oder Admin Apps einordnen." %}
    + +
    + +
    +{% endblock %} diff --git a/backend/workflows/templates/workflows/branding_settings.html b/backend/workflows/templates/workflows/branding_settings.html index 4f9138e..c7380d4 100644 --- a/backend/workflows/templates/workflows/branding_settings.html +++ b/backend/workflows/templates/workflows/branding_settings.html @@ -26,6 +26,11 @@ {{ form.company_name }}
    +
    + + {{ form.company_domain }} +
    {% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}
    +
    {{ form.support_email }} diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 76e4552..5066ee8 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -102,12 +102,13 @@ docker compose exec -T web python manage.py check

    Role and Permission Model

      -
    • Stable Django group names: Super Admin, Admin, IT Staff, Staff.
    • +
    • Stable Django group names: Platform Owner, Super Admin, Admin, IT Staff, Staff.
    • Groups are created automatically through a post_migrate hook in workflows.signals.
    • Capability checks are centralized in workflows.roles.CAPABILITIES.
    • Use _require_capability(...) in views instead of flat is_staff checks.
    • Templates receive permission flags from workflows.context_processors.role_context.
    • -
    • Super-admin-only user management lives at /admin-tools/users/ and is the preferred path for normal role assignment, account activation, invitation mail dispatch, password-reset mail dispatch, and controlled user deletion.
    • +
    • Platform Owner is the product-level role. Company roles remain Super Admin, Admin, IT Staff, and Staff.
    • +
    • User management lives at /admin-tools/users/ and is the preferred path for normal role assignment, account activation, invitation mail dispatch, password-reset mail dispatch, and controlled user deletion.
    • Backward-compatibility rule: authenticated legacy users with is_staff=True but no explicit role group currently fall back to the Admin capability set.
    • superuser accounts resolve to Super Admin.
    • When adding a new operational page or action, define the capability in roles.py, gate the view, and hide the UI affordance when the capability is absent.
    • @@ -180,6 +181,15 @@ docker compose exec -T web django-admin compilemessages
    • User invitation emails and welcome-template fallbacks also use the configured branding defaults.
    +

    10b) App Registry

    +
      +
    • Registry definitions live in workflows/app_registry.py.
    • +
    • DB overrides live in PortalAppConfig.
    • +
    • The landing page now renders from registry data instead of hardcoded cards.
    • +
    • Security remains code-based: app visibility/order is configurable, but access still depends on role capabilities in roles.py.
    • +
    • Management UI: /admin-tools/apps/ for Platform Owner.
    • +
    +

    11) Builder Architecture

    Form Builder

      diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 8f194c0..c9387e0 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -51,147 +51,44 @@
      {% include 'workflows/includes/messages.html' %} -
      -

      {% trans "Apps" %}

      -

      {% trans "Wählen Sie den gewünschten Prozess." %}

      -
      -
      -
      -
      -
      ON
      -

      {% trans "Onboarding" %}

      -

      {% trans "Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen." %}

      -
      -{% trans "Mehrschritt-Formular" %} - PDF -{% trans "E-Mail Routing" %} -
      -
      - -
      - -
      -
      -
      OFF
      -

      {% trans "Offboarding" %}

      -

      {% trans "Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten." %}

      -
      -{% trans "Profile-Suche" %} -{% trans "Hardware-Liste" %} -{% trans "IT-Rückgabe" %} -
      -
      - -
      - - {% if can_access_requests_dashboard %} -
      -
      -
      APP
      -

      {% trans "Anfragen Dashboard" %}

      -

      {% trans "Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen." %}

      -
      -{% trans "Suche" %} -{% trans "Status" %} -{% trans "PDF Zugriff" %} -
      -
      - -
      - {% endif %} -
      - - {% if can_manage_product_branding %} + {% for section in portal_app_sections %} + {% if not forloop.first %} -
      -

      {% trans "Platform Apps" %}

      -

      {% trans "Produktweite Konfiguration und Produktsteuerung." %}

      -
      -
      -
      -

      {% trans "Branding" %}

      -

      {% trans "Logo, Portalname, Farben und PDF-Briefkopf verwalten." %}

      -{% trans "Öffnen" %} -
      -
      {% endif %} - - {% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %} - -
      -

      {% trans "Admin Apps" %}

      -

      {% trans "Konfiguration, Tests und Steuerung." %}

      +
      +

      {{ section.title }}

      +

      {{ section.subtitle }}

      -
      - {% if can_manage_integrations %} +
      + {% for app in section.apps %} + {% if section.key == 'app' %} +
      +
      +
      {{ app.accent }}
      +

      {{ app.title }}

      +

      {{ app.description }}

      + {% if app.tags %} +
      + {% for tag in app.tags %} + {{ tag }} + {% endfor %} +
      + {% endif %} +
      + +
      + {% else %}
      -

      {% trans "Integrationen" %}

      -

      {% trans "Nextcloud- und E-Mail-Setup." %}

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_manage_users %} -
      -

      {% trans "Benutzer & Rollen" %}

      -

      {% trans "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." %}

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_view_audit_log %} -
      -

      {% trans "Audit Log" %}

      -

      {% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_manage_backups %} -
      -

      {% trans "Backup & Recovery" %}

      -

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

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_manage_welcome_emails %} -
      -

      {% trans "Welcome E-Mails" %}

      -

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

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_manage_builders %} -
      -

      {% trans "Form Builder" %}

      -

      {% trans "Felder, Schritte und Optionen verwalten." %}

      -{% trans "Öffnen" %} -
      -
      -

      {% trans "Einweisungs-Builder" %}

      -

      {% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_view_docs %} -
      -

      {% trans "Handbook" %}

      -

      {% trans "Project wiki and developer documentation in one place." %}

      -{% trans "Öffnen" %} -
      - {% endif %} - {% if can_access_django_admin_link %} -
      -

      {% trans "Django Admin" %}

      -

      {% trans "Vollständige Datenverwaltung." %}

      -{% trans "Öffnen" %} +

      {{ app.title }}

      +

      {{ app.description }}

      +{{ app.action_label }}
      {% endif %} + {% endfor %}
      - {% endif %} + {% endfor %}
      -
      + {% csrf_token %}
      {% for field in form.visible_fields %} @@ -71,4 +71,3 @@ {% block extra_scripts %} {% endblock %} - diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index b6d7edd..9a2953a 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -42,7 +42,7 @@
      {% trans "Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt." %}
      {% endif %} - + {% csrf_token %} {% for section in onboarding_sections %} @@ -165,4 +165,3 @@ {% block extra_scripts %} {% endblock %} - diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html index 3810443..923c016 100644 --- a/backend/workflows/templates/workflows/project_wiki.html +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -179,6 +179,7 @@
    • 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, workflow rules, and remote backup target settings.
    • Branding: portal title, company name, logo, support email, default language, PDF letterhead, and basic brand colors.
    • +
    • App Registry: platform-level registry for enabling, ordering, and relabeling landing-page apps without editing the home template.
    • Benutzer & Rollen: super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.
    • 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.
    • diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 2b7afca..708af95 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -32,6 +32,8 @@ urlpatterns = [ path('admin-tools/handbook/', views.handbook_page, name='handbook_page'), path('admin-tools/branding/', views.portal_branding_page, name='portal_branding_page'), path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'), + path('admin-tools/apps/', views.portal_app_registry_page, name='portal_app_registry_page'), + path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'), path('admin-tools/users/', views.user_management_page, name='user_management_page'), path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'), path('admin-tools/users//update/', views.update_user_from_admin, name='update_user_from_admin'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index d966876..f4330f2 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -24,8 +24,9 @@ from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import get_language, override from django.urls import reverse +from .app_registry import build_portal_app_sections, get_portal_app_registry_rows from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle -from .branding import get_branding_email_copy, get_default_notification_templates +from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, @@ -35,7 +36,7 @@ from .form_builder import ( ONBOARDING_PAGE_ORDER, ensure_form_field_configs, ) -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud @@ -244,6 +245,7 @@ def _audit_action_label(action: str) -> str: 'backup_verified': _('Backup verifiziert'), 'backup_deleted': _('Backup gelöscht'), 'backup_settings_saved': _('Backup-Einstellungen gespeichert'), + 'portal_app_registry_saved': _('App-Registry gespeichert'), } return labels.get(action, action.replace('_', ' ').strip().capitalize()) @@ -334,10 +336,57 @@ def home(request): 'email_test_mode': is_email_test_mode(), 'workflow_config': config, 'role_label': get_user_role_label(request.user), + 'portal_app_sections': build_portal_app_sections(request.user), }, ) +@_require_capability('manage_app_registry') +def portal_app_registry_page(request): + return render( + request, + 'workflows/app_registry.html', + { + 'rows': get_portal_app_registry_rows(), + 'section_choices': _translate_choice_list(PortalAppConfig.SECTION_CHOICES), + }, + ) + + +@_require_capability('manage_app_registry') +@require_POST +def save_portal_app_registry(request): + rows = get_portal_app_registry_rows() + for row in rows: + config = row['config'] + key = config.key + config.section = (request.POST.get(f'section__{key}') or config.section).strip() + if config.section not in dict(PortalAppConfig.SECTION_CHOICES): + config.section = row['default_section'] + config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on' + try: + config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order']) + except ValueError: + config.sort_order = row['default_sort_order'] + config.title_override = (request.POST.get(f'title_override__{key}') or '').strip() + config.title_override_en = (request.POST.get(f'title_override_en__{key}') or '').strip() + config.description_override = (request.POST.get(f'description_override__{key}') or '').strip() + config.description_override_en = (request.POST.get(f'description_override_en__{key}') or '').strip() + config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip() + config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip() + config.save() + + _audit( + request, + 'portal_app_registry_saved', + target_type='portal_app_registry', + target_label='Portal App Registry', + details={'updated_apps': len(rows)}, + ) + messages.success(request, _('App-Registry gespeichert.')) + return redirect('portal_app_registry_page') + + def _user_management_rows(): user_model = get_user_model() role_order = { @@ -1170,6 +1219,7 @@ def onboarding_create(request): 'legal_text': legal_text, 'saved': request.GET.get('saved') == '1', 'saved_request_id': request.GET.get('id', ''), + 'portal_email_domain': get_company_email_domain(), }, ) @@ -1331,7 +1381,8 @@ def offboarding_create(request): if selected_profile: obj.employee_profile = selected_profile requester_email = (request.user.email or '').strip().lower() - if requester_email and requester_email.endswith('@tub.co'): + company_suffix = f"@{get_company_email_domain()}" + if requester_email and requester_email.endswith(company_suffix): obj.requested_by_email = requester_email else: obj.requested_by_email = settings.DEFAULT_FROM_EMAIL @@ -1353,6 +1404,7 @@ def offboarding_create(request): 'search_query': search_query, 'saved': request.GET.get('saved') == '1', 'saved_request_id': request.GET.get('id', ''), + 'portal_email_domain': get_company_email_domain(), }, ) From 007d4e329a78245a181f91945a6659bb89411897 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 12:29:26 +0100 Subject: [PATCH 04/45] snapshot: preserve extended branding layer and branding UI polish --- backend/locale/en/LC_MESSAGES/django.po | 220 ++++++++++------ backend/workflows/admin.py | 2 +- backend/workflows/branding.py | 42 +++ backend/workflows/emailing.py | 3 +- backend/workflows/forms.py | 25 ++ ...0_portalbranding_favicon_image_and_more.py | 49 ++++ backend/workflows/models.py | 12 + .../static/workflows/css/admin_tools.css | 31 ++- .../static/workflows/css/app_chrome.css | 19 ++ .../templates/workflows/auth/login.html | 2 +- .../templates/workflows/base_shell.html | 7 + .../workflows/branding_settings.html | 248 ++++++++++++++---- .../workflows/developer_handbook.html | 4 +- .../templates/workflows/project_wiki.html | 2 +- 14 files changed, 525 insertions(+), 141 deletions(-) create mode 100644 backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 8bc10ab..dc566a3 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 10:55+0000\n" +"POT-Creation-Date: 2026-03-26 11:02+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/app_registry.py:32 workflows/models.py:261 workflows/models.py:342 +#: workflows/app_registry.py:32 workflows/models.py:273 workflows/models.py:354 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:43 workflows/models.py:262 workflows/models.py:343 +#: workflows/app_registry.py:43 workflows/models.py:274 workflows/models.py:355 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -213,7 +213,7 @@ msgstr "Django Admin" msgid "Vollständige Datenverwaltung." msgstr "Full data management." -#: workflows/app_registry.py:165 workflows/models.py:68 +#: workflows/app_registry.py:165 workflows/models.py:80 msgid "Apps" msgstr "Apps" @@ -221,7 +221,7 @@ msgstr "Apps" msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:171 workflows/models.py:69 +#: workflows/app_registry.py:171 workflows/models.py:81 msgid "Platform Apps" msgstr "" @@ -231,7 +231,7 @@ msgstr "" msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:177 workflows/models.py:70 +#: workflows/app_registry.py:177 workflows/models.py:82 msgid "Admin Apps" msgstr "Admin Apps" @@ -338,307 +338,339 @@ msgstr "Invalid role." msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:188 +#: workflows/forms.py:195 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:189 +#: workflows/forms.py:196 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:190 +#: workflows/forms.py:197 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:191 +#: workflows/forms.py:198 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:192 +#: workflows/forms.py:199 +msgid "Absender-Anzeigename" +msgstr "" + +#: workflows/forms.py:200 +msgid "Login-Untertitel" +msgstr "" + +#: workflows/forms.py:201 +msgid "Footer-Text DE" +msgstr "" + +#: workflows/forms.py:202 +msgid "Footer-Text EN" +msgstr "" + +#: workflows/forms.py:203 +msgid "Rechtlicher Hinweis DE" +msgstr "" + +#: workflows/forms.py:204 +msgid "Rechtlicher Hinweis EN" +msgstr "" + +#: workflows/forms.py:205 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:193 +#: workflows/forms.py:206 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:194 +#: workflows/forms.py:207 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:195 +#: workflows/forms.py:208 +msgid "Favicon" +msgstr "" + +#: workflows/forms.py:209 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:196 +#: workflows/forms.py:210 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:210 +#: workflows/forms.py:227 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:218 +#: workflows/forms.py:235 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:357 workflows/forms.py:542 +#: workflows/forms.py:243 +msgid "Das Favicon darf maximal 2 MB groß sein." +msgstr "" + +#: workflows/forms.py:382 workflows/forms.py:567 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:379 workflows/forms.py:556 +#: workflows/forms.py:404 workflows/forms.py:581 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:464 +#: workflows/forms.py:489 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:139 workflows/views.py:200 +#: workflows/models.py:151 workflows/views.py:200 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:140 workflows/views.py:201 +#: workflows/models.py:152 workflows/views.py:201 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:141 workflows/models.py:456 workflows/views.py:202 +#: workflows/models.py:153 workflows/models.py:468 workflows/views.py:202 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:142 workflows/models.py:396 +#: workflows/models.py:154 workflows/models.py:408 #: workflows/templates/workflows/backup_recovery.html:70 #: workflows/templates/workflows/requests_dashboard.html:222 #: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:203 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:149 +#: workflows/models.py:161 msgid "Herr" msgstr "" -#: workflows/models.py:149 +#: workflows/models.py:161 msgid "Frau" msgstr "" -#: workflows/models.py:149 +#: workflows/models.py:161 msgid "Divers" msgstr "" -#: workflows/models.py:159 +#: workflows/models.py:171 msgid "befristet" msgstr "" -#: workflows/models.py:159 +#: workflows/models.py:171 msgid "unbefristet" msgstr "" -#: workflows/models.py:222 +#: workflows/models.py:234 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/models.py:223 +#: workflows/models.py:235 msgid "Geräte" msgstr "" -#: workflows/models.py:224 +#: workflows/models.py:236 msgid "Software" msgstr "" -#: workflows/models.py:225 +#: workflows/models.py:237 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:226 +#: workflows/models.py:238 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:227 +#: workflows/models.py:239 msgid "Ressourcen" msgstr "" -#: workflows/models.py:228 +#: workflows/models.py:240 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:254 +#: workflows/models.py:266 msgid "Automatisch" msgstr "" -#: workflows/models.py:255 workflows/views.py:95 +#: workflows/models.py:267 workflows/views.py:95 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:256 workflows/views.py:96 +#: workflows/models.py:268 workflows/views.py:96 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:257 workflows/views.py:97 +#: workflows/models.py:269 workflows/views.py:97 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:258 workflows/views.py:98 +#: workflows/models.py:270 workflows/views.py:98 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:300 +#: workflows/models.py:312 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:301 +#: workflows/models.py:313 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:302 +#: workflows/models.py:314 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:303 +#: workflows/models.py:315 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:304 +#: workflows/models.py:316 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:305 +#: workflows/models.py:317 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:306 +#: workflows/models.py:318 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:307 +#: workflows/models.py:319 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:308 +#: workflows/models.py:320 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:309 +#: workflows/models.py:321 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:310 +#: workflows/models.py:322 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:346 +#: workflows/models.py:358 msgid "Immer" msgstr "" -#: workflows/models.py:347 workflows/models.py:425 +#: workflows/models.py:359 workflows/models.py:437 msgid "Enthält" msgstr "" -#: workflows/models.py:348 workflows/models.py:426 +#: workflows/models.py:360 workflows/models.py:438 msgid "Ist gleich" msgstr "" -#: workflows/models.py:349 +#: workflows/models.py:361 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:350 +#: workflows/models.py:362 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:392 +#: workflows/models.py:404 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:393 +#: workflows/models.py:405 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:394 +#: workflows/models.py:406 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:395 +#: workflows/models.py:407 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:418 workflows/tasks.py:576 +#: workflows/models.py:430 workflows/tasks.py:576 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:419 workflows/tasks.py:577 +#: workflows/models.py:431 workflows/tasks.py:577 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:420 workflows/tasks.py:578 +#: workflows/models.py:432 workflows/tasks.py:578 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:421 workflows/tasks.py:579 +#: workflows/models.py:433 workflows/tasks.py:579 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:424 +#: workflows/models.py:436 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:427 +#: workflows/models.py:439 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:428 +#: workflows/models.py:440 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:455 +#: workflows/models.py:467 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:475 +#: workflows/models.py:487 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:476 +#: workflows/models.py:488 msgid "S3" msgstr "" -#: workflows/models.py:477 +#: workflows/models.py:489 msgid "NFS" msgstr "" @@ -754,7 +786,6 @@ msgid "Anmeldung" msgstr "Sign in" #: workflows/templates/registration/login.html:20 -#: workflows/templates/workflows/auth/login.html:18 msgid "Bitte melden Sie sich mit Ihrem Benutzerkonto an." msgstr "Please sign in with your user account." @@ -1173,28 +1204,28 @@ msgstr "Delete" msgid "Noch keine Backup-Bundles vorhanden." msgstr "No backup bundles available yet." -#: workflows/templates/workflows/base_shell.html:24 +#: workflows/templates/workflows/base_shell.html:31 msgid "Bitte bestätigen" msgstr "" -#: workflows/templates/workflows/base_shell.html:28 +#: workflows/templates/workflows/base_shell.html:35 #: workflows/templates/workflows/welcome_emails.html:134 msgid "Abbrechen" msgstr "Cancel" -#: workflows/templates/workflows/base_shell.html:29 +#: workflows/templates/workflows/base_shell.html:36 msgid "Bestätigen" msgstr "" -#: workflows/templates/workflows/base_shell.html:36 +#: workflows/templates/workflows/base_shell.html:43 msgid "Bitte warten" msgstr "Please wait" -#: workflows/templates/workflows/base_shell.html:37 +#: workflows/templates/workflows/base_shell.html:44 msgid "Aktion läuft" msgstr "Action in progress" -#: workflows/templates/workflows/base_shell.html:38 +#: workflows/templates/workflows/base_shell.html:45 msgid "Die Aktion wird im aktuellen Tab ausgeführt." msgstr "The action is running in the current tab." @@ -1209,28 +1240,43 @@ msgid "" "B. tub.co." msgstr "" -#: workflows/templates/workflows/branding_settings.html:53 +#: workflows/templates/workflows/branding_settings.html:41 +msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." +msgstr "" + +#: workflows/templates/workflows/branding_settings.html:78 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/templates/workflows/branding_settings.html:56 +#: workflows/templates/workflows/branding_settings.html:81 msgid "Aktuelles Logo:" msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:56 -#: workflows/templates/workflows/branding_settings.html:65 +#: workflows/templates/workflows/branding_settings.html:81 +#: workflows/templates/workflows/branding_settings.html:90 +#: workflows/templates/workflows/branding_settings.html:99 msgid "öffnen" msgstr "open" -#: workflows/templates/workflows/branding_settings.html:62 +#: workflows/templates/workflows/branding_settings.html:87 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/templates/workflows/branding_settings.html:65 +#: workflows/templates/workflows/branding_settings.html:90 msgid "Aktueller Briefkopf:" msgstr "Current letterhead:" -#: workflows/templates/workflows/branding_settings.html:70 +#: workflows/templates/workflows/branding_settings.html:96 +msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." +msgstr "" + +#: workflows/templates/workflows/branding_settings.html:99 +#, fuzzy +#| msgid "Aktuelles Logo:" +msgid "Aktuelles Favicon:" +msgstr "Current logo:" + +#: workflows/templates/workflows/branding_settings.html:104 msgid "" "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " "hochgeladen werden." @@ -1238,7 +1284,7 @@ msgstr "" "TUBCO remains the default until values are changed or files are uploaded " "here." -#: workflows/templates/workflows/branding_settings.html:71 +#: workflows/templates/workflows/branding_settings.html:105 msgid "Branding speichern" msgstr "Save branding" diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index 31a851f..1064d62 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -22,7 +22,7 @@ class AdminAuditLogAdmin(admin.ModelAdmin): @admin.register(PortalBranding) class PortalBrandingAdmin(admin.ModelAdmin): - list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at') + list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at') @admin.register(PortalAppConfig) diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py index 46d4ae6..94759b0 100644 --- a/backend/workflows/branding.py +++ b/backend/workflows/branding.py @@ -1,9 +1,11 @@ from __future__ import annotations from pathlib import Path +from email.utils import formataddr from django.conf import settings from django.templatetags.static import static +from django.utils.translation import get_language from .models import PortalBranding @@ -16,6 +18,12 @@ def get_portal_branding() -> PortalBranding: 'company_name': 'TUBCO', 'company_domain': 'tub.co', 'support_email': 'info@tub.co', + 'sender_display_name': 'TUBCO', + 'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.', + 'footer_text': 'TUBCO Onboarding & Offboarding Portal', + 'footer_text_en': 'TUBCO Onboarding & Offboarding Portal', + 'legal_notice': '', + 'legal_notice_en': '', 'default_language': 'de', 'primary_color': '#000078', 'secondary_color': '#c0002b', @@ -40,6 +48,16 @@ def get_portal_logo_url() -> str: return static('workflows/img/tubco-logo.svg') +def get_portal_favicon_url() -> str: + branding = get_portal_branding() + if branding.favicon_image: + try: + return branding.favicon_image.url + except ValueError: + pass + return static('workflows/img/tubco-logo.svg') + + def get_portal_letterhead_path() -> Path: branding = get_portal_branding() if branding.pdf_letterhead: @@ -54,18 +72,31 @@ def get_portal_letterhead_path() -> Path: def get_branding_context() -> dict[str, object]: branding = get_portal_branding() + lang = (get_language() or branding.default_language or 'de').split('-')[0] + footer_text = (branding.footer_text_en or '').strip() if lang == 'en' else '' + legal_notice = (branding.legal_notice_en or '').strip() if lang == 'en' else '' + if not footer_text: + footer_text = (branding.footer_text or branding.portal_title).strip() + if not legal_notice: + legal_notice = (branding.legal_notice or '').strip() return { 'portal_branding': branding, 'portal_title': branding.portal_title, 'portal_company_name': branding.company_name, 'portal_email_domain': get_company_email_domain(), 'portal_support_email': branding.support_email, + 'portal_sender_display_name': branding.sender_display_name or branding.company_name, + 'portal_login_subtitle': branding.login_subtitle, + 'portal_footer_text': footer_text, + 'portal_legal_notice': legal_notice, 'portal_default_language': branding.default_language, 'portal_primary_color': branding.primary_color, 'portal_secondary_color': branding.secondary_color, 'portal_logo_url': get_portal_logo_url(), + 'portal_favicon_url': get_portal_favicon_url(), 'portal_has_custom_logo': bool(branding.logo_image), 'portal_has_custom_letterhead': bool(branding.pdf_letterhead), + 'portal_has_custom_favicon': bool(branding.favicon_image), } @@ -78,9 +109,20 @@ def get_branding_email_copy() -> dict[str, str]: 'company_domain': get_company_email_domain(), 'portal_title': portal_title, 'support_email': (branding.support_email or '').strip(), + 'sender_display_name': (branding.sender_display_name or company_name).strip(), } +def get_branded_from_email(email_address: str | None) -> str | None: + address = (email_address or '').strip() + if not address: + return None + display_name = (get_branding_email_copy()['sender_display_name'] or '').strip() + if not display_name: + return address + return formataddr((display_name, address)) + + def get_default_notification_templates() -> dict[str, dict[str, str]]: from copy import deepcopy diff --git a/backend/workflows/emailing.py b/backend/workflows/emailing.py index 84620f9..68bba55 100644 --- a/backend/workflows/emailing.py +++ b/backend/workflows/emailing.py @@ -1,6 +1,7 @@ from django.conf import settings from django.core.mail import EmailMessage, get_connection +from .branding import get_branded_from_email from .models import SystemEmailConfig, WorkflowConfig @@ -66,7 +67,7 @@ def send_system_email( msg = EmailMessage( subject=subject, body=body, - from_email=(from_email or smtp['from_email']), + from_email=get_branded_from_email(from_email or smtp['from_email']) or (from_email or smtp['from_email']), to=to, connection=connection, ) diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index acd6060..5aaabc4 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -178,9 +178,16 @@ class PortalBrandingForm(forms.ModelForm): 'company_name', 'company_domain', 'support_email', + 'sender_display_name', + 'login_subtitle', + 'footer_text', + 'footer_text_en', + 'legal_notice', + 'legal_notice_en', 'default_language', 'logo_image', 'pdf_letterhead', + 'favicon_image', 'primary_color', 'secondary_color', ] @@ -189,9 +196,16 @@ class PortalBrandingForm(forms.ModelForm): 'company_name': gettext_lazy('Firmenname'), 'company_domain': gettext_lazy('Firmen-Domain'), 'support_email': gettext_lazy('Support-E-Mail'), + 'sender_display_name': gettext_lazy('Absender-Anzeigename'), + 'login_subtitle': gettext_lazy('Login-Untertitel'), + 'footer_text': gettext_lazy('Footer-Text DE'), + 'footer_text_en': gettext_lazy('Footer-Text EN'), + 'legal_notice': gettext_lazy('Rechtlicher Hinweis DE'), + 'legal_notice_en': gettext_lazy('Rechtlicher Hinweis EN'), 'default_language': gettext_lazy('Standardsprache'), 'logo_image': gettext_lazy('Logo'), 'pdf_letterhead': gettext_lazy('PDF-Briefkopf'), + 'favicon_image': gettext_lazy('Favicon'), 'primary_color': gettext_lazy('Primärfarbe'), 'secondary_color': gettext_lazy('Sekundärfarbe'), } @@ -200,6 +214,9 @@ class PortalBrandingForm(forms.ModelForm): 'secondary_color': forms.TextInput(attrs={'type': 'color'}), 'logo_image': forms.ClearableFileInput(attrs={'accept': '.svg,.png,.jpg,.jpeg,.webp'}), 'pdf_letterhead': forms.ClearableFileInput(attrs={'accept': '.pdf'}), + 'favicon_image': forms.ClearableFileInput(attrs={'accept': '.ico,.png,.svg,.webp'}), + 'legal_notice': forms.Textarea(attrs={'rows': 3}), + 'legal_notice_en': forms.Textarea(attrs={'rows': 3}), } def clean_logo_image(self): @@ -218,6 +235,14 @@ class PortalBrandingForm(forms.ModelForm): raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.')) return letterhead + def clean_favicon_image(self): + favicon = self.cleaned_data.get('favicon_image') + if not favicon: + return favicon + if getattr(favicon, 'size', 0) > 2 * 1024 * 1024: + raise forms.ValidationError(_('Das Favicon darf maximal 2 MB groß sein.')) + return favicon + class OnboardingRequestForm(forms.ModelForm): first_name = forms.CharField(label='Vorname', required=False) diff --git a/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py b/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py new file mode 100644 index 0000000..4408dd2 --- /dev/null +++ b/backend/workflows/migrations/0040_portalbranding_favicon_image_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.5 on 2026-03-26 11:02 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0039_portalbranding_company_domain'), + ] + + operations = [ + migrations.AddField( + model_name='portalbranding', + name='favicon_image', + field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])]), + ), + migrations.AddField( + model_name='portalbranding', + name='footer_text', + field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='footer_text_en', + field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='legal_notice', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='portalbranding', + name='legal_notice_en', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='portalbranding', + name='login_subtitle', + field=models.CharField(blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.', max_length=255), + ), + migrations.AddField( + model_name='portalbranding', + name='sender_display_name', + field=models.CharField(blank=True, default='TUBCO', max_length=255), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 7108549..54e3091 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -31,6 +31,12 @@ class PortalBranding(models.Model): company_name = models.CharField(max_length=255, default='TUBCO') company_domain = models.CharField(max_length=120, blank=True, default='tub.co') support_email = models.EmailField(blank=True, default='info@tub.co') + sender_display_name = models.CharField(max_length=255, blank=True, default='TUBCO') + login_subtitle = models.CharField(max_length=255, blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.') + footer_text = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal') + footer_text_en = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal') + legal_notice = models.TextField(blank=True, default='') + legal_notice_en = models.TextField(blank=True, default='') default_language = models.CharField( max_length=10, choices=[('de', 'Deutsch'), ('en', 'English')], @@ -48,6 +54,12 @@ class PortalBranding(models.Model): null=True, validators=[FileExtensionValidator(allowed_extensions=['pdf'])], ) + favicon_image = models.FileField( + upload_to='branding/', + blank=True, + null=True, + validators=[FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])], + ) primary_color = models.CharField(max_length=20, blank=True, default='#000078') secondary_color = models.CharField(max_length=20, blank=True, default='#c0002b') updated_at = models.DateTimeField(auto_now=True) diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index ada691d..35878d6 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -7,6 +7,30 @@ h1 { margin: 12px 0 6px; color: #000078; } .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; } +.branding-sections { display: grid; gap: 14px; } +.branding-block { border: 1px solid #dce5f1; border-radius: 16px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.94)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); } +.branding-block-head { margin-bottom: 12px; } +.branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; } +.branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; } +.lang-pairs { align-items: start; } +.lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; } +.lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; } +.branding-preview { max-width: 460px; margin-left: auto; border: 1px solid #dce5f1; border-radius: 18px; background: + radial-gradient(circle at top right, rgba(59,112,234,0.10), transparent 30%), + linear-gradient(180deg, #f9fbff, #eef4ff); + padding: 10px; } +.branding-preview-shell { border: 1px solid rgba(210, 221, 236, 0.95); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.96)); box-shadow: 0 8px 22px rgba(16, 32, 57, 0.05); } +.branding-preview-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(217, 227, 238, 0.9); } +.branding-preview-logo { width: 64px; max-width: 100%; height: auto; display: block; object-fit: contain; filter: saturate(1.02); } +.branding-preview-copy { display: grid; gap: 2px; min-width: 0; } +.branding-preview-copy strong { color: #18335b; font-size: 13px; line-height: 1.2; } +.branding-preview-copy span { color: #61738d; font-size: 12px; line-height: 1.3; } +.branding-preview-band { display: flex; gap: 8px; padding: 10px 12px; } +.branding-preview-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 104px; padding: 5px 10px; border-radius: 999px; color: #fff; font-size: 10px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: #000078; box-shadow: inset 0 1px 0 rgba(255,255,255,0.16); } +.branding-preview-chip-secondary { background: #c0002b; } +.branding-preview-footer { padding: 0 12px 12px; } +.branding-preview-footer-main { color: #20385f; font-size: 11px; font-weight: 700; line-height: 1.35; } +.branding-preview-footer-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; } .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); } @@ -56,8 +80,13 @@ th { background: #f6f9ff; color: #334155; } .bulk-note { color: #64748b; font-size: 12px; } .field label { display: block; font-weight: 600; margin-bottom: 6px; } .field input, .field select { min-height: 40px; } +.field-full { grid-column: 1 / -1; } .mini { color: #64748b; font-size: 12px; } .table-controls input[type="text"], .table-controls select { width: 100%; min-height: 36px; padding: 7px 9px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; } .table-controls input[type="checkbox"] { transform: scale(1.1); width: auto; } .actions { white-space: nowrap; } -@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } } +@media (max-width: 760px) { + .grid { grid-template-columns: 1fr; } + .branding-preview-header { flex-direction: column; align-items: flex-start; } + .branding-preview-band { flex-wrap: wrap; } +} diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index 3129592..8ba66d8 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -122,6 +122,25 @@ margin-right: auto !important; } +.app-site-footer { + width: min(var(--app-shell-width), 100%); + margin: 14px auto 0; + padding: 0 10px 18px; + color: #5f728d; + text-align: center; +} + +.app-site-footer-main { + font-size: 13px; + font-weight: 700; +} + +.app-site-footer-legal { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; +} + @media (max-width: 900px) { .app-header, .app-header-in-shell { diff --git a/backend/workflows/templates/workflows/auth/login.html b/backend/workflows/templates/workflows/auth/login.html index cbca437..ab0ff1d 100644 --- a/backend/workflows/templates/workflows/auth/login.html +++ b/backend/workflows/templates/workflows/auth/login.html @@ -15,7 +15,7 @@ {% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index c9387e0..7472c20 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -34,6 +34,7 @@ {% trans "Operations Console" %}

      {{ portal_title }}

      {% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}

      + {% if can_manage_integrations %}
      {% trans "Rolle:" %} {{ role_label }} @@ -44,6 +45,7 @@ {% trans "PDF + E-Mail Workflow bereit" %}
      + {% endif %}
      diff --git a/backend/workflows/views.py b/backend/workflows/views.py index f4330f2..adff632 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -328,6 +328,7 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) @login_required def home(request): config, _ = WorkflowConfig.objects.get_or_create(name='Default') + role_key = get_user_role_key(request.user) return render( request, 'workflows/home.html', @@ -336,6 +337,7 @@ def home(request): 'email_test_mode': is_email_test_mode(), 'workflow_config': config, 'role_label': get_user_role_label(request.user), + 'role_key': role_key, 'portal_app_sections': build_portal_app_sections(request.user), }, ) @@ -364,6 +366,10 @@ def save_portal_app_registry(request): if config.section not in dict(PortalAppConfig.SECTION_CHOICES): config.section = row['default_section'] config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on' + config.visible_to_super_admin = request.POST.get(f'visible_to_super_admin__{key}') == 'on' + config.visible_to_admin = request.POST.get(f'visible_to_admin__{key}') == 'on' + config.visible_to_it_staff = request.POST.get(f'visible_to_it_staff__{key}') == 'on' + config.visible_to_staff = request.POST.get(f'visible_to_staff__{key}') == 'on' try: config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order']) except ValueError: From 7bd03fc86edba78daf8c3de074807a2031c6f5f6 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Thu, 26 Mar 2026 13:58:45 +0100 Subject: [PATCH 06/45] snapshot: preserve company config foundation and staff dashboard access --- backend/locale/en/LC_MESSAGES/django.po | 441 ++++++++++++------ backend/workflows/admin.py | 7 +- backend/workflows/app_registry.py | 15 + backend/workflows/branding.py | 38 +- backend/workflows/forms.py | 39 +- .../migrations/0042_portalcompanyconfig.py | 39 ++ backend/workflows/models.py | 26 ++ backend/workflows/roles.py | 6 +- .../static/workflows/css/app_chrome.css | 23 + .../templates/workflows/app_registry.html | 2 +- .../templates/workflows/base_shell.html | 7 + .../templates/workflows/company_config.html | 120 +++++ .../workflows/requests_dashboard.html | 6 +- backend/workflows/urls.py | 2 + backend/workflows/views.py | 64 ++- 15 files changed, 681 insertions(+), 154 deletions(-) create mode 100644 backend/workflows/migrations/0042_portalcompanyconfig.py create mode 100644 backend/workflows/templates/workflows/company_config.html diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index dfd613d..3a1ed4e 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-26 11:56+0000\n" +"POT-Creation-Date: 2026-03-26 12:55+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/app_registry.py:32 workflows/models.py:277 workflows/models.py:358 +#: workflows/app_registry.py:32 workflows/models.py:303 workflows/models.py:384 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:43 workflows/models.py:278 workflows/models.py:359 +#: workflows/app_registry.py:43 workflows/models.py:304 workflows/models.py:385 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -104,155 +104,167 @@ msgid "PDF Zugriff" msgstr "PDF access" #: workflows/app_registry.py:65 -#: workflows/templates/workflows/branding_settings.html:4 -#: workflows/templates/workflows/branding_settings.html:12 -msgid "Branding" -msgstr "Branding" +#: workflows/templates/workflows/company_config.html:4 +#: workflows/templates/workflows/company_config.html:12 +msgid "Company Config" +msgstr "" #: workflows/app_registry.py:66 -msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." -msgstr "Manage logo, portal name, colors, and PDF letterhead." +msgid "" +"Rechtliche Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " +"pflegen." +msgstr "" #: workflows/app_registry.py:67 workflows/app_registry.py:76 #: workflows/app_registry.py:85 workflows/app_registry.py:94 #: workflows/app_registry.py:103 workflows/app_registry.py:112 #: workflows/app_registry.py:121 workflows/app_registry.py:130 #: workflows/app_registry.py:139 workflows/app_registry.py:148 -#: workflows/app_registry.py:157 +#: workflows/app_registry.py:157 workflows/app_registry.py:166 msgid "Öffnen" msgstr "Open" #: workflows/app_registry.py:74 +#: workflows/templates/workflows/branding_settings.html:4 +#: workflows/templates/workflows/branding_settings.html:12 +msgid "Branding" +msgstr "Branding" + +#: workflows/app_registry.py:75 +msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." +msgstr "Manage logo, portal name, colors, and PDF letterhead." + +#: workflows/app_registry.py:83 #: workflows/templates/workflows/app_registry.html:5 #: workflows/templates/workflows/app_registry.html:13 msgid "App Registry" msgstr "" -#: workflows/app_registry.py:75 +#: workflows/app_registry.py:84 msgid "Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten." msgstr "" -#: workflows/app_registry.py:83 +#: workflows/app_registry.py:92 msgid "Integrationen" msgstr "Integrations" -#: workflows/app_registry.py:84 +#: workflows/app_registry.py:93 msgid "Nextcloud- und E-Mail-Setup." msgstr "Nextcloud and email setup." -#: workflows/app_registry.py:92 +#: workflows/app_registry.py:101 #: workflows/templates/workflows/user_management.html:4 #: workflows/templates/workflows/user_management.html:14 msgid "Benutzer & Rollen" msgstr "Users & roles" -#: workflows/app_registry.py:93 +#: workflows/app_registry.py:102 msgid "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." msgstr "Create users, assign roles, and control access." -#: workflows/app_registry.py:101 workflows/templates/workflows/audit_log.html:4 +#: workflows/app_registry.py:110 workflows/templates/workflows/audit_log.html:4 #: workflows/templates/workflows/audit_log.html:15 msgid "Audit Log" msgstr "" -#: workflows/app_registry.py:102 +#: workflows/app_registry.py:111 msgid "Wichtige Admin-Aktionen nachvollziehen und prüfen." msgstr "" -#: workflows/app_registry.py:110 +#: workflows/app_registry.py:119 #: workflows/templates/workflows/backup_recovery.html:4 #: workflows/templates/workflows/backup_recovery.html:12 msgid "Backup & Recovery" msgstr "Backup & Recovery" -#: workflows/app_registry.py:111 +#: workflows/app_registry.py:120 msgid "Backups erstellen und sicher verifizieren." msgstr "" -#: workflows/app_registry.py:119 +#: workflows/app_registry.py:128 #: workflows/templates/workflows/welcome_emails.html:4 msgid "Welcome E-Mails" msgstr "Welcome Emails" -#: workflows/app_registry.py:120 +#: workflows/app_registry.py:129 msgid "Geplante Welcome Mails verwalten." msgstr "Manage scheduled welcome emails." -#: workflows/app_registry.py:128 +#: workflows/app_registry.py:137 #: workflows/templates/workflows/form_builder.html:4 #: workflows/templates/workflows/form_builder.html:14 msgid "Form Builder" msgstr "Form Builder" -#: workflows/app_registry.py:129 +#: workflows/app_registry.py:138 msgid "Felder, Schritte und Optionen verwalten." msgstr "Manage fields, steps, and options." -#: workflows/app_registry.py:137 +#: workflows/app_registry.py:146 #: workflows/templates/workflows/intro_builder.html:4 #: workflows/templates/workflows/intro_builder.html:17 msgid "Einweisungs-Builder" msgstr "Introduction Builder" -#: workflows/app_registry.py:138 +#: workflows/app_registry.py:147 msgid "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." msgstr "Configure checklist items for the introduction protocol." -#: workflows/app_registry.py:146 workflows/templates/workflows/handbook.html:4 +#: workflows/app_registry.py:155 workflows/templates/workflows/handbook.html:4 #: workflows/templates/workflows/handbook.html:15 msgid "Handbook" msgstr "Handbook" -#: workflows/app_registry.py:147 +#: workflows/app_registry.py:156 msgid "Project wiki and developer documentation in one place." msgstr "Project wiki and developer documentation in one place." -#: workflows/app_registry.py:155 +#: workflows/app_registry.py:164 msgid "Django Admin" msgstr "Django Admin" -#: workflows/app_registry.py:156 +#: workflows/app_registry.py:165 msgid "Vollständige Datenverwaltung." msgstr "Full data management." -#: workflows/app_registry.py:259 +#: workflows/app_registry.py:274 msgid "Nur Platform" msgstr "" -#: workflows/app_registry.py:261 +#: workflows/app_registry.py:276 #: workflows/templates/workflows/app_registry.html:85 msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:267 workflows/models.py:80 +#: workflows/app_registry.py:282 workflows/models.py:106 #: workflows/templates/workflows/app_registry.html:43 #: workflows/templates/workflows/app_registry.html:78 msgid "Apps" msgstr "Apps" -#: workflows/app_registry.py:268 +#: workflows/app_registry.py:283 msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:273 workflows/models.py:81 +#: workflows/app_registry.py:288 workflows/models.py:107 #: workflows/templates/workflows/app_registry.html:44 #: workflows/templates/workflows/app_registry.html:74 msgid "Platform Apps" msgstr "" -#: workflows/app_registry.py:274 +#: workflows/app_registry.py:289 #, fuzzy #| msgid "Konfiguration, Tests und Steuerung." msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:279 workflows/models.py:82 +#: workflows/app_registry.py:294 workflows/models.py:108 #: workflows/templates/workflows/app_registry.html:45 #: workflows/templates/workflows/app_registry.html:76 msgid "Admin Apps" msgstr "Admin Apps" -#: workflows/app_registry.py:280 +#: workflows/app_registry.py:295 msgid "Konfiguration, Tests und Steuerung." msgstr "Configuration, tests, and controls." @@ -347,11 +359,11 @@ msgstr "Role:" msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:154 workflows/views.py:622 +#: workflows/forms.py:154 workflows/views.py:680 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:156 workflows/views.py:625 +#: workflows/forms.py:156 workflows/views.py:683 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" @@ -435,261 +447,321 @@ msgstr "" msgid "Das Favicon darf maximal 2 MB groß sein." msgstr "" -#: workflows/forms.py:382 workflows/forms.py:567 +#: workflows/forms.py:267 +#, fuzzy +#| msgid "Firmenname" +msgid "Rechtlicher Firmenname" +msgstr "Company name" + +#: workflows/forms.py:268 +msgid "Straße und Hausnummer" +msgstr "" + +#: workflows/forms.py:269 +msgid "Postleitzahl" +msgstr "" + +#: workflows/forms.py:270 +msgid "Stadt" +msgstr "" + +#: workflows/forms.py:271 +msgid "Land" +msgstr "" + +#: workflows/forms.py:272 workflows/templates/workflows/base_shell.html:27 +msgid "Website" +msgstr "" + +#: workflows/forms.py:273 +msgid "Impressum-URL" +msgstr "" + +#: workflows/forms.py:274 +msgid "Datenschutz-URL" +msgstr "" + +#: workflows/forms.py:275 +msgid "HR-Kontakt" +msgstr "" + +#: workflows/forms.py:276 +msgid "IT-Kontakt" +msgstr "" + +#: workflows/forms.py:277 +#, fuzzy +#| msgid "Operations" +msgid "Operations-Kontakt" +msgstr "Operations" + +#: workflows/forms.py:278 +msgid "Zentrale Telefonnummer" +msgstr "" + +#: workflows/forms.py:279 +msgid "USt-IdNr." +msgstr "" + +#: workflows/forms.py:280 +msgid "Register- oder Handelsnummer" +msgstr "" + +#: workflows/forms.py:419 workflows/forms.py:604 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:404 workflows/forms.py:581 +#: workflows/forms.py:441 workflows/forms.py:618 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:489 +#: workflows/forms.py:526 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " "(frühestens %(date)s)." msgstr "" -#: workflows/models.py:155 workflows/views.py:200 +#: workflows/models.py:181 workflows/views.py:200 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:156 workflows/views.py:201 +#: workflows/models.py:182 workflows/views.py:201 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:157 workflows/models.py:472 workflows/views.py:202 +#: workflows/models.py:183 workflows/models.py:498 workflows/views.py:202 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:158 workflows/models.py:412 +#: workflows/models.py:184 workflows/models.py:438 #: workflows/templates/workflows/backup_recovery.html:70 #: workflows/templates/workflows/requests_dashboard.html:222 #: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:203 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:165 +#: workflows/models.py:191 msgid "Herr" msgstr "" -#: workflows/models.py:165 +#: workflows/models.py:191 msgid "Frau" msgstr "" -#: workflows/models.py:165 +#: workflows/models.py:191 msgid "Divers" msgstr "" -#: workflows/models.py:175 +#: workflows/models.py:201 msgid "befristet" msgstr "" -#: workflows/models.py:175 +#: workflows/models.py:201 msgid "unbefristet" msgstr "" -#: workflows/models.py:238 +#: workflows/models.py:264 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/models.py:239 +#: workflows/models.py:265 msgid "Geräte" msgstr "" -#: workflows/models.py:240 +#: workflows/models.py:266 msgid "Software" msgstr "" -#: workflows/models.py:241 +#: workflows/models.py:267 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:242 +#: workflows/models.py:268 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:243 +#: workflows/models.py:269 msgid "Ressourcen" msgstr "" -#: workflows/models.py:244 +#: workflows/models.py:270 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:270 +#: workflows/models.py:296 msgid "Automatisch" msgstr "" -#: workflows/models.py:271 workflows/views.py:95 +#: workflows/models.py:297 workflows/views.py:95 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:272 workflows/views.py:96 +#: workflows/models.py:298 workflows/views.py:96 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:273 workflows/views.py:97 +#: workflows/models.py:299 workflows/views.py:97 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:274 workflows/views.py:98 +#: workflows/models.py:300 workflows/views.py:98 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:316 +#: workflows/models.py:342 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:317 +#: workflows/models.py:343 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:318 +#: workflows/models.py:344 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:319 +#: workflows/models.py:345 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:320 +#: workflows/models.py:346 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:321 +#: workflows/models.py:347 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:322 +#: workflows/models.py:348 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:323 +#: workflows/models.py:349 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:324 +#: workflows/models.py:350 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:325 +#: workflows/models.py:351 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:326 +#: workflows/models.py:352 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:362 +#: workflows/models.py:388 msgid "Immer" msgstr "" -#: workflows/models.py:363 workflows/models.py:441 +#: workflows/models.py:389 workflows/models.py:467 msgid "Enthält" msgstr "" -#: workflows/models.py:364 workflows/models.py:442 +#: workflows/models.py:390 workflows/models.py:468 msgid "Ist gleich" msgstr "" -#: workflows/models.py:365 +#: workflows/models.py:391 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:366 +#: workflows/models.py:392 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:408 +#: workflows/models.py:434 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:409 +#: workflows/models.py:435 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:410 +#: workflows/models.py:436 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:411 +#: workflows/models.py:437 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:434 workflows/tasks.py:576 +#: workflows/models.py:460 workflows/tasks.py:576 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:435 workflows/tasks.py:577 +#: workflows/models.py:461 workflows/tasks.py:577 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:436 workflows/tasks.py:578 +#: workflows/models.py:462 workflows/tasks.py:578 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:437 workflows/tasks.py:579 +#: workflows/models.py:463 workflows/tasks.py:579 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:440 +#: workflows/models.py:466 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:443 +#: workflows/models.py:469 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:444 +#: workflows/models.py:470 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:471 +#: workflows/models.py:497 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:491 +#: workflows/models.py:517 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:492 +#: workflows/models.py:518 msgid "S3" msgstr "" -#: workflows/models.py:493 +#: workflows/models.py:519 msgid "NFS" msgstr "" @@ -1312,7 +1384,7 @@ msgstr "Delete this backup bundle?" #: 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:286 +#: workflows/templates/workflows/requests_dashboard.html:288 #: workflows/templates/workflows/user_management.html:127 #: workflows/templates/workflows/welcome_emails.html:70 msgid "Löschen" @@ -1324,28 +1396,36 @@ msgstr "Delete" msgid "Noch keine Backup-Bundles vorhanden." msgstr "No backup bundles available yet." -#: workflows/templates/workflows/base_shell.html:31 +#: workflows/templates/workflows/base_shell.html:28 +msgid "Impressum" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:29 +msgid "Datenschutz" +msgstr "" + +#: workflows/templates/workflows/base_shell.html:38 msgid "Bitte bestätigen" msgstr "" -#: workflows/templates/workflows/base_shell.html:35 +#: workflows/templates/workflows/base_shell.html:42 #: workflows/templates/workflows/welcome_emails.html:134 msgid "Abbrechen" msgstr "Cancel" -#: workflows/templates/workflows/base_shell.html:36 +#: workflows/templates/workflows/base_shell.html:43 msgid "Bestätigen" msgstr "" -#: workflows/templates/workflows/base_shell.html:43 +#: workflows/templates/workflows/base_shell.html:50 msgid "Bitte warten" msgstr "Please wait" -#: workflows/templates/workflows/base_shell.html:44 +#: workflows/templates/workflows/base_shell.html:51 msgid "Aktion läuft" msgstr "Action in progress" -#: workflows/templates/workflows/base_shell.html:45 +#: workflows/templates/workflows/base_shell.html:52 msgid "Die Aktion wird im aktuellen Tab ausgeführt." msgstr "The action is running in the current tab." @@ -1442,6 +1522,64 @@ msgstr "" msgid "Branding speichern" msgstr "Save branding" +#: workflows/templates/workflows/company_config.html:13 +msgid "" +"Strukturierte Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " +"zentral pflegen." +msgstr "" + +#: workflows/templates/workflows/company_config.html:23 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmenprofil" +msgstr "Company name" + +#: workflows/templates/workflows/company_config.html:24 +msgid "Rechtlicher Name und zentrale Stammdaten der Firma." +msgstr "" + +#: workflows/templates/workflows/company_config.html:48 +msgid "Adresse & Register" +msgstr "" + +#: workflows/templates/workflows/company_config.html:49 +msgid "Anschrift sowie optionale Register- und Steuerangaben." +msgstr "" + +#: workflows/templates/workflows/company_config.html:77 +msgid "Kontaktpunkte" +msgstr "" + +#: workflows/templates/workflows/company_config.html:78 +msgid "Zentrale Ansprechpartner für HR, IT und Operations." +msgstr "" + +#: workflows/templates/workflows/company_config.html:98 +msgid "Recht & Öffentlichkeit" +msgstr "" + +#: workflows/templates/workflows/company_config.html:99 +msgid "Öffentliche Links für Website, Impressum und Datenschutz." +msgstr "" + +#: workflows/templates/workflows/company_config.html:111 +msgid "" +"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " +"verwendet werden." +msgstr "" + +#: workflows/templates/workflows/company_config.html:115 +msgid "" +"Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte " +"Firmendaten, nicht um visuelle Gestaltung." +msgstr "" + +#: workflows/templates/workflows/company_config.html:116 +#, fuzzy +#| msgid "Optionen speichern" +msgid "Firmenkonfiguration speichern" +msgstr "Save options" + #: workflows/templates/workflows/form_builder.html:15 msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." msgstr "Sort fields by drag and drop and group them by step." @@ -2290,7 +2428,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:875 +#: workflows/views.py:933 msgid "Vertragsbeginn" msgstr "Contract start" @@ -2708,25 +2846,25 @@ msgstr "Generate PDF" msgid "Nicht relevant" msgstr "Not relevant" -#: workflows/templates/workflows/requests_dashboard.html:276 +#: workflows/templates/workflows/requests_dashboard.html:277 msgid "Timeline" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:278 +#: workflows/templates/workflows/requests_dashboard.html:280 msgid "Eintrag erneut verarbeiten?" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:280 +#: workflows/templates/workflows/requests_dashboard.html:282 msgid "Erneut versuchen" msgstr "" -#: workflows/templates/workflows/requests_dashboard.html:284 +#: workflows/templates/workflows/requests_dashboard.html:286 #, fuzzy #| msgid "Option wirklich löschen?" msgid "Eintrag wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/requests_dashboard.html:294 +#: workflows/templates/workflows/requests_dashboard.html:296 msgid "Noch keine Vorgänge vorhanden." msgstr "No requests available yet." @@ -2955,7 +3093,7 @@ msgstr "Devices, software, and access" msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:129 workflows/views.py:961 workflows/views.py:966 +#: workflows/views.py:129 workflows/views.py:1019 workflows/views.py:1024 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." @@ -3233,17 +3371,32 @@ msgstr "User could not be created. Please check the input." msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:595 +#: workflows/views.py:610 +#, fuzzy +#| msgid "" +#| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." +msgid "" +"Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die " +"Eingaben." +msgstr "User could not be created. Please check the input." + +#: workflows/views.py:637 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Firmenkonfiguration wurde gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:653 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:608 +#: workflows/views.py:666 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:630 +#: workflows/views.py:688 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3254,14 +3407,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:633 +#: workflows/views.py:691 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:636 +#: workflows/views.py:694 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3272,7 +3425,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:639 +#: workflows/views.py:697 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3283,18 +3436,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:656 +#: workflows/views.py:714 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:678 +#: workflows/views.py:736 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:690 +#: workflows/views.py:748 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3304,7 +3457,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:693 +#: workflows/views.py:751 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3314,7 +3467,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:696 +#: workflows/views.py:754 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3323,7 +3476,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:699 +#: workflows/views.py:757 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -3332,121 +3485,121 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:712 +#: workflows/views.py:770 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:799 +#: workflows/views.py:857 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:801 +#: workflows/views.py:859 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:817 +#: workflows/views.py:875 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:819 +#: workflows/views.py:877 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:835 +#: workflows/views.py:893 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:837 +#: workflows/views.py:895 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:863 +#: workflows/views.py:921 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:865 +#: workflows/views.py:923 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:877 +#: workflows/views.py:935 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:887 +#: workflows/views.py:945 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:889 +#: workflows/views.py:947 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:898 +#: workflows/views.py:956 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:924 +#: workflows/views.py:982 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:936 +#: workflows/views.py:994 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:975 +#: workflows/views.py:1033 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1018 +#: workflows/views.py:1076 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1020 +#: workflows/views.py:1078 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1022 +#: workflows/views.py:1080 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1250 +#: workflows/views.py:1308 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1267 +#: workflows/views.py:1325 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1296 +#: workflows/views.py:1354 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1310 +#: workflows/views.py:1368 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1323 +#: workflows/views.py:1381 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index e1e0660..21860b3 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig @admin.register(EmployeeProfile) @@ -25,6 +25,11 @@ class PortalBrandingAdmin(admin.ModelAdmin): list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at') +@admin.register(PortalCompanyConfig) +class PortalCompanyConfigAdmin(admin.ModelAdmin): + list_display = ('name', 'legal_company_name', 'website_url', 'hr_contact_email', 'it_contact_email', 'updated_at') + + @admin.register(PortalAppConfig) class PortalAppConfigAdmin(admin.ModelAdmin): list_display = ( diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py index 781ad8b..d65d47b 100644 --- a/backend/workflows/app_registry.py +++ b/backend/workflows/app_registry.py @@ -58,6 +58,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = ( accent='APP', tags=(_('Suche'), _('Status'), _('PDF Zugriff')), ), + AppDefinition( + key='company_config', + section=PortalAppConfig.SECTION_PLATFORM, + route_name='portal_company_config_page', + title=_('Company Config'), + description=_('Rechtliche Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks pflegen.'), + action_label=_('Öffnen'), + capability='manage_company_config', + ), AppDefinition( key='branding', section=PortalAppConfig.SECTION_PLATFORM, @@ -185,6 +194,12 @@ DEFAULT_ROLE_VISIBILITY = { ROLE_IT_STAFF: False, ROLE_STAFF: False, }, + 'company_config': { + ROLE_SUPER_ADMIN: False, + ROLE_ADMIN: False, + ROLE_IT_STAFF: False, + ROLE_STAFF: False, + }, 'app_registry': { ROLE_SUPER_ADMIN: False, ROLE_ADMIN: False, diff --git a/backend/workflows/branding.py b/backend/workflows/branding.py index 94759b0..94c2022 100644 --- a/backend/workflows/branding.py +++ b/backend/workflows/branding.py @@ -7,7 +7,7 @@ from django.conf import settings from django.templatetags.static import static from django.utils.translation import get_language -from .models import PortalBranding +from .models import PortalBranding, PortalCompanyConfig def get_portal_branding() -> PortalBranding: @@ -32,6 +32,26 @@ def get_portal_branding() -> PortalBranding: return branding +def get_portal_company_config() -> PortalCompanyConfig: + company_config, _ = PortalCompanyConfig.objects.get_or_create( + name='Default', + defaults={ + 'legal_company_name': 'TUBCO GmbH', + 'country': 'Deutschland', + 'website_url': '', + 'imprint_url': '', + 'privacy_url': '', + 'hr_contact_email': '', + 'it_contact_email': '', + 'operations_contact_email': '', + 'phone_number': '', + 'vat_id': '', + 'registration_number': '', + }, + ) + return company_config + + def get_company_email_domain() -> str: branding = get_portal_branding() domain = (branding.company_domain or '').strip().lower().lstrip('@') @@ -72,6 +92,7 @@ def get_portal_letterhead_path() -> Path: def get_branding_context() -> dict[str, object]: branding = get_portal_branding() + company_config = get_portal_company_config() lang = (get_language() or branding.default_language or 'de').split('-')[0] footer_text = (branding.footer_text_en or '').strip() if lang == 'en' else '' legal_notice = (branding.legal_notice_en or '').strip() if lang == 'en' else '' @@ -97,6 +118,21 @@ def get_branding_context() -> dict[str, object]: 'portal_has_custom_logo': bool(branding.logo_image), 'portal_has_custom_letterhead': bool(branding.pdf_letterhead), 'portal_has_custom_favicon': bool(branding.favicon_image), + 'portal_company_config': company_config, + 'portal_company_legal_name': company_config.legal_company_name or branding.company_name, + 'portal_company_street': company_config.street_address, + 'portal_company_postal_code': company_config.postal_code, + 'portal_company_city': company_config.city, + 'portal_company_country': company_config.country, + 'portal_company_website_url': company_config.website_url, + 'portal_company_imprint_url': company_config.imprint_url, + 'portal_company_privacy_url': company_config.privacy_url, + 'portal_company_hr_contact_email': company_config.hr_contact_email, + 'portal_company_it_contact_email': company_config.it_contact_email, + 'portal_company_operations_contact_email': company_config.operations_contact_email, + 'portal_company_phone_number': company_config.phone_number, + 'portal_company_vat_id': company_config.vat_id, + 'portal_company_registration_number': company_config.registration_number, } diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 5aaabc4..74fdf94 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -8,7 +8,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy from .branding import get_company_email_domain from .form_builder import apply_form_field_config -from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, WorkflowConfig +from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role @@ -244,6 +244,43 @@ class PortalBrandingForm(forms.ModelForm): return favicon +class PortalCompanyConfigForm(forms.ModelForm): + class Meta: + model = PortalCompanyConfig + fields = [ + 'legal_company_name', + 'street_address', + 'postal_code', + 'city', + 'country', + 'website_url', + 'imprint_url', + 'privacy_url', + 'hr_contact_email', + 'it_contact_email', + 'operations_contact_email', + 'phone_number', + 'vat_id', + 'registration_number', + ] + labels = { + 'legal_company_name': gettext_lazy('Rechtlicher Firmenname'), + 'street_address': gettext_lazy('Straße und Hausnummer'), + 'postal_code': gettext_lazy('Postleitzahl'), + 'city': gettext_lazy('Stadt'), + 'country': gettext_lazy('Land'), + 'website_url': gettext_lazy('Website'), + 'imprint_url': gettext_lazy('Impressum-URL'), + 'privacy_url': gettext_lazy('Datenschutz-URL'), + 'hr_contact_email': gettext_lazy('HR-Kontakt'), + 'it_contact_email': gettext_lazy('IT-Kontakt'), + 'operations_contact_email': gettext_lazy('Operations-Kontakt'), + 'phone_number': gettext_lazy('Zentrale Telefonnummer'), + 'vat_id': gettext_lazy('USt-IdNr.'), + 'registration_number': gettext_lazy('Register- oder Handelsnummer'), + } + + class OnboardingRequestForm(forms.ModelForm): first_name = forms.CharField(label='Vorname', required=False) last_name = forms.CharField(label='Nachname', required=False) diff --git a/backend/workflows/migrations/0042_portalcompanyconfig.py b/backend/workflows/migrations/0042_portalcompanyconfig.py new file mode 100644 index 0000000..a30276b --- /dev/null +++ b/backend/workflows/migrations/0042_portalcompanyconfig.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.5 on 2026-03-26 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0041_portalappconfig_visible_to_admin_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='PortalCompanyConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default', max_length=80, unique=True)), + ('legal_company_name', models.CharField(blank=True, default='', max_length=255)), + ('street_address', models.CharField(blank=True, default='', max_length=255)), + ('postal_code', models.CharField(blank=True, default='', max_length=50)), + ('city', models.CharField(blank=True, default='', max_length=120)), + ('country', models.CharField(blank=True, default='Deutschland', max_length=120)), + ('website_url', models.URLField(blank=True, default='')), + ('imprint_url', models.URLField(blank=True, default='')), + ('privacy_url', models.URLField(blank=True, default='')), + ('hr_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('it_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('operations_contact_email', models.EmailField(blank=True, default='', max_length=254)), + ('phone_number', models.CharField(blank=True, default='', max_length=80)), + ('vat_id', models.CharField(blank=True, default='', max_length=80)), + ('registration_number', models.CharField(blank=True, default='', max_length=120)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Portal Company Config', + 'verbose_name_plural': 'Portal Company Config', + }, + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 5b0852c..841bbe4 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -72,6 +72,32 @@ class PortalBranding(models.Model): return self.portal_title or self.company_name or self.name +class PortalCompanyConfig(models.Model): + name = models.CharField(max_length=80, default='Default', unique=True) + legal_company_name = models.CharField(max_length=255, blank=True, default='') + street_address = models.CharField(max_length=255, blank=True, default='') + postal_code = models.CharField(max_length=50, blank=True, default='') + city = models.CharField(max_length=120, blank=True, default='') + country = models.CharField(max_length=120, blank=True, default='Deutschland') + website_url = models.URLField(blank=True, default='') + imprint_url = models.URLField(blank=True, default='') + privacy_url = models.URLField(blank=True, default='') + hr_contact_email = models.EmailField(blank=True, default='') + it_contact_email = models.EmailField(blank=True, default='') + operations_contact_email = models.EmailField(blank=True, default='') + phone_number = models.CharField(max_length=80, blank=True, default='') + vat_id = models.CharField(max_length=80, blank=True, default='') + registration_number = models.CharField(max_length=120, blank=True, default='') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Portal Company Config' + verbose_name_plural = 'Portal Company Config' + + def __str__(self) -> str: + return self.legal_company_name or self.name + + class PortalAppConfig(models.Model): SECTION_APP = 'app' SECTION_PLATFORM = 'platform' diff --git a/backend/workflows/roles.py b/backend/workflows/roles.py index e8841e5..437896d 100644 --- a/backend/workflows/roles.py +++ b/backend/workflows/roles.py @@ -29,8 +29,10 @@ ROLE_LABELS = { CAPABILITIES = { 'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN}, 'manage_product_branding': {ROLE_PLATFORM_OWNER}, + 'manage_company_config': {ROLE_PLATFORM_OWNER}, 'manage_app_registry': {ROLE_PLATFORM_OWNER}, - 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, + 'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF}, + 'view_request_timeline': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, 'run_intro_session': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, 'generate_intro_pdfs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, 'retry_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF}, @@ -124,9 +126,11 @@ def template_role_context(user) -> dict[str, object]: 'role_key': role_key, 'role_label': str(ROLE_LABELS[role_key]), 'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'), + 'can_manage_company_config': user_has_capability(user, 'manage_company_config'), 'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'), 'can_manage_users': user_has_capability(user, 'manage_users'), 'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'), + 'can_view_request_timeline': user_has_capability(user, 'view_request_timeline'), 'can_run_intro_session': user_has_capability(user, 'run_intro_session'), 'can_generate_intro_pdfs': user_has_capability(user, 'generate_intro_pdfs'), 'can_retry_requests': user_has_capability(user, 'retry_requests'), diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index 8ba66d8..6179816 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -141,6 +141,29 @@ line-height: 1.5; } +.app-site-footer-links { + margin-top: 8px; + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.app-site-footer-links a { + color: #1f3a5f; + font-size: 12px; + font-weight: 700; + text-decoration: none; + transition: + color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease); +} + +.app-site-footer-links a:hover { + color: var(--app-brand-blue); + transform: translateY(-1px); +} + @media (max-width: 900px) { .app-header, .app-header-in-shell { diff --git a/backend/workflows/templates/workflows/app_registry.html b/backend/workflows/templates/workflows/app_registry.html index d34155e..5c07e5a 100644 --- a/backend/workflows/templates/workflows/app_registry.html +++ b/backend/workflows/templates/workflows/app_registry.html @@ -197,7 +197,7 @@ {% endblock %} -{% block extra_js %} +{% block extra_scripts %} +{% endblock %} diff --git a/backend/workflows/templates/workflows/includes/app_header.html b/backend/workflows/templates/workflows/includes/app_header.html index 6f57087..7e57a34 100644 --- a/backend/workflows/templates/workflows/includes/app_header.html +++ b/backend/workflows/templates/workflows/includes/app_header.html @@ -23,10 +23,14 @@
      diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py index d9505c1..9716e2e 100644 --- a/backend/workflows/tests/test_account_ui.py +++ b/backend/workflows/tests/test_account_ui.py @@ -1,6 +1,8 @@ from django.contrib.auth import get_user_model from django.test import Client, TestCase +from workflows.models import UserProfile + class AccountUISmokeTests(TestCase): def setUp(self): @@ -24,3 +26,32 @@ class AccountUISmokeTests(TestCase): response = self.client.get('/accounts/password_change/', HTTP_HOST='localhost') self.assertEqual(response.status_code, 200) self.assertContains(response, 'Aktuelles Passwort') + + def test_user_profile_is_created_automatically(self): + self.assertTrue(UserProfile.objects.filter(user=self.user).exists()) + + def test_account_profile_details_can_be_updated(self): + response = self.client.post( + '/account/', + { + 'account_form': 'details', + 'first_name': 'Updated', + 'last_name': 'User', + 'email': 'updated@example.com', + 'phone_number': '030 123456', + 'mobile_number': '0176 123456', + 'job_title': 'IT Manager', + 'department': 'IT', + 'location': 'Berlin', + 'contact_notes': 'Available in the mornings', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + profile = self.user.profile + self.assertEqual(self.user.first_name, 'Updated') + self.assertEqual(self.user.email, 'updated@example.com') + self.assertEqual(profile.phone_number, '030 123456') + self.assertEqual(profile.job_title, 'IT Manager') diff --git a/backend/workflows/views.py b/backend/workflows/views.py index a778cd1..6b9611a 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -33,7 +33,7 @@ from .backup_ops import ( verify_backup_bundle, ) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired -from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm +from .forms import AccountAvatarForm, AccountDetailsForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, LOCKED_FIELD_RULES, @@ -42,7 +42,7 @@ from .form_builder import ( ONBOARDING_PAGE_ORDER, ensure_form_field_configs, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserProfile, WorkflowConfig from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud @@ -128,11 +128,36 @@ def healthz(request): @login_required def account_profile_page(request): + profile, created = UserProfile.objects.get_or_create(user=request.user) + avatar_form = AccountAvatarForm(instance=profile) + details_form = AccountDetailsForm(user=request.user, profile=profile) + account_edit_open = False + if request.method == 'POST': + form_kind = (request.POST.get('account_form') or '').strip() + if form_kind == 'avatar': + avatar_form = AccountAvatarForm(request.POST, request.FILES, instance=profile) + if avatar_form.is_valid(): + avatar_form.save() + messages.success(request, _('Profilbild gespeichert.')) + return redirect('account_profile_page') + messages.error(request, _('Profilbild konnte nicht gespeichert werden.')) + elif form_kind == 'details': + account_edit_open = True + details_form = AccountDetailsForm(request.POST, user=request.user, profile=profile) + if details_form.is_valid(): + details_form.save() + messages.success(request, _('Profildaten gespeichert.')) + return redirect('account_profile_page') + messages.error(request, _('Profildaten konnten nicht gespeichert werden.')) return render( request, 'workflows/account_profile.html', { 'account_user': request.user, + 'account_user_profile': profile, + 'avatar_form': avatar_form, + 'details_form': details_form, + 'account_edit_open': account_edit_open, 'role_label': get_user_role_label(request.user), }, ) From c6794884372e633f41770a1606836027b82804ca Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 02:46:40 +0100 Subject: [PATCH 13/45] snapshot: preserve totp account security baseline --- PRODUCTIZATION_ROADMAP.md | 33 + backend/locale/en/LC_MESSAGES/django.mo | Bin 35906 -> 35857 bytes backend/locale/en/LC_MESSAGES/django.po | 992 ++++++++++-------- backend/workflows/app_registry.py | 10 + backend/workflows/forms.py | 99 ++ .../0049_userprofile_totp_fields.py | 26 + backend/workflows/models.py | 16 + .../static/workflows/css/account.css | 28 + .../static/workflows/css/admin_tools.css | 27 +- .../templates/workflows/account_profile.html | 88 ++ .../templates/workflows/app_registry.html | 454 +++++--- .../templates/workflows/auth/login.html | 10 +- .../workflows/branding_settings.html | 258 +++-- .../templates/workflows/company_config.html | 167 ++- .../workflows/templates/workflows/home.html | 6 +- backend/workflows/tests/test_account_ui.py | 58 + backend/workflows/totp.py | 49 + backend/workflows/views.py | 188 +++- 18 files changed, 1723 insertions(+), 786 deletions(-) create mode 100644 backend/workflows/migrations/0049_userprofile_totp_fields.py create mode 100644 backend/workflows/totp.py diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md index c05a29e..cb4480a 100644 --- a/PRODUCTIZATION_ROADMAP.md +++ b/PRODUCTIZATION_ROADMAP.md @@ -16,6 +16,7 @@ Current branch roles: 3. Start as single-tenant configurable, not full multi-tenant. 4. Make branding and document identity admin-managed, not code-managed. 5. Add new business apps only after the core platform layer is standardized. +6. Prefer inline editing for lightweight profile and configuration data, but keep explicit forms for sensitive or high-risk settings. ## Product Layers @@ -213,3 +214,35 @@ This is the first productization slice because it gives: - keep migrations backward-compatible - update both wiki and developer handbook for every architecture change - snapshot at the end of each major phase + +## Shared UI Pattern: Inline Editing + +Use inline editing as a platform pattern where it improves speed without weakening clarity or safety. + +Good candidates: + +- user profile and contact data +- company config sections +- branding text and non-sensitive metadata +- low-risk app-registry metadata + +Do not use it by default for: + +- credentials and secrets +- integrations with side effects +- destructive actions +- multi-step workflow forms +- settings that need heavy validation or confirmation + +Preferred implementation style: + +- section-level inline editing +- explicit `Bearbeiten`, `Speichern`, `Abbrechen` +- no noisy per-field autosave +- clear view mode and edit mode separation + +Reason: + +- keeps Workdock faster and more product-grade +- avoids large admin-style forms for simple edits +- still preserves reliable validation and safer change boundaries diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index b04c453eb33e3cc5418cc628d73ad740258afb9f..612fd69a6fb861f051063ff5331277fe4bbcfe36 100644 GIT binary patch delta 9559 zcmYk>33yId9>?(;LC7MOkVQx$B(X*!B=$%nA|iIRORP~st3(p?ja?CH>!7qs?UWWR zX^U2AJF3+x#@O1SYFcYsoi1rR-`{(V=W(CspU?T9d+xnwyZ7z#!J2Z**OYTztzLe$ z!||?%21~j^lDJQ1If!A6OYZqaDWw{m>hOkv=)~u`0%6HGBdqVL#M$!>qaJPd*t# zaJH@AfECEMq3*i}EAo8j5CuKpDC&l%Q4P;yI$p-=*dWH3gt6q^FaW1x3ci4P@L6n& z*KHogv?P(oVhzm2nm7Y%^L%H8J@Go~K_6fxJcpI>DwfAvs3o|AzSw|uq3Fb8QyhYE zxCr&YchL(UqGs+-)XY_BZ1Qk)X^P?~Xe5~!h&dRJg;*EYp${I$ig*Hh~y>9e=@0?AXjq?Gp4Q z{}}avbC`;utV;*X!7SW}n#p_UkH4Y@P>G$XrSNag{HqX2g=QcTwKkd75y&oe3a~4# zM?K&sYBx8ax9WIjtcBTF2d7~$uEuG&7pq}3qhc&hYwV3(h}-oP6ZXbatYa;tcjd*YCC>tCgA3 z0@MiRp{8y#_Q2h!sVl>p=*a?C!C=%#V^A}df|}|vSQYb8n{Wa8;4<{amoQN8|5gh6 zfE+>f>>Jcf_^^e;u`O1|A*hj0!Wy^W5)voQ$D31GS6SqBh%6Y=qab8CFR)_3g11 zc@A>QDM0P1L#XR+p`PR0$z^tLSSPdQtx;>#(VAiHhXK@Qqi#GElW_@Zm!Gy?MGfSh z^)IVmirGt{sOu9@d#Ia>0&k$x6Se8)qdpW%P_Nm`s3|{yy77Ccj(m#T+xf=kkx#H8 z$&*l1pN(3&k5M!E6>0`=p&nd;kA#-cO4A| zsSQBoeNhh@i)uF=HGnm!`@W8v*%PSyo`9W`}>Q6nor zy(V)}Yqi4Gzhdimp{DjQssm?G9lD5m&~1!Ck8Y;E5vqNA)TZr=uBH@5Q>ct9Q8(Iv z6>%$SCib8goa~i&Nd9}Zf@8VbzuT(NiwhzjEhGt?FoQH+D3jMH3PqPG}sOw@; z18(E86{)DH?tvOXF8bq4)RdNBA6$ic&@Ehp_i+S1-^+YIZlgNP7=lBOGNKI1^BtZ8~aX?_*uOfa=gMsO!A?nU2@M2=Y3p z8R&?b$u!jWr9Y0td@R=c|Fx~i>2E%br%)H1LA}RcV*~shlQH~B^Zw>w0Qqrjg8xFT zb%grercP$TPrjWGk&E+2KHji|NU zj^X&B&A-L^3s@-DL09K$LxEa-fgQ(}Zj#CJva2|v3E{332 zj@jMyP!}YkZrB;yU?xW60(*WhYO}tFnz>If9)CmKuL)l%-KPtxgZ+>haXE`9=rvk} z8p#f9h-a`l{*KAmc%-@U1k{7pVvW&>M?TzX7LFuk(4-0B)j|?v7g* zugPe$X=Os;$V0}MT^)zod_8aqjzewAtEeTsgPk#S ztmAa&`OXLmdR_LR*5ndu2JWJ65R_|1RDy}*U!yix&^WUMk*MptVLQx6ZNmMirTWgE zulJOB4ZC9}>hsXmkiuRHdc7`U9lVK}y7J>qPyMh0c^}j!8i-oU@u-m&VqIK`n(8;v z8$ZB`cp59?SE%P)MQzF-#@k$`Pms7GWGNLG6_zsMqc+&cVug z=7A;XNxlYsa6K-8pbUGV=hJ2{^g~TuE@}jm zQLkw+hT~e)^`)q#JA-O>7t3QAYNQ^|m``zK)Y3LawR0s<(C$saDmc)Zi)uK{=EXK& zhU(B7n{UD%v zt$74$id$hQ4#X(TxAhxPujMh+fG%Nk{1LT8kyFeUa{%&SX9foGd}kR2P0@B#NA{t5 ze9U?Z1IW*#ruqleNIj;ScIl`Q^|5AK^H5Viz!0p1*=)$nT*#*q@sO;Zvvw&$0PZREM{r z27aiB_16vSPB&k&IIKzD4FfR;)qz5M4i}?t=sm++R}-}aF{t`DR0rDG`kojQI(QV-{xfWa zmrygKf8*8tqLAxcPAmm|P}*PurkWE@A*w@jF&G!4M!W@^;X9~~e~`#nHi_XlbZ1k5)BjG52;tLLq$&{QQ`yP~GDH)^xxpl-Me zHNwNFCHow8-8JhitVjL;)scV&=03rw>tb*ac0fKA&e{dc{{RX#7Mjg80rlX?sGiP3 zy+*6CExv)e!5vfw(w{f~?l%CH&%+_O+U7r^W~SUC(;9 zi3-FxceQ!H2ieBGsGCChpgkw<60!EYKR!j?2(`vFZ2hy?ns|@8=C+QaGmNs9pa=H$ zVEubi*kv0uX-iMmZV zlGsc6Wz?~Z_?+OMznrfL9V>{*#COD#w0-)%Tu0(VCHDB)dLKhwr|v>~FYpNo7XoH)uU_&PpA=-5s867Lf0iBckvb3fvBLWdu5#9cCfCU8EL;&!5) zsdBmh1KL)SpG<}O_fW-18v78Dl>cYXjlg{Bp0&A{W6Lk%eqt%-y>JsAB%&yPPs9@g z2^|I0?<4+Y_n*76AAcXXOXjys#dm0=<5OZU@j9`Nx-cxoW<)z;H=(6miK~f6M>d6q z#5P+u%br+{b*SsFi~dbiC23$Asm(&l+ljf4)%B(R2c6{TLewD^bAB8#iD+Tlo>v2o zLh@8%GI7sTIny!Jwp&Hn)r(}dJ-L?hHgo|=4jv>ks69^CL+7ho2BM2SO z8Qg!r;%{B*=i>~bGtr2cZQFiJd53LfkEyssJVkjl>Nl+)<>44Z3?agamP99F z4d-<9#r?z<%Jqq4TR%!CIhxx1cO{hHfJDNZ_=n#AAyj@rqT>+d9fTiInRs*zq&$;k zA@L&R^+X)yS$Nep+KIz#c@S3OoQ~?)hX}U0spa2|D0pi9|9(`1Z_ubQF_sukT%>NJ zJ-^QSrS&YP5eYWWroE1*@uVrb|AvsK5NF6v+4^o6P3ZZ~NKU?uK}0Cgfm}y8PA9q% zTW#G()bAr+Py>5RNmiKs`6C!dEps$iihI^{eX^G&BBlc-PZBsvn0 dj<+eNlMEo{?~iD3%X@!l^D7Day+*f<{yz<}PYVD5 delta 9600 zcmYk>30&4i9>?)Nf}nr`;(-U3B6y*Q;DI*^d84R#p(09%3W{jr`Dk8=wO(1?S>|Gz z_u1yPnU>|zW~FXYn&nZhX|}7C>3+V<@Ot@wy?)PoX8y;_{AT{oL$+*HnGaW$aeZ6K zW2M7!#LaPPp+`l>=~KpW?$lJP|^@FJA9zjp~cRrz@4$h+O-(HF2+RMi0b${ zw#Emx-h{NYq~0C_u>h;!D_E2Mowx0Yqo|I)z;gH_mdCs3fsas2@E7`GJnKTyNyKOz zhfQ$}s>3trg=HI>%vC^TF2vSj(4`c0q@bCkVKvOdFkFNoxD|cyGHZcWC3ahZ(%H6z%J<7*l~Jb3M#eh z&=)VEI`|PgV*G@OcCFdb{hnGDUvO4JV^V{%R-tK$5D%1C6qxqmEjtFsz;L!GmzC2>8V(3e7R zbJLKAO6^+I47Q+BcnVYSJSugeEN~T!#ERGkHPh~>Ol6`{y$JnrHEJ_%MIYRQ-g^I! zQm975Y19|VHPpyF*uu(0V+_M#sF}KuBst455Kp3Jb{Q99g;wVMUWs~ZHljM*jD@%x zD_|RTpqJkNZWJ`bR8%I0U?lEDeGhz%8sHVwX8R2_V@c*v z13hoOj?CP7i0*p-%d{~m_rx0115p<=#tdwQn)y0Z3U{KG=pY8+M;MO9w*D0L+6J{X zGi`wyND?X&y-=ARj4qv+NkKDMfO_CcTi;De7)77>=58u5F)#<*Bd4TKFbv7avA#w(qe4dUhad z*c8>Cj#Y6Ua*4ARwWq#8o%iYJG95MPXm)QRYR%J7OORpBu@+!u+UKGkyb3$uPSh^H zXLU<7Gp%F|x5l9MQhU_>16&leiE@!Q(aA>*;9b-g#ZJ^~_D@vGFQQU;6*Z7wkUkxc zPNtrWQPhW^Qa=~9bU&jq{1BBvpU$RVS3L?^!)VOG1k}j3U@#s-by$qroOe(U@Jce7 zi$JBeg{@CPbySGDZUbrpAE2K5FH~l4Ab-3NE#}mHeCVM#Kou!i%@HJ z9JM5ua2EcAdSEs`2)b_)mcxa%z7(rd-(=g5pfY$0wRG2T1pPbzqo4;2>}DK`O5H5f z%+{hZRD@cp{kHu>+kOs}+AF95JU~6~F{&fK?&b?A0@dCVb$vRzv}>nOh{l((JRU$j z=s0@fX;daYM=!jM8pwTQ^_(ZR-noa#NFUUFX{dpZL|@Fq<~R$rl>2*-f2H&@8Z?k% z)Bx_HcJ(8BVM0&yz}~1E2cVWD2OHpgdwv&opnecF^GB$OROn@_is~m6)o)BM@}Ev2 zkp^$vgcWgztsg=CihY9W_!8>IyQnV`{UqwX7}Ta~jmpqQtcY81DjviN*tEAjQ^x;bg12F^N!?Ng?Y6e^dbzejD!C2J5 z+MzbNt0#rV6tYn%U4?^i7iwVM^wt3@V>0$fe_V%pt+rz%eunDcSMix}q4NwDbj^Wq= zm4OUYCbLoBFH;X>?1~z|5RAex=+Xn0Q)r57P`mOBs{JB2M%wtgbzsEHM$GJ4C_?+zvZy76}!DxovX3?vZMUK5*OZPX@9 zMWu2iHpa;qi0`15@DOUP?_x82jOsXMxcM)l)~Jc)qpq9jqM!#Zwhil11KEaJf}_?` zs7-ke)$s+?0L!GCzhuf|B=u<2n)kyvoQk?`2WkTQQT?4l4ZwAYf;zl`)$n%=!hjLx zJ#B#6-ASk$2BRK08WV6lM&VX_{tMJ*y^6}*9n@Oa;@hbPrl6iP1{t8sDWIT~Y)5r; z5H*uCs9pX5V=$QC=nmKm_24C_j*eju{0!S+&?xggF#uDkuSfj`+(UKzJL*+C0g&-rt&yHEExTiMSeD;uX})s=Q!~!K&1|V=!i5$=iUFsINgi-!F@$ zq<<%bLK61Gp11<_+FU~|jqhlaiBQx7x}s)w6kA|Gw%KG|F^YOW)crHC4Hls`;}57M z3mRk2_eEDI4YMe8#0^*dj zb$Arr@ihA2SuDiMSOG`nl7G!)Vy=1J3Xrb?XEth2e2Y5&D{26burvCPH#6yvI-iD` zX$HpOM68WPsDXTne)tt?0zaZJK6Fvg13V_!lw%d@)ljL5#y%K_+6xO%sauVj!6ww} zx*NmrQ`G%Gqc)#gp1Cd*J*Y>c7sg^Zx)La8ZPQQ>%tY=M=imZsEk}ip67CYrl3^(hLzAQ-=wNC`cscUEk!(P09{dQ zo{CCw7S_T-jKm__eh&3o-bYQymsckS>!6mXAJ)&9OIX$rhsa&MMRr6j?t+O{5sr-@O9+{{KmXQeJMV>DV82 zV=zv`mRJY(VgO!5&FD7jfxn9+go?-4wMlHc0RC@+$06DgO4%VPvi0Wsvi$WHK zL#UL8%`~ZtMqStj^`HUP4AgZwsE($gQau+n(B-y$3+lSv_WV(-N&PHp6W+G%u7?z~ zi9DH3L#&EQZ6a#3b+M+PQusWoftm&}tH zm(z)Y9ykDXV>;>!Wh}2%bP) z7dp?(G!j*BgL-}^)KaBj$-n;(rJz(7pmzCo9F8YZOHp^e`NN?JCQ~1Sdcbbfo;Zdf zcnOuUho}sdSzspShq}Hp>Up(M_cdBT{`Gn!(V!U)LQfonN>!e98Y-1@QJZZ!>Va2K zYxxsu$@CAvy3gNQ9cxpsjUJeUdQLageS;Q~|G^Z-)4+F#^C=F*&M%w2upV{8Ce%Rp zpkAX>*c!h_-5;{Z3}7lYqQ1!14`7F_iqls}f>^boZ5o6E$<5=nqQEOb;wok$Y;wWuRZJWM-oZ(co1ii5@ z(T8}C&=E}(l$3boY@3!rYySs9a!P)Bd*K8kns7>VA_AZol zEFroPZ&RO+%WV7Wlyw{@+7T^juW!%Ip`@;ubNGYo9&-L1hiGnD!5G1wKdg)h<~_R3{D+|0ISH zpAtIsJJ+5#sfs=RYrT&(Y&nI8>3{UUPu-9Bk=Q_eBkG7DQi(#^+7a2r4#G8vkH0vf zFVl|*9SKA|+UMh|L@eb*+=&wj9oq?C;s~*x_<#uF+ynf9(4nuRVf^T{(SygQZm0~Djwz{9cPJM#7<%@ZK3!9HX+&)+lkquF_Z`+;)#yL zYR>8CkNb$fQ;r}y+V)JHjmqVm`udk`UtMqk%u3fV##Ms>Ya#xQ9EPXyJHkl(*Gz<9- zS;v12C7*w@<;m7+c!^lfwJxG9FCcjJ@YZ Hb&C2w;{#aT diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index a94e56c..99412a3 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 01:04+0000\n" +"POT-Creation-Date: 2026-03-27 01:45+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/app_registry.py:35 workflows/models.py:373 workflows/models.py:454 +#: workflows/app_registry.py:35 workflows/models.py:389 workflows/models.py:470 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:46 workflows/models.py:374 workflows/models.py:455 +#: workflows/app_registry.py:46 workflows/models.py:390 workflows/models.py:471 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -85,12 +85,13 @@ msgid "Dashboard öffnen" msgstr "Open dashboard" #: workflows/app_registry.py:62 -#: workflows/templates/workflows/app_registry.html:27 +#: workflows/templates/workflows/app_registry.html:28 msgid "Suche" msgstr "Search" #: workflows/app_registry.py:62 -#: workflows/templates/workflows/app_registry.html:31 +#: workflows/templates/workflows/account_profile.html:218 +#: workflows/templates/workflows/app_registry.html:32 #: workflows/templates/workflows/backup_recovery.html:72 #: workflows/templates/workflows/job_monitor.html:29 #: workflows/templates/workflows/job_monitor.html:50 @@ -151,8 +152,8 @@ msgid "Logo, Portalname, Farben und PDF-Briefkopf verwalten." msgstr "Manage logo, portal name, colors, and PDF letterhead." #: workflows/app_registry.py:95 -#: workflows/templates/workflows/app_registry.html:5 -#: workflows/templates/workflows/app_registry.html:13 +#: workflows/templates/workflows/app_registry.html:6 +#: workflows/templates/workflows/app_registry.html:14 msgid "App Registry" msgstr "" @@ -258,13 +259,13 @@ msgid "Nur Platform" msgstr "" #: workflows/app_registry.py:311 -#: workflows/templates/workflows/app_registry.html:85 +#: workflows/templates/workflows/app_registry.html:107 msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:317 workflows/models.py:150 -#: workflows/templates/workflows/app_registry.html:43 -#: workflows/templates/workflows/app_registry.html:78 +#: workflows/app_registry.py:317 workflows/models.py:166 +#: workflows/templates/workflows/app_registry.html:44 +#: workflows/templates/workflows/app_registry.html:100 msgid "Apps" msgstr "Apps" @@ -272,9 +273,9 @@ msgstr "Apps" msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:323 workflows/models.py:151 -#: workflows/templates/workflows/app_registry.html:44 -#: workflows/templates/workflows/app_registry.html:74 +#: workflows/app_registry.py:323 workflows/models.py:167 +#: workflows/templates/workflows/app_registry.html:45 +#: workflows/templates/workflows/app_registry.html:96 msgid "Platform Apps" msgstr "" @@ -284,9 +285,9 @@ msgstr "" msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:329 workflows/models.py:152 -#: workflows/templates/workflows/app_registry.html:45 -#: workflows/templates/workflows/app_registry.html:76 +#: workflows/app_registry.py:329 workflows/models.py:168 +#: workflows/templates/workflows/app_registry.html:46 +#: workflows/templates/workflows/app_registry.html:98 msgid "Admin Apps" msgstr "Admin Apps" @@ -383,76 +384,88 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:104 workflows/forms.py:227 +#: workflows/forms.py:106 workflows/forms.py:326 #: workflows/templates/workflows/account_profile.html:66 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:105 +#: workflows/forms.py:107 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:109 workflows/forms.py:173 workflows/forms.py:228 +#: workflows/forms.py:109 workflows/forms.py:265 workflows/forms.py:297 +msgid "TOTP-Code" +msgstr "" + +#: workflows/forms.py:117 workflows/forms.py:286 workflows/forms.py:319 +msgid "Der TOTP-Code ist ungültig." +msgstr "" + +#: workflows/forms.py:118 +msgid "Bitte geben Sie Ihren TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:143 workflows/forms.py:207 workflows/forms.py:327 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:114 workflows/forms.py:133 +#: workflows/forms.py:148 workflows/forms.py:167 #: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:120 workflows/forms.py:139 +#: workflows/forms.py:154 workflows/forms.py:173 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:128 +#: workflows/forms.py:162 workflows/forms.py:260 workflows/forms.py:292 #, fuzzy #| msgid "Neues Passwort" msgid "Aktuelles Passwort" msgstr "New password" -#: workflows/forms.py:150 workflows/templates/workflows/account_profile.html:36 +#: workflows/forms.py:184 workflows/templates/workflows/account_profile.html:36 #: workflows/templates/workflows/includes/app_header.html:27 msgid "Profilbild" msgstr "" -#: workflows/forms.py:166 +#: workflows/forms.py:200 msgid "Das Profilbild darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:171 workflows/forms.py:225 +#: workflows/forms.py:205 workflows/forms.py:324 #: workflows/templates/workflows/account_profile.html:112 msgid "Vorname" msgstr "" -#: workflows/forms.py:172 workflows/forms.py:226 +#: workflows/forms.py:206 workflows/forms.py:325 #: workflows/templates/workflows/account_profile.html:116 msgid "Nachname" msgstr "" -#: workflows/forms.py:174 +#: workflows/forms.py:208 #: workflows/templates/workflows/account_profile.html:120 msgid "Telefon" msgstr "" -#: workflows/forms.py:175 +#: workflows/forms.py:209 #: workflows/templates/workflows/account_profile.html:124 msgid "Mobil" msgstr "" -#: workflows/forms.py:176 workflows/templates/workflows/account_profile.html:70 +#: workflows/forms.py:210 workflows/templates/workflows/account_profile.html:70 #: workflows/templates/workflows/account_profile.html:128 #, fuzzy #| msgid "Produktion" msgid "Position" msgstr "Production" -#: workflows/forms.py:177 workflows/models.py:334 +#: workflows/forms.py:211 workflows/models.py:350 #: workflows/templates/workflows/account_profile.html:74 #: workflows/templates/workflows/account_profile.html:132 #: workflows/templates/workflows/onboarding_intro_session.html:28 @@ -460,19 +473,27 @@ msgstr "Production" msgid "Abteilung" msgstr "Department" -#: workflows/forms.py:178 +#: workflows/forms.py:212 #: workflows/templates/workflows/account_profile.html:136 msgid "Standort" msgstr "" -#: workflows/forms.py:180 +#: workflows/forms.py:214 #: workflows/templates/workflows/account_profile.html:140 #, fuzzy #| msgid "Einweisung" msgid "Hinweise" msgstr "Introduction" -#: workflows/forms.py:229 workflows/templates/workflows/account_profile.html:62 +#: workflows/forms.py:278 workflows/forms.py:310 +msgid "Das aktuelle Passwort ist nicht korrekt." +msgstr "" + +#: workflows/forms.py:284 workflows/forms.py:316 +msgid "Bitte geben Sie einen gültigen TOTP-Code ein." +msgstr "" + +#: workflows/forms.py:328 workflows/templates/workflows/account_profile.html:62 #: workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 @@ -481,205 +502,207 @@ msgstr "Introduction" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:243 +#: workflows/forms.py:342 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:252 workflows/views.py:807 +#: workflows/forms.py:351 workflows/views.py:987 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:254 workflows/views.py:810 +#: workflows/forms.py:353 workflows/views.py:990 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:293 +#: workflows/forms.py:392 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:294 +#: workflows/forms.py:393 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:295 +#: workflows/forms.py:394 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:296 +#: workflows/forms.py:395 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:297 +#: workflows/forms.py:396 msgid "Absender-Anzeigename" msgstr "" -#: workflows/forms.py:298 +#: workflows/forms.py:397 msgid "Login-Untertitel" msgstr "" -#: workflows/forms.py:299 +#: workflows/forms.py:398 msgid "Footer-Text DE" msgstr "" -#: workflows/forms.py:300 +#: workflows/forms.py:399 msgid "Footer-Text EN" msgstr "" -#: workflows/forms.py:301 +#: workflows/forms.py:400 msgid "Rechtlicher Hinweis DE" msgstr "" -#: workflows/forms.py:302 +#: workflows/forms.py:401 msgid "Rechtlicher Hinweis EN" msgstr "" -#: workflows/forms.py:303 +#: workflows/forms.py:402 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:304 +#: workflows/forms.py:403 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:305 +#: workflows/forms.py:404 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:306 +#: workflows/forms.py:405 msgid "Favicon" msgstr "" -#: workflows/forms.py:307 -#: workflows/templates/workflows/branding_settings.html:94 +#: workflows/forms.py:406 +#: workflows/templates/workflows/branding_settings.html:89 +#: workflows/templates/workflows/branding_settings.html:162 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:308 -#: workflows/templates/workflows/branding_settings.html:95 +#: workflows/forms.py:407 +#: workflows/templates/workflows/branding_settings.html:90 +#: workflows/templates/workflows/branding_settings.html:163 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:325 +#: workflows/forms.py:424 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:333 +#: workflows/forms.py:432 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:341 +#: workflows/forms.py:440 msgid "Das Favicon darf maximal 2 MB groß sein." msgstr "" -#: workflows/forms.py:365 +#: workflows/forms.py:464 #, fuzzy #| msgid "Firmenname" msgid "Rechtlicher Firmenname" msgstr "Company name" -#: workflows/forms.py:366 +#: workflows/forms.py:465 msgid "Straße und Hausnummer" msgstr "" -#: workflows/forms.py:367 +#: workflows/forms.py:466 msgid "Postleitzahl" msgstr "" -#: workflows/forms.py:368 +#: workflows/forms.py:467 msgid "Stadt" msgstr "" -#: workflows/forms.py:369 +#: workflows/forms.py:468 msgid "Land" msgstr "" -#: workflows/forms.py:370 workflows/templates/workflows/base_shell.html:64 +#: workflows/forms.py:469 workflows/templates/workflows/base_shell.html:64 msgid "Website" msgstr "" -#: workflows/forms.py:371 +#: workflows/forms.py:470 msgid "Impressum-URL" msgstr "" -#: workflows/forms.py:372 +#: workflows/forms.py:471 msgid "Datenschutz-URL" msgstr "" -#: workflows/forms.py:373 +#: workflows/forms.py:472 msgid "HR-Kontakt" msgstr "" -#: workflows/forms.py:374 +#: workflows/forms.py:473 msgid "IT-Kontakt" msgstr "" -#: workflows/forms.py:375 +#: workflows/forms.py:474 #, fuzzy #| msgid "Operations" msgid "Operations-Kontakt" msgstr "Operations" -#: workflows/forms.py:376 +#: workflows/forms.py:475 msgid "Zentrale Telefonnummer" msgstr "" -#: workflows/forms.py:377 +#: workflows/forms.py:476 msgid "USt-IdNr." msgstr "" -#: workflows/forms.py:378 +#: workflows/forms.py:477 msgid "Register- oder Handelsnummer" msgstr "" -#: workflows/forms.py:395 +#: workflows/forms.py:494 msgid "Trial-Modus aktiv" msgstr "" -#: workflows/forms.py:396 +#: workflows/forms.py:495 msgid "Trial-Beginn" msgstr "" -#: workflows/forms.py:397 +#: workflows/forms.py:496 msgid "Trial-Ende" msgstr "" -#: workflows/forms.py:398 +#: workflows/forms.py:497 msgid "Produktive Integrationen begrenzen" msgstr "" -#: workflows/forms.py:399 +#: workflows/forms.py:498 msgid "Cleanup nach Ablauf zulassen" msgstr "" -#: workflows/forms.py:400 +#: workflows/forms.py:499 msgid "Banner-Text DE" msgstr "" -#: workflows/forms.py:401 +#: workflows/forms.py:500 msgid "Banner-Text EN" msgstr "" -#: workflows/forms.py:421 +#: workflows/forms.py:520 msgid "Bitte ein Trial-Ende festlegen." msgstr "" -#: workflows/forms.py:423 +#: workflows/forms.py:522 msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:562 workflows/forms.py:747 +#: workflows/forms.py:661 workflows/forms.py:846 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:584 workflows/forms.py:761 +#: workflows/forms.py:683 workflows/forms.py:860 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:669 +#: workflows/forms.py:768 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " @@ -731,251 +754,251 @@ msgid "" "ausführen." msgstr "" -#: workflows/models.py:199 workflows/views.py:420 +#: workflows/models.py:215 workflows/views.py:460 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:200 workflows/views.py:420 +#: workflows/models.py:216 workflows/views.py:460 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:201 workflows/models.py:254 workflows/models.py:508 +#: workflows/models.py:217 workflows/models.py:270 workflows/models.py:524 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:246 -#: workflows/views.py:420 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:286 +#: workflows/views.py:460 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:251 workflows/views.py:243 +#: workflows/models.py:267 workflows/views.py:283 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:252 workflows/views.py:244 +#: workflows/models.py:268 workflows/views.py:284 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:253 workflows/models.py:568 workflows/views.py:245 +#: workflows/models.py:269 workflows/models.py:584 workflows/views.py:285 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Herr" msgstr "" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Frau" msgstr "" -#: workflows/models.py:261 +#: workflows/models.py:277 msgid "Divers" msgstr "" -#: workflows/models.py:271 +#: workflows/models.py:287 msgid "befristet" msgstr "" -#: workflows/models.py:271 +#: workflows/models.py:287 msgid "unbefristet" msgstr "" -#: workflows/models.py:335 +#: workflows/models.py:351 msgid "Geräte" msgstr "" -#: workflows/models.py:336 +#: workflows/models.py:352 msgid "Software" msgstr "" -#: workflows/models.py:337 +#: workflows/models.py:353 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:338 +#: workflows/models.py:354 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:339 +#: workflows/models.py:355 msgid "Ressourcen" msgstr "" -#: workflows/models.py:340 +#: workflows/models.py:356 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:366 +#: workflows/models.py:382 msgid "Automatisch" msgstr "" -#: workflows/models.py:367 workflows/views.py:101 +#: workflows/models.py:383 workflows/views.py:102 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:368 workflows/views.py:102 +#: workflows/models.py:384 workflows/views.py:103 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:369 workflows/views.py:103 +#: workflows/models.py:385 workflows/views.py:104 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:370 workflows/views.py:104 +#: workflows/models.py:386 workflows/views.py:105 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:412 +#: workflows/models.py:428 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:413 +#: workflows/models.py:429 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:414 +#: workflows/models.py:430 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:415 +#: workflows/models.py:431 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:416 +#: workflows/models.py:432 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:417 +#: workflows/models.py:433 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:418 +#: workflows/models.py:434 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:419 +#: workflows/models.py:435 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:420 +#: workflows/models.py:436 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:421 +#: workflows/models.py:437 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:422 +#: workflows/models.py:438 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:458 +#: workflows/models.py:474 msgid "Immer" msgstr "" -#: workflows/models.py:459 workflows/models.py:537 +#: workflows/models.py:475 workflows/models.py:553 msgid "Enthält" msgstr "" -#: workflows/models.py:460 workflows/models.py:538 +#: workflows/models.py:476 workflows/models.py:554 msgid "Ist gleich" msgstr "" -#: workflows/models.py:461 +#: workflows/models.py:477 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:462 +#: workflows/models.py:478 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:504 +#: workflows/models.py:520 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:505 +#: workflows/models.py:521 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:506 +#: workflows/models.py:522 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:507 +#: workflows/models.py:523 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:530 workflows/tasks.py:600 +#: workflows/models.py:546 workflows/tasks.py:600 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:531 workflows/tasks.py:601 +#: workflows/models.py:547 workflows/tasks.py:601 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:532 workflows/tasks.py:602 +#: workflows/models.py:548 workflows/tasks.py:602 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:533 workflows/tasks.py:603 +#: workflows/models.py:549 workflows/tasks.py:603 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:536 +#: workflows/models.py:552 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:539 +#: workflows/models.py:555 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:540 +#: workflows/models.py:556 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:567 +#: workflows/models.py:583 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:587 +#: workflows/models.py:603 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:588 +#: workflows/models.py:604 msgid "S3" msgstr "" -#: workflows/models.py:589 +#: workflows/models.py:605 msgid "NFS" msgstr "" @@ -983,18 +1006,18 @@ msgstr "" msgid "Platform Owner" msgstr "" -#: workflows/roles.py:27 workflows/templates/workflows/app_registry.html:88 -#: workflows/templates/workflows/app_registry.html:114 +#: workflows/roles.py:27 workflows/templates/workflows/app_registry.html:110 +#: workflows/templates/workflows/app_registry.html:136 msgid "Super Admin" msgstr "Super Admin" -#: workflows/roles.py:28 workflows/templates/workflows/app_registry.html:89 -#: workflows/templates/workflows/app_registry.html:118 +#: workflows/roles.py:28 workflows/templates/workflows/app_registry.html:111 +#: workflows/templates/workflows/app_registry.html:140 msgid "Admin" msgstr "Admin" -#: workflows/roles.py:29 workflows/templates/workflows/app_registry.html:90 -#: workflows/templates/workflows/app_registry.html:122 +#: workflows/roles.py:29 workflows/templates/workflows/app_registry.html:112 +#: workflows/templates/workflows/app_registry.html:144 msgid "IT Staff" msgstr "IT Staff" @@ -1105,13 +1128,12 @@ msgid "Anmeldung fehlgeschlagen" msgstr "Failed" #: workflows/templates/registration/login.html:31 -#: workflows/templates/workflows/auth/login.html:29 msgid "" "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." msgstr "" #: workflows/templates/registration/login.html:37 -#: workflows/templates/workflows/auth/login.html:35 +#: workflows/templates/workflows/auth/login.html:39 msgid "Anmelden" msgstr "Sign in" @@ -1250,6 +1272,8 @@ msgid "Die wichtigsten Stammdaten Ihres aktuellen Kontos." msgstr "The most important master data of your current account." #: workflows/templates/workflows/account_profile.html:97 +#: workflows/templates/workflows/branding_settings.html:32 +#: workflows/templates/workflows/company_config.html:25 #, fuzzy #| msgid "In Bearbeitung" msgid "Bearbeiten" @@ -1271,12 +1295,16 @@ msgid "E-Mail" msgstr "Email" #: workflows/templates/workflows/account_profile.html:165 +#: workflows/templates/workflows/branding_settings.html:177 +#: workflows/templates/workflows/company_config.html:54 #: workflows/templates/workflows/user_management.html:115 msgid "Speichern" msgstr "Save" #: workflows/templates/workflows/account_profile.html:166 #: workflows/templates/workflows/base_shell.html:79 +#: workflows/templates/workflows/branding_settings.html:178 +#: workflows/templates/workflows/company_config.html:55 #: workflows/templates/workflows/welcome_emails.html:134 msgid "Abbrechen" msgstr "Cancel" @@ -1290,7 +1318,7 @@ msgid "Direkte Aktionen für Ihr Workdock-Konto." msgstr "Direct actions for your Workdock account." #: workflows/templates/workflows/account_profile.html:179 -#: workflows/templates/workflows/account_profile.html:190 +#: workflows/templates/workflows/account_profile.html:278 #: workflows/templates/workflows/auth/password_change_form.html:4 #: workflows/templates/workflows/auth/password_change_form.html:17 #: workflows/templates/workflows/includes/app_header.html:48 @@ -1302,14 +1330,93 @@ msgid "Aktualisieren Sie Ihr Passwort direkt im Konto." msgstr "Update your password directly in your account." #: workflows/templates/workflows/account_profile.html:184 +msgid "TOTP" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:187 +msgid "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:189 +msgid "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:195 msgid "Sitzung" msgstr "Session" -#: workflows/templates/workflows/account_profile.html:185 +#: workflows/templates/workflows/account_profile.html:196 msgid "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." msgstr "" -#: workflows/templates/workflows/account_profile.html:193 +#: workflows/templates/workflows/account_profile.html:203 +msgid "Zwei-Faktor-Authentifizierung" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:204 +msgid "" +"Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es " +"ausgeschaltet." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:208 +#: workflows/templates/workflows/app_registry.html:35 +#: workflows/templates/workflows/app_registry.html:84 +#: workflows/templates/workflows/form_builder.html:91 +#: workflows/templates/workflows/integrations_setup.html:263 +#: workflows/templates/workflows/intro_builder.html:65 +#: workflows/templates/workflows/trial_management.html:28 +#: workflows/templates/workflows/user_management.html:75 +msgid "Aktiv" +msgstr "Active" + +#: workflows/templates/workflows/account_profile.html:210 +#, fuzzy +#| msgid "Auf" +msgid "Aus" +msgstr "To" + +#: workflows/templates/workflows/account_profile.html:219 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP ist aktiviert." +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:222 +msgid "Bestätigt am" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:241 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP deaktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:247 +#, fuzzy +#| msgid "Onboarding starten" +msgid "Manueller Schlüssel" +msgstr "Start onboarding" + +#: workflows/templates/workflows/account_profile.html:251 +#, fuzzy +#| msgid "Setup Mail" +msgid "Setup-Link" +msgstr "Setup Mail" + +#: workflows/templates/workflows/account_profile.html:255 +msgid "" +"Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den " +"otpauth-Link manuell ein." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:271 +#, fuzzy +#| msgid "Aktivieren" +msgid "TOTP aktivieren" +msgstr "Enable" + +#: workflows/templates/workflows/account_profile.html:281 #: workflows/templates/workflows/includes/app_header.html:51 msgid "Abmelden" msgstr "Log out" @@ -1320,32 +1427,39 @@ msgstr "Log out" msgid "Ungespeicherte Änderungen" msgstr "Last updated" -#: workflows/templates/workflows/app_registry.html:14 +#: workflows/templates/workflows/app_registry.html:4 +#: workflows/templates/workflows/app_registry.html:103 +#: workflows/templates/workflows/form_builder.html:87 +#: workflows/templates/workflows/intro_builder.html:58 +msgid "Sortierung" +msgstr "Sort order" + +#: workflows/templates/workflows/app_registry.html:15 msgid "" "Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf " "der Landing Page ausspielen." msgstr "" -#: workflows/templates/workflows/app_registry.html:20 +#: workflows/templates/workflows/app_registry.html:21 msgid "" "Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier " "steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." msgstr "" -#: workflows/templates/workflows/app_registry.html:21 +#: workflows/templates/workflows/app_registry.html:22 #, fuzzy #| msgid "Produktion" msgid "Produktkern" msgstr "Production" -#: workflows/templates/workflows/app_registry.html:28 +#: workflows/templates/workflows/app_registry.html:29 #, fuzzy #| msgid "Nach Name oder E-Mail suchen" msgid "Nach App-Name oder Key filtern" msgstr "Search by name or email" -#: workflows/templates/workflows/app_registry.html:33 -#: workflows/templates/workflows/app_registry.html:42 +#: workflows/templates/workflows/app_registry.html:34 +#: workflows/templates/workflows/app_registry.html:43 #: workflows/templates/workflows/audit_log.html:25 #: workflows/templates/workflows/job_monitor.html:22 #: workflows/templates/workflows/job_monitor.html:31 @@ -1355,18 +1469,8 @@ msgstr "Search by name or email" msgid "Alle" msgstr "" -#: workflows/templates/workflows/app_registry.html:34 -#: workflows/templates/workflows/app_registry.html:62 -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/integrations_setup.html:263 -#: workflows/templates/workflows/intro_builder.html:65 -#: workflows/templates/workflows/trial_management.html:28 -#: workflows/templates/workflows/user_management.html:75 -msgid "Aktiv" -msgstr "Active" - -#: workflows/templates/workflows/app_registry.html:35 -#: workflows/templates/workflows/app_registry.html:64 +#: workflows/templates/workflows/app_registry.html:36 +#: workflows/templates/workflows/app_registry.html:86 #: workflows/templates/workflows/backup_recovery.html:106 #: workflows/templates/workflows/trial_management.html:30 #: workflows/templates/workflows/trial_management.html:43 @@ -1375,132 +1479,155 @@ msgstr "Active" msgid "Deaktiviert" msgstr "Disabled" -#: workflows/templates/workflows/app_registry.html:36 -#: workflows/templates/workflows/app_registry.html:83 +#: workflows/templates/workflows/app_registry.html:37 +#: workflows/templates/workflows/app_registry.html:105 msgid "Platform only" msgstr "" -#: workflows/templates/workflows/app_registry.html:40 -#: workflows/templates/workflows/app_registry.html:136 +#: workflows/templates/workflows/app_registry.html:41 +#: workflows/templates/workflows/app_registry.html:158 #, fuzzy #| msgid "Eingereicht" msgid "Bereich" msgstr "Submitted" -#: workflows/templates/workflows/app_registry.html:69 +#: workflows/templates/workflows/app_registry.html:50 +msgid "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:59 +msgid "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:61 +msgid "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:63 +msgid "" +"Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:79 +#, fuzzy +#| msgid "Ziehen zum Sortieren" +msgid "Ziehen zum Umordnen" +msgstr "Drag to reorder" + +#: workflows/templates/workflows/app_registry.html:91 msgid "Empfohlener Standardzugriff:" msgstr "" -#: workflows/templates/workflows/app_registry.html:81 -#: workflows/templates/workflows/form_builder.html:87 -#: workflows/templates/workflows/intro_builder.html:58 -msgid "Sortierung" -msgstr "Sort order" - -#: workflows/templates/workflows/app_registry.html:91 -#: workflows/templates/workflows/app_registry.html:126 +#: workflows/templates/workflows/app_registry.html:113 +#: workflows/templates/workflows/app_registry.html:148 #, fuzzy #| msgid "IT Staff" msgid "Staff" msgstr "IT Staff" -#: workflows/templates/workflows/app_registry.html:99 +#: workflows/templates/workflows/app_registry.html:121 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "Verfügbarkeit" msgstr "Not available yet" -#: workflows/templates/workflows/app_registry.html:103 +#: workflows/templates/workflows/app_registry.html:125 #, fuzzy #| msgid "Deaktivieren" msgid "App aktiviert" msgstr "Disabled" -#: workflows/templates/workflows/app_registry.html:106 +#: workflows/templates/workflows/app_registry.html:128 msgid "" "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen " "sie sehen dürften." msgstr "" -#: workflows/templates/workflows/app_registry.html:110 +#: workflows/templates/workflows/app_registry.html:132 msgid "Sichtbarkeit nach Rolle" msgstr "" -#: workflows/templates/workflows/app_registry.html:129 +#: workflows/templates/workflows/app_registry.html:151 msgid "" "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform " "sichtbar." msgstr "" -#: workflows/templates/workflows/app_registry.html:133 +#: workflows/templates/workflows/app_registry.html:155 #, fuzzy #| msgid "Sortierung" msgid "Platzierung" msgstr "Sort order" -#: workflows/templates/workflows/app_registry.html:144 +#: workflows/templates/workflows/app_registry.html:166 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Reihenfolge" msgstr "Save order" -#: workflows/templates/workflows/app_registry.html:151 +#: workflows/templates/workflows/app_registry.html:176 +msgid "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." +msgstr "" + +#: workflows/templates/workflows/app_registry.html:182 msgid "Bezeichnungen & Texte" msgstr "" -#: workflows/templates/workflows/app_registry.html:154 -#: workflows/templates/workflows/branding_settings.html:141 +#: workflows/templates/workflows/app_registry.html:185 +#: workflows/templates/workflows/branding_settings.html:40 +#: workflows/templates/workflows/branding_settings.html:111 #: workflows/templates/workflows/trial_management.html:105 msgid "Deutsch" msgstr "" -#: workflows/templates/workflows/app_registry.html:156 +#: workflows/templates/workflows/app_registry.html:187 msgid "Titel" msgstr "" -#: workflows/templates/workflows/app_registry.html:160 +#: workflows/templates/workflows/app_registry.html:191 msgid "Beschreibung" msgstr "" -#: workflows/templates/workflows/app_registry.html:164 +#: workflows/templates/workflows/app_registry.html:195 #, fuzzy #| msgid "Aktionen" msgid "Aktionslabel" msgstr "Actions" -#: workflows/templates/workflows/app_registry.html:169 -#: workflows/templates/workflows/branding_settings.html:152 +#: workflows/templates/workflows/app_registry.html:200 +#: workflows/templates/workflows/branding_settings.html:49 +#: workflows/templates/workflows/branding_settings.html:121 #: workflows/templates/workflows/trial_management.html:112 #, fuzzy #| msgid "English label" msgid "English" msgstr "English label" -#: workflows/templates/workflows/app_registry.html:171 +#: workflows/templates/workflows/app_registry.html:202 msgid "Title" msgstr "" -#: workflows/templates/workflows/app_registry.html:175 +#: workflows/templates/workflows/app_registry.html:206 msgid "Description" msgstr "" -#: workflows/templates/workflows/app_registry.html:179 +#: workflows/templates/workflows/app_registry.html:210 #, fuzzy #| msgid "Aktionen" msgid "Action label" msgstr "Actions" -#: workflows/templates/workflows/app_registry.html:191 +#: workflows/templates/workflows/app_registry.html:226 msgid "" "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter " "Apps oder Admin Apps einordnen." msgstr "" -#: workflows/templates/workflows/app_registry.html:192 +#: workflows/templates/workflows/app_registry.html:227 msgid "Keine ungespeicherten Änderungen" msgstr "" -#: workflows/templates/workflows/app_registry.html:194 +#: workflows/templates/workflows/app_registry.html:229 #, fuzzy #| msgid "Regeln speichern" msgid "App Registry speichern" @@ -1594,6 +1721,16 @@ msgstr "" msgid "Noch keine Audit-Einträge vorhanden." msgstr "No requests available yet." +#: workflows/templates/workflows/auth/login.html:29 +msgid "" +"Anmeldedaten oder TOTP-Code sind nicht korrekt. Bitte versuchen Sie es " +"erneut." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:37 +msgid "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." +msgstr "" + #: workflows/templates/workflows/auth/password_change_done.html:4 #: workflows/templates/workflows/auth/password_change_done.html:17 #: workflows/templates/workflows/user_management.html:174 @@ -1831,83 +1968,26 @@ msgid "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." msgstr "" "Manage portal name, company branding, logo, and PDF letterhead centrally." -#: workflows/templates/workflows/branding_settings.html:23 -msgid "Identität" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:24 -msgid "Titel, Firmenname und zentrale Spracheinstellungen." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:38 -msgid "" -"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " -"B. tub.co." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:53 -msgid "Farben & Erscheinungsbild" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:54 -msgid "Zentrale visuelle Markenwerte und Browser-Icon." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:68 -msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:71 -msgid "Aktuelles Logo:" -msgstr "Current logo:" - -#: workflows/templates/workflows/branding_settings.html:71 -#: workflows/templates/workflows/branding_settings.html:80 -#: workflows/templates/workflows/branding_settings.html:128 +#: workflows/templates/workflows/branding_settings.html:66 +#: workflows/templates/workflows/branding_settings.html:144 msgid "öffnen" msgstr "open" -#: workflows/templates/workflows/branding_settings.html:77 -msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." -msgstr "" +#: workflows/templates/workflows/branding_settings.html:140 +msgid "Aktuelles Logo:" +msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:80 +#: workflows/templates/workflows/branding_settings.html:141 #, fuzzy #| msgid "Aktuelles Logo:" msgid "Aktuelles Favicon:" msgstr "Current logo:" -#: workflows/templates/workflows/branding_settings.html:109 -#, fuzzy -#| msgid "Produktion" -msgid "Kommunikation" -msgstr "Production" - -#: workflows/templates/workflows/branding_settings.html:110 -msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:120 -msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:125 -msgid "Erlaubtes Format: PDF. Maximal 10 MB." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:128 +#: workflows/templates/workflows/branding_settings.html:142 msgid "Aktueller Briefkopf:" msgstr "Current letterhead:" -#: workflows/templates/workflows/branding_settings.html:136 -msgid "Footer & Rechtliches" -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:137 -msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." -msgstr "" - -#: workflows/templates/workflows/branding_settings.html:166 +#: workflows/templates/workflows/branding_settings.html:185 #, fuzzy #| msgid "" #| "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien " @@ -1919,68 +1999,18 @@ msgstr "" "TUBCO remains the default until values are changed or files are uploaded " "here." -#: workflows/templates/workflows/branding_settings.html:167 -msgid "Branding speichern" -msgstr "Save branding" - #: workflows/templates/workflows/company_config.html:13 msgid "" "Strukturierte Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks " "zentral pflegen." msgstr "" -#: workflows/templates/workflows/company_config.html:23 -#, fuzzy -#| msgid "Firmenname" -msgid "Firmenprofil" -msgstr "Company name" - -#: workflows/templates/workflows/company_config.html:24 -msgid "Rechtlicher Name und zentrale Stammdaten der Firma." -msgstr "" - -#: workflows/templates/workflows/company_config.html:48 -msgid "Adresse & Register" -msgstr "" - -#: workflows/templates/workflows/company_config.html:49 -msgid "Anschrift sowie optionale Register- und Steuerangaben." -msgstr "" - -#: workflows/templates/workflows/company_config.html:77 -msgid "Kontaktpunkte" -msgstr "" - -#: workflows/templates/workflows/company_config.html:78 -msgid "Zentrale Ansprechpartner für HR, IT und Operations." -msgstr "" - -#: workflows/templates/workflows/company_config.html:98 -msgid "Recht & Öffentlichkeit" -msgstr "" - -#: workflows/templates/workflows/company_config.html:99 -msgid "Öffentliche Links für Website, Impressum und Datenschutz." -msgstr "" - -#: workflows/templates/workflows/company_config.html:111 -msgid "" -"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " -"verwendet werden." -msgstr "" - -#: workflows/templates/workflows/company_config.html:115 +#: workflows/templates/workflows/company_config.html:62 msgid "" "Diese Ebene ist bewusst von Branding getrennt: Hier geht es um strukturierte " "Firmendaten, nicht um visuelle Gestaltung." msgstr "" -#: workflows/templates/workflows/company_config.html:116 -#, fuzzy -#| msgid "Optionen speichern" -msgid "Firmenkonfiguration speichern" -msgstr "Save options" - #: workflows/templates/workflows/form_builder.html:15 msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." msgstr "Sort fields by drag and drop and group them by step." @@ -2847,7 +2877,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1062 +#: workflows/views.py:1242 msgid "Vertragsbeginn" msgstr "Contract start" @@ -3651,280 +3681,302 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:101 +#: workflows/views.py:102 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:102 +#: workflows/views.py:103 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:103 +#: workflows/views.py:104 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:104 +#: workflows/views.py:105 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:141 +#: workflows/views.py:154 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:143 +#: workflows/views.py:156 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:149 +#: workflows/views.py:162 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:151 +#: workflows/views.py:164 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:172 workflows/views.py:1148 workflows/views.py:1153 +#: workflows/views.py:171 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP wurde aktiviert." +msgstr "Disabled" + +#: workflows/views.py:173 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht aktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:180 +msgid "TOTP wurde deaktiviert." +msgstr "" + +#: workflows/views.py:182 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "TOTP konnte nicht deaktiviert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:212 workflows/views.py:1328 workflows/views.py:1333 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:253 +#: workflows/views.py:293 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:254 +#: workflows/views.py:294 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:255 +#: workflows/views.py:295 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:256 +#: workflows/views.py:296 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:257 +#: workflows/views.py:297 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:258 +#: workflows/views.py:298 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:259 +#: workflows/views.py:299 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:260 +#: workflows/views.py:300 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:261 +#: workflows/views.py:301 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:262 +#: workflows/views.py:302 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:263 +#: workflows/views.py:303 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:264 +#: workflows/views.py:304 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:265 +#: workflows/views.py:305 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:266 +#: workflows/views.py:306 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:267 +#: workflows/views.py:307 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:268 +#: workflows/views.py:308 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:269 +#: workflows/views.py:309 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:270 +#: workflows/views.py:310 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:271 +#: workflows/views.py:311 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:272 +#: workflows/views.py:312 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:273 +#: workflows/views.py:313 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:274 +#: workflows/views.py:314 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:275 +#: workflows/views.py:315 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:276 +#: workflows/views.py:316 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:277 +#: workflows/views.py:317 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:278 +#: workflows/views.py:318 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:279 +#: workflows/views.py:319 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:280 +#: workflows/views.py:320 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:281 +#: workflows/views.py:321 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:282 +#: workflows/views.py:322 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:283 +#: workflows/views.py:323 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:284 +#: workflows/views.py:324 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:285 +#: workflows/views.py:325 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:286 +#: workflows/views.py:326 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:287 +#: workflows/views.py:327 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:288 +#: workflows/views.py:328 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:289 +#: workflows/views.py:329 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:290 +#: workflows/views.py:330 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:291 +#: workflows/views.py:331 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:459 +#: workflows/views.py:503 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:558 +#: workflows/views.py:602 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:567 +#: workflows/views.py:611 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:569 +#: workflows/views.py:613 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3937,12 +3989,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:580 +#: workflows/views.py:624 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:582 +#: workflows/views.py:626 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3955,7 +4007,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:620 +#: workflows/views.py:677 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3963,13 +4015,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:646 +#: workflows/views.py:705 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:677 +#: workflows/views.py:722 +msgid "Identität" +msgstr "" + +#: workflows/views.py:723 +msgid "Titel, Firmenname und zentrale Spracheinstellungen." +msgstr "" + +#: workflows/views.py:727 +msgid "" +"Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " +"B. tub.co." +msgstr "" + +#: workflows/views.py:732 +msgid "Farben & Erscheinungsbild" +msgstr "" + +#: workflows/views.py:733 +msgid "Zentrale visuelle Markenwerte und Browser-Icon." +msgstr "" + +#: workflows/views.py:737 +msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." +msgstr "" + +#: workflows/views.py:738 +msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." +msgstr "" + +#: workflows/views.py:743 +#, fuzzy +#| msgid "Produktion" +msgid "Kommunikation" +msgstr "Production" + +#: workflows/views.py:744 +msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." +msgstr "" + +#: workflows/views.py:748 +msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." +msgstr "" + +#: workflows/views.py:749 +msgid "Erlaubtes Format: PDF. Maximal 10 MB." +msgstr "" + +#: workflows/views.py:754 +msgid "Footer & Rechtliches" +msgstr "" + +#: workflows/views.py:755 +msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." +msgstr "" + +#: workflows/views.py:809 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3978,13 +4086,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:704 +#: workflows/views.py:838 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:736 +#: workflows/views.py:855 +#, fuzzy +#| msgid "Firmenname" +msgid "Firmenprofil" +msgstr "Company name" + +#: workflows/views.py:856 +msgid "Rechtlicher Name und zentrale Stammdaten der Firma." +msgstr "" + +#: workflows/views.py:861 +msgid "Adresse & Register" +msgstr "" + +#: workflows/views.py:862 +msgid "Anschrift sowie optionale Register- und Steuerangaben." +msgstr "" + +#: workflows/views.py:867 +msgid "Kontaktpunkte" +msgstr "" + +#: workflows/views.py:868 +msgid "Zentrale Ansprechpartner für HR, IT und Operations." +msgstr "" + +#: workflows/views.py:873 +msgid "Recht & Öffentlichkeit" +msgstr "" + +#: workflows/views.py:874 +msgid "Öffentliche Links für Website, Impressum und Datenschutz." +msgstr "" + +#: workflows/views.py:876 +msgid "" +"Diese Links können später im Portal-Footer oder in öffentlichen Seiten " +"verwendet werden." +msgstr "" + +#: workflows/views.py:916 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -3993,21 +4141,21 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:763 +#: workflows/views.py:943 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:780 +#: workflows/views.py:960 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:793 +#: workflows/views.py:973 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:815 +#: workflows/views.py:995 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4018,14 +4166,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:818 +#: workflows/views.py:998 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:821 +#: workflows/views.py:1001 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4036,7 +4184,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:824 +#: workflows/views.py:1004 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4047,18 +4195,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:841 +#: workflows/views.py:1021 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:863 +#: workflows/views.py:1043 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:875 +#: workflows/views.py:1055 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4068,7 +4216,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:878 +#: workflows/views.py:1058 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4078,7 +4226,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:881 +#: workflows/views.py:1061 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4087,7 +4235,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:884 +#: workflows/views.py:1064 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4096,124 +4244,132 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:897 +#: workflows/views.py:1077 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:986 +#: workflows/views.py:1166 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:988 +#: workflows/views.py:1168 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1004 +#: workflows/views.py:1184 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1006 +#: workflows/views.py:1186 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1022 +#: workflows/views.py:1202 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1024 +#: workflows/views.py:1204 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1050 +#: workflows/views.py:1230 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1052 +#: workflows/views.py:1232 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1064 +#: workflows/views.py:1244 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1074 +#: workflows/views.py:1254 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1076 +#: workflows/views.py:1256 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1085 +#: workflows/views.py:1265 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1111 +#: workflows/views.py:1291 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1123 +#: workflows/views.py:1303 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:1162 +#: workflows/views.py:1342 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1205 +#: workflows/views.py:1385 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1207 +#: workflows/views.py:1387 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1209 +#: workflows/views.py:1389 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1437 +#: workflows/views.py:1617 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1454 +#: workflows/views.py:1634 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1483 +#: workflows/views.py:1663 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1497 +#: workflows/views.py:1677 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1510 +#: workflows/views.py:1690 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." +#~ msgid "Branding speichern" +#~ msgstr "Save branding" + +#, fuzzy +#~| msgid "Optionen speichern" +#~ msgid "Firmenkonfiguration speichern" +#~ msgstr "Save options" + #, fuzzy #~| msgid "Aktion" #~ msgid "Aktion DE" @@ -4250,15 +4406,9 @@ msgstr "Introduction was saved as draft." #~ msgid "Aktiv/Inaktiv direkt umschalten." #~ msgstr "Switch active/inactive directly." -#~ msgid "Aktivieren" -#~ msgstr "Enable" - #~ msgid "Zwischen Testmodus und Produktion wechseln." #~ msgstr "Switch between test mode and production." -#~ msgid "Auf" -#~ msgstr "To" - #~ msgid "SMTP Einstellungen" #~ msgstr "SMTP Settings" diff --git a/backend/workflows/app_registry.py b/backend/workflows/app_registry.py index bf46d01..3e9db5b 100644 --- a/backend/workflows/app_registry.py +++ b/backend/workflows/app_registry.py @@ -349,6 +349,16 @@ def ensure_portal_app_configs() -> None: 'visible_to_staff': visibility.get(ROLE_STAFF, False), }, ) + normalize_portal_app_sort_orders() + + +def normalize_portal_app_sort_orders() -> None: + for section_key, _label in PortalAppConfig.SECTION_CHOICES: + configs = list(PortalAppConfig.objects.filter(section=section_key).order_by('sort_order', 'key')) + for position, config in enumerate(configs): + if config.sort_order != position: + config.sort_order = position + config.save(update_fields=['sort_order']) def get_portal_app_registry_rows() -> list[dict[str, object]]: diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 477a401..ccb0a3d 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -3,6 +3,7 @@ from pathlib import Path from datetime import timedelta from django.contrib.auth import get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm +from django.core.exceptions import ValidationError from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy @@ -10,6 +11,7 @@ from .branding import get_company_email_domain from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role +from .totp import normalize_totp_token, verify_totp_token YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -103,6 +105,38 @@ SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)' class AppAuthenticationForm(AuthenticationForm): username = forms.CharField(label=gettext_lazy('Benutzername')) password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) + otp_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + required=False, + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + error_messages = { + **AuthenticationForm.error_messages, + 'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'), + 'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'), + } + + def clean(self): + cleaned_data = super().clean() + user = self.get_user() + if not user: + return cleaned_data + profile, _ = UserProfile.objects.get_or_create(user=user) + if profile.totp_enabled: + otp_code = normalize_totp_token(cleaned_data.get('otp_code')) + if not otp_code: + raise ValidationError( + self.error_messages['missing_otp'], + code='missing_otp', + ) + if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())): + raise ValidationError( + self.error_messages['invalid_otp'], + code='invalid_otp', + ) + return cleaned_data class AppPasswordResetForm(PasswordResetForm): @@ -221,6 +255,71 @@ class AccountDetailsForm(forms.Form): return self.user, self.profile +class AccountTOTPEnableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + def __init__(self, *args, user=None, secret: str = '', **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.secret = secret + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean_verification_code(self): + code = normalize_totp_token(self.cleaned_data.get('verification_code')) + if not code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + if not self.secret or not verify_totp_token(self.secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return code + + +class AccountTOTPDisableForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.profile = profile + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean_verification_code(self): + code = normalize_totp_token(self.cleaned_data.get('verification_code')) + if not code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + secret = getattr(self.profile, 'totp_secret', '') or '' + if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return code + + class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) diff --git a/backend/workflows/migrations/0049_userprofile_totp_fields.py b/backend/workflows/migrations/0049_userprofile_totp_fields.py new file mode 100644 index 0000000..c9f13eb --- /dev/null +++ b/backend/workflows/migrations/0049_userprofile_totp_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0048_userprofile'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='totp_confirmed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='userprofile', + name='totp_enabled', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='userprofile', + name='totp_secret', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 1d2633c..b637743 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.validators import FileExtensionValidator from django.db import models from django.utils.translation import get_language +from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -39,6 +40,9 @@ class UserProfile(models.Model): department = models.CharField(max_length=255, blank=True, default='') location = models.CharField(max_length=255, blank=True, default='') contact_notes = models.CharField(max_length=255, blank=True, default='') + totp_secret = models.CharField(max_length=64, blank=True, default='') + totp_enabled = models.BooleanField(default=False) + totp_confirmed_at = models.DateTimeField(null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -48,6 +52,18 @@ class UserProfile(models.Model): def __str__(self) -> str: return getattr(self.user, 'username', '') or str(self.user_id) + def disable_totp(self) -> None: + self.totp_secret = '' + self.totp_enabled = False + self.totp_confirmed_at = None + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + + def enable_totp(self, secret: str) -> None: + self.totp_secret = secret + self.totp_enabled = True + self.totp_confirmed_at = timezone.now() + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + class PortalBranding(models.Model): name = models.CharField(max_length=80, default='Default', unique=True) diff --git a/backend/workflows/static/workflows/css/account.css b/backend/workflows/static/workflows/css/account.css index 4c5d8ba..839bd3f 100644 --- a/backend/workflows/static/workflows/css/account.css +++ b/backend/workflows/static/workflows/css/account.css @@ -298,6 +298,34 @@ body { margin-bottom: 18px; } +.account-totp-card { + margin-bottom: 18px; + padding: 18px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%), + #f9fbff; +} + +.account-totp-card h3 { + margin: 0 0 6px; + color: #132238; + font-size: 18px; +} + +.account-secret { + display: block; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.55; + word-break: break-all; +} + +.account-totp-form { + margin-top: 14px; +} + .account-action-card { display: grid; gap: 6px; diff --git a/backend/workflows/static/workflows/css/admin_tools.css b/backend/workflows/static/workflows/css/admin_tools.css index 619303a..cf2d4af 100644 --- a/backend/workflows/static/workflows/css/admin_tools.css +++ b/backend/workflows/static/workflows/css/admin_tools.css @@ -12,6 +12,19 @@ h1 { margin: 12px 0 6px; color: #000078; } .branding-block-head { margin-bottom: 12px; } .branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; } .branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; } +.branding-inline-head, .company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.branding-inline-trigger, .company-inline-trigger { min-width: 112px; } +.branding-inline-view.is-hidden, .branding-inline-form.is-hidden, .company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.branding-inline-value, .company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.branding-inline-actions, .company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.branding-inline-error, .company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.company-inline-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; } +.company-inline-trigger { min-width: 112px; } +.company-inline-view.is-hidden, .company-inline-form.is-hidden { display: none; } +.company-inline-value { min-height: 40px; padding: 10px 12px; border: 1px solid #d9e4f1; border-radius: 10px; background: rgba(248,251,255,0.92); color: #18335b; line-height: 1.45; word-break: break-word; } +.company-inline-actions { display: flex; gap: 10px; margin-top: 14px; } +.company-inline-error { margin-top: 6px; color: #ab1e1e; font-size: 12px; line-height: 1.4; } +.field.has-error input, .field.has-error select, .field.has-error textarea { border-color: #e3a3a3; background: #fffafa; box-shadow: 0 0 0 4px rgba(185, 28, 28, 0.06); } .lang-pairs { align-items: start; } .lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; } .lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; } @@ -146,14 +159,18 @@ th { background: #f6f9ff; color: #334155; } .app-registry-card { border: 1px solid #d9e4f1; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); 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), opacity 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } .app-registry-card:hover { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.06); border-color: #c9d8eb; } .app-registry-card.is-disabled { opacity: 0.84; } +.app-registry-card.is-dragging { opacity: 0.55; transform: rotate(0.4deg); box-shadow: 0 18px 28px rgba(16, 32, 57, 0.14); } .app-registry-card[hidden] { display: none !important; } .app-registry-card-head { display: flex; justify-content: space-between; align-items: start; gap: 14px; margin-bottom: 14px; } .app-registry-card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } .app-registry-card-title-row h2 { margin: 0; color: #17345e; font-size: 19px; } .app-registry-card-copy { margin: 8px 0 0; color: #60738d; max-width: 760px; } -.app-registry-summary { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; } +.app-registry-summary { display: grid; grid-template-columns: 28px minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; } .app-registry-summary::-webkit-details-marker { display: none; } .app-registry-summary::marker { display: none; } +.app-registry-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; min-height: 42px; border-radius: 10px; border: 1px dashed #cbd7e6; background: #f8fbff; color: #5f6f85; font-size: 15px; letter-spacing: 0.04em; cursor: grab; user-select: none; } +.app-registry-card.is-dragging .app-registry-drag-handle { cursor: grabbing; } +.app-registry-card.drag-disabled .app-registry-drag-handle { opacity: 0.4; cursor: not-allowed; border-style: solid; } .app-registry-summary-main { min-width: 0; } .app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; } .app-registry-card-grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 12px; align-items: start; } @@ -187,6 +204,8 @@ th { background: #f6f9ff; color: #334155; } .actions { white-space: nowrap; } @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } + .branding-inline-head, .company-inline-head { flex-direction: column; } + .branding-inline-actions, .company-inline-actions { flex-direction: column; } .trial-summary-grid { grid-template-columns: 1fr 1fr; } .trial-expired-shell { padding: 20px 16px 28px; } .trial-expired-card { padding: 18px; } @@ -200,3 +219,9 @@ th { background: #f6f9ff; color: #334155; } .app-registry-copy-panel { grid-column: auto; } .app-registry-savebar { align-items: stretch; flex-direction: column; } } +.app-registry-groups { display: grid; gap: 18px; } +.app-registry-group { border: 1px solid #d7e3f0; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(244,248,255,0.95)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); } +.app-registry-group-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 14px; } +.app-registry-group-head h2 { margin: 0; color: #17345e; font-size: 18px; } +.app-registry-group-body { display: grid; gap: 14px; } +.app-registry-group[hidden] { display: none !important; } diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html index 10a5202..6124e0c 100644 --- a/backend/workflows/templates/workflows/account_profile.html +++ b/backend/workflows/templates/workflows/account_profile.html @@ -180,12 +180,100 @@ {% trans "Aktualisieren Sie Ihr Passwort direkt im Konto." %} + +
      + + -
      - {% for row in rows %} -
      - -
      -
      -

      {{ row.definition.title }}

      - {% if row.config.is_enabled %} - {% trans "Aktiv" %} +
      {% trans "Für eine verlässliche Reihenfolge bitte ohne aktive Filter umsortieren." %}
      +
      + {% for section_key, section_label in section_choices %} +
      +
      +
      +

      {{ section_label }}

      +

      + {% if section_key == 'platform' %} + {% trans "Produktweite Steuerung und nur für die Platform sichtbare Oberflächen." %} + {% elif section_key == 'admin' %} + {% trans "Administrative Apps für Kundenrollen mit erhöhter Verantwortung." %} {% else %} - {% trans "Deaktiviert" %} + {% trans "Operative Apps, die im täglichen Einsatz auf der Landing Page erscheinen." %} {% endif %} -

      -
      {{ row.config.key }}
      -

      {{ row.definition.description }}

      -

      {% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}

      +

      -
      - - {% if row.config.section == 'platform_apps' %} - {% trans "Platform Apps" %} - {% elif row.config.section == 'admin_apps' %} - {% trans "Admin Apps" %} - {% else %} - {% trans "Apps" %} - {% endif %} - - {% trans "Sortierung" %}: {{ row.config.sort_order }} - {% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %} - {% trans "Platform only" %} - {% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %} - {% trans "Alle Firmenrollen" %} - {% else %} - - {% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %} - {% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %} - {% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %} - {% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %} - - {% endif %} -
      -
      - -
      -
      -

      {% trans "Verfügbarkeit" %}

      -
      - -
      -

      {% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}

      -
      - -
      -

      {% trans "Sichtbarkeit nach Rolle" %}

      -
      - - - - -
      -

      {% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}

      -
      - -
      -

      {% trans "Platzierung" %}

      -
      -
      - - -
      -
      - - -
      -
      -
      - -
      -

      {% trans "Bezeichnungen & Texte" %}

      -
      -
      -

      {% trans "Deutsch" %}

      -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      -

      {% trans "English" %}

      -
      - - -
      -
      - - -
      -
      - - -
      -
      -
      -
      + {{ section_label }}
      -
      +
      + {% for row in rows %} + {% if row.config.section == section_key %} +
      + + +
      +
      +

      {{ row.definition.title }}

      + {% if row.config.is_enabled %} + {% trans "Aktiv" %} + {% else %} + {% trans "Deaktiviert" %} + {% endif %} +
      +
      {{ row.config.key }}
      +

      {{ row.definition.description }}

      +

      {% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}

      +
      +
      + + {% if row.config.section == 'platform' %} + {% trans "Platform Apps" %} + {% elif row.config.section == 'admin' %} + {% trans "Admin Apps" %} + {% else %} + {% trans "Apps" %} + {% endif %} + + {% trans "Sortierung" %}: {{ row.config.sort_order }} + {% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %} + {% trans "Platform only" %} + {% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %} + {% trans "Alle Firmenrollen" %} + {% else %} + + {% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %} + {% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %} + {% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %} + {% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %} + + {% endif %} +
      +
      + +
      +
      +

      {% trans "Verfügbarkeit" %}

      +
      + +
      +

      {% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}

      +
      + +
      +

      {% trans "Sichtbarkeit nach Rolle" %}

      +
      + + + + +
      +

      {% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}

      +
      + +
      +

      {% trans "Platzierung" %}

      +
      +
      + + +
      +
      + + +
      {% trans "Wird per Drag-and-drop und Bereichswechsel dynamisch neu nummeriert." %}
      +
      +
      +
      + +
      +

      {% trans "Bezeichnungen & Texte" %}

      +
      +
      +

      {% trans "Deutsch" %}

      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +

      {% trans "English" %}

      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +
      +
      +
      + {% endif %} + {% endfor %} +
      + {% endfor %}
      @@ -204,13 +239,46 @@ const stateSelect = document.getElementById('app-registry-state'); const sectionSelect = document.getElementById('app-registry-section'); const cards = Array.from(document.querySelectorAll('[data-app-card]')); + const groups = Array.from(document.querySelectorAll('[data-app-group]')); + const groupBodies = Array.from(document.querySelectorAll('[data-app-group-body]')); const form = document.querySelector('form[action$="app-registry/save/"], form[action*="save_portal_app_registry"]') || document.querySelector('.stack-form'); const dirtyState = document.getElementById('app-registry-dirty-state'); + const reorderHint = document.getElementById('app-registry-reorder-hint'); + const SECTION_ORDER = ['app', 'platform', 'admin']; + let draggedCard = null; + + function cardsInDomOrder() { + return groupBodies.flatMap((body) => Array.from(body.querySelectorAll('[data-app-card]'))); + } + + function syncGroupContainers() { + cards.forEach((card) => { + const sectionField = card.querySelector('select[name^="section__"]'); + const targetSection = sectionField ? sectionField.value : card.dataset.section; + const targetBody = document.querySelector(`[data-app-group-body="${targetSection}"]`); + if (targetBody && card.parentElement !== targetBody) { + targetBody.appendChild(card); + } + card.dataset.section = targetSection; + }); + } + + function updateGroupVisibility() { + groups.forEach((group) => { + const visibleCards = group.querySelectorAll('[data-app-card]:not([hidden])').length; + group.hidden = visibleCards === 0; + const countBadge = group.querySelector('[data-app-group-count]'); + if (countBadge) { + countBadge.textContent = String(visibleCards); + } + }); + } function applyFilters() { const query = (searchInput?.value || '').trim().toLowerCase(); const state = stateSelect?.value || 'all'; const section = sectionSelect?.value || 'all'; + const filterActive = Boolean(query) || state !== 'all' || section !== 'all'; cards.forEach((card) => { const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query); @@ -221,20 +289,116 @@ (state === 'platform_only' && card.dataset.platformOnly === '1'); const matchesSection = section === 'all' || card.dataset.section === section; card.hidden = !(matchesQuery && matchesState && matchesSection); + card.draggable = !filterActive; + card.classList.toggle('drag-disabled', filterActive); + }); + + if (reorderHint) { + reorderHint.hidden = !filterActive; + } + updateGroupVisibility(); + } + + function normalizeSortOrders() { + const grouped = new Map(); + const orderedCards = cardsInDomOrder(); + SECTION_ORDER.forEach((key) => grouped.set(key, [])); + + orderedCards.forEach((card) => { + const sectionField = card.querySelector('select[name^="section__"]'); + const sortInput = card.querySelector('[data-sort-order-input]'); + const sectionKey = sectionField ? sectionField.value : card.dataset.section; + const sortValue = sortInput ? parseInt(sortInput.value || '0', 10) : 0; + if (!grouped.has(sectionKey)) grouped.set(sectionKey, []); + grouped.get(sectionKey).push({ + card, + sortInput, + sortValue: Number.isNaN(sortValue) ? 0 : sortValue, + title: (card.dataset.title || '').toLowerCase(), + }); + }); + + grouped.forEach((items, sectionKey) => { + items + .sort((a, b) => { + const domOrder = orderedCards.indexOf(a.card) - orderedCards.indexOf(b.card); + return (a.sortValue - b.sortValue) || domOrder || a.title.localeCompare(b.title); + }) + .forEach((item, index) => { + if (item.sortInput) item.sortInput.value = index; + item.card.dataset.section = sectionKey; + const badge = item.card.querySelector('[data-sort-badge]'); + if (badge) badge.textContent = `{{ sort_label|escapejs }}: ${index}`; + }); }); } - function markDirty() { - if (!dirtyState) return; - dirtyState.textContent = "{{ dirty_state_label|escapejs }}"; + function closestCardAfterPointer(container, clientY) { + const siblingCards = Array.from(container.querySelectorAll('[data-app-card]:not(.is-dragging)')); + return siblingCards.find((card) => { + const rect = card.getBoundingClientRect(); + return clientY < rect.top + rect.height / 2; + }) || null; } - searchInput?.addEventListener('input', applyFilters); - stateSelect?.addEventListener('change', applyFilters); - sectionSelect?.addEventListener('change', applyFilters); - form?.addEventListener('input', markDirty); - form?.addEventListener('change', markDirty); + function markDirty() { + if (dirtyState) { + dirtyState.textContent = '{{ dirty_state_label|escapejs }}'; + } + } + if (form) { + form.addEventListener('change', (event) => { + if (event.target.matches('select[name^="section__"], [data-sort-order-input]')) { + syncGroupContainers(); + normalizeSortOrders(); + } + markDirty(); + applyFilters(); + }); + + form.addEventListener('input', () => { + markDirty(); + }); + } + + cards.forEach((card) => { + card.addEventListener('dragstart', () => { + if (card.classList.contains('drag-disabled')) return; + draggedCard = card; + card.classList.add('is-dragging'); + }); + + card.addEventListener('dragend', () => { + card.classList.remove('is-dragging'); + draggedCard = null; + normalizeSortOrders(); + markDirty(); + applyFilters(); + }); + }); + + groupBodies.forEach((body) => { + body.addEventListener('dragover', (event) => { + if (!draggedCard || draggedCard.classList.contains('drag-disabled')) return; + event.preventDefault(); + const nextCard = closestCardAfterPointer(body, event.clientY); + if (nextCard) { + body.insertBefore(draggedCard, nextCard); + } else { + body.appendChild(draggedCard); + } + }); + }); + + [searchInput, stateSelect, sectionSelect].forEach((control) => { + if (!control) return; + control.addEventListener('input', applyFilters); + control.addEventListener('change', applyFilters); + }); + + syncGroupContainers(); + normalizeSortOrders(); applyFilters(); })(); diff --git a/backend/workflows/templates/workflows/auth/login.html b/backend/workflows/templates/workflows/auth/login.html index ab0ff1d..25e80ee 100644 --- a/backend/workflows/templates/workflows/auth/login.html +++ b/backend/workflows/templates/workflows/auth/login.html @@ -26,12 +26,16 @@ {% endif %} -
      {{ form.username.label_tag }}{{ form.username }}
      -
      {{ form.password.label_tag }}{{ form.password }}
      +
      {{ form.username.label_tag }}{{ form.username }}
      +
      {{ form.password.label_tag }}{{ form.password }}
      +
      + {{ form.otp_code.label_tag }}{{ form.otp_code }} +
      {% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}
      +
      diff --git a/backend/workflows/templates/workflows/branding_settings.html b/backend/workflows/templates/workflows/branding_settings.html index f1a989f..45815cd 100644 --- a/backend/workflows/templates/workflows/branding_settings.html +++ b/backend/workflows/templates/workflows/branding_settings.html @@ -15,71 +15,66 @@ {% include 'workflows/includes/messages.html' %}
      -
      - {% csrf_token %} -
      -
      -
      -

      {% trans "Identität" %}

      -

      {% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}

      +
      + {% for section in branding_sections %} +
      +
      +
      +

      {{ section.title }}

      +

      {{ section.subtitle }}

      -
      -
      - - {{ form.portal_title }} -
      -
      - - {{ form.company_name }} -
      -
      - - {{ form.company_domain }} -
      {% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}
      -
      -
      - - {{ form.default_language }} -
      -
      - - {{ form.login_subtitle }} -
      -
      -
      + +
      -
      -
      -

      {% trans "Farben & Erscheinungsbild" %}

      -

      {% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}

      +
      + {% if section.key == 'legal' %} +
      +
      +

      {% trans "Deutsch" %}

      + {% for row in section.rows|slice:":2" %} +
      + +
      {{ row.value|default:"-" }}
      +
      + {% endfor %} +
      +
      +

      {% trans "English" %}

      + {% for row in section.rows|slice:"2:" %} +
      + +
      {{ row.value|default:"-" }}
      +
      + {% endfor %} +
      + {% else %}
      -
      - - {{ form.primary_color }} -
      -
      - - {{ form.secondary_color }} -
      -
      - - {{ form.logo_image }} -
      {% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}
      - {% for error in form.logo_image.errors %}
      {{ error }}
      {% endfor %} - {% if branding.logo_image %} -
      {% trans "Aktuelles Logo:" %} {% trans "öffnen" %}
      - {% endif %} -
      -
      - - {{ form.favicon_image }} -
      {% trans "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." %}
      - {% for error in form.favicon_image.errors %}
      {{ error }}
      {% endfor %} - {% if branding.favicon_image %} -
      {% trans "Aktuelles Favicon:" %} {% trans "öffnen" %}
      - {% endif %} + {% for row in section.rows %} +
      + +
      + {% if row.is_file %} + {% if row.value %} + {% trans "öffnen" %} + {% else %} + - + {% endif %} + {% else %} + {{ row.value|default:"-" }} + {% endif %} +
      + {% if row.hint %}
      {{ row.hint }}
      {% endif %}
      + {% endfor %} + {% if section.key == 'appearance' %}
      @@ -101,78 +96,121 @@
      + {% endif %}
      -
      + {% endif %} +
      -
      -
      -

      {% trans "Kommunikation" %}

      -

      {% trans "Absender, Support und PDF-Branding für ausgehende Kommunikation." %}

      -
      -
      -
      - - {{ form.support_email }} -
      -
      - - {{ form.sender_display_name }} -
      {% trans "Wird für ausgehende System-E-Mails als Anzeigename verwendet." %}
      -
      -
      - - {{ form.pdf_letterhead }} -
      {% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}
      - {% for error in form.pdf_letterhead.errors %}
      {{ error }}
      {% endfor %} - {% if branding.pdf_letterhead %} -
      {% trans "Aktueller Briefkopf:" %} {% trans "öffnen" %}
      - {% endif %} -
      -
      -
      + + {% csrf_token %} + -
      -
      -

      {% trans "Footer & Rechtliches" %}

      -

      {% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}

      -
      + {% if section.key == 'legal' %}

      {% trans "Deutsch" %}

      -
      - - {{ form.footer_text }} -
      -
      - - {{ form.legal_notice }} + {% for row in section.rows|slice:":2" %} +
      + + {{ row.bound_field }} + {% if row.bound_field.errors %}
      {{ row.bound_field.errors|join:", " }}
      {% endif %}
      + {% endfor %}

      {% trans "English" %}

      -
      - - {{ form.footer_text_en }} -
      -
      - - {{ form.legal_notice_en }} + {% for row in section.rows|slice:"2:" %} +
      + + {{ row.bound_field }} + {% if row.bound_field.errors %}
      {{ row.bound_field.errors|join:", " }}
      {% endif %}
      + {% endfor %}
      -
      -
      + {% else %} +
      + {% for row in section.rows %} +
      + + {{ row.bound_field }} + {% if row.hint %}
      {{ row.hint }}
      {% endif %} + {% if row.is_file and row.value %} +
      + {% if row.name == 'logo_image' %}{% trans "Aktuelles Logo:" %} + {% elif row.name == 'favicon_image' %}{% trans "Aktuelles Favicon:" %} + {% elif row.name == 'pdf_letterhead' %}{% trans "Aktueller Briefkopf:" %} + {% endif %} + {% trans "öffnen" %} +
      + {% endif %} + {% if row.bound_field.errors %}
      {{ row.bound_field.errors|join:", " }}
      {% endif %} +
      + {% endfor %} + {% if section.key == 'appearance' %} +
      +
      +
      +
      + +
      + {{ branding.company_name }} + {{ branding.portal_title }} +
      +
      +
      + {% trans "Primärfarbe" %} + {% trans "Sekundärfarbe" %} +
      + +
      +
      +
      + {% endif %} +
      + {% endif %} + +
      + + +
      +
      +
      + {% endfor %} +
      {% trans "Die aktuell gesetzte Deployment-Branding bleibt erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}
      -
      - +
    {% endblock %} {% block extra_scripts %} +{% endblock %} diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 54d2753..6329e7e 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -3,15 +3,15 @@ {% block title %}{{ portal_title }}{% endblock %} - +{% block shell_header %} +{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_inside_shell=1 %} +{% endblock %} {% block extra_css %} {% endblock %} {% block shell_body %} -{% include 'workflows/includes/app_header.html' with header_show_lang=1 %} -
    diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py index 9716e2e..86b19fa 100644 --- a/backend/workflows/tests/test_account_ui.py +++ b/backend/workflows/tests/test_account_ui.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from django.test import Client, TestCase +from django.utils import timezone from workflows.models import UserProfile +from workflows.totp import generate_totp_token class AccountUISmokeTests(TestCase): @@ -55,3 +57,59 @@ class AccountUISmokeTests(TestCase): self.assertEqual(self.user.email, 'updated@example.com') self.assertEqual(profile.phone_number, '030 123456') self.assertEqual(profile.job_title, 'IT Manager') + + def test_totp_can_be_enabled_from_account(self): + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': '000000', + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + profile = self.user.profile + pending_secret = self.client.session.get('account_totp_pending_secret') + self.assertTrue(pending_secret) + valid_code = generate_totp_token(pending_secret, int(timezone.now().timestamp())) + + response = self.client.post( + '/account/', + { + 'account_form': 'totp_enable', + 'current_password': 'secret-12345', + 'verification_code': valid_code, + }, + HTTP_HOST='localhost', + follow=True, + ) + self.assertEqual(response.status_code, 200) + profile.refresh_from_db() + self.assertTrue(profile.totp_enabled) + self.assertTrue(profile.totp_secret) + + def test_login_requires_totp_when_enabled(self): + profile = self.user.profile + profile.totp_secret = 'JBSWY3DPEHPK3PXP' + profile.totp_enabled = True + profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at']) + + client = Client() + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345'}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'TOTP-Code') + + token = generate_totp_token(profile.totp_secret, int(timezone.now().timestamp())) + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345', 'otp_code': token}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) diff --git a/backend/workflows/totp.py b/backend/workflows/totp.py new file mode 100644 index 0000000..a15063b --- /dev/null +++ b/backend/workflows/totp.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import struct +from urllib.parse import quote + + +def generate_totp_secret(length: int = 20) -> str: + return base64.b32encode(secrets.token_bytes(length)).decode('ascii').rstrip('=') + + +def normalize_totp_token(value: str | None) -> str: + return ''.join(ch for ch in (value or '').strip() if ch.isdigit()) + + +def _secret_bytes(secret: str) -> bytes: + padded = secret.strip().replace(' ', '').upper() + padding = '=' * ((8 - len(padded) % 8) % 8) + return base64.b32decode(padded + padding, casefold=True) + + +def generate_totp_token(secret: str, for_time: int, *, digits: int = 6, period: int = 30) -> str: + counter = int(for_time // period) + key = _secret_bytes(secret) + msg = struct.pack('>Q', counter) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code_int = struct.unpack('>I', digest[offset:offset + 4])[0] & 0x7FFFFFFF + return str(code_int % (10**digits)).zfill(digits) + + +def verify_totp_token(secret: str, token: str, *, for_time: int, digits: int = 6, period: int = 30, window: int = 1) -> bool: + normalized = normalize_totp_token(token) + if len(normalized) != digits: + return False + for offset in range(-window, window + 1): + candidate_time = for_time + (offset * period) + if generate_totp_token(secret, candidate_time, digits=digits, period=period) == normalized: + return True + return False + + +def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str: + label = quote(f'{issuer}:{account_name}') + issuer_q = quote(issuer) + return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30' diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 6b9611a..695f488 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -24,7 +24,7 @@ from django.utils.translation import gettext as _, gettext_lazy from django.utils.translation import get_language, override from django.urls import reverse -from .app_registry import build_portal_app_sections, get_portal_app_registry_rows +from .app_registry import build_portal_app_sections, get_portal_app_registry_rows, normalize_portal_app_sort_orders from .backup_ops import ( create_backup_bundle, delete_backup_bundle, @@ -33,7 +33,7 @@ from .backup_ops import ( verify_backup_bundle, ) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired -from .forms import AccountAvatarForm, AccountDetailsForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm +from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, LOCKED_FIELD_RULES, @@ -46,6 +46,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud +from .totp import build_otpauth_uri, generate_totp_secret from .tasks import ( _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, @@ -128,10 +129,22 @@ def healthz(request): @login_required def account_profile_page(request): + session_secret_key = 'account_totp_pending_secret' profile, created = UserProfile.objects.get_or_create(user=request.user) + pending_totp_secret = request.session.get(session_secret_key) or '' + if profile.totp_enabled: + pending_totp_secret = '' + request.session.pop(session_secret_key, None) + elif not pending_totp_secret: + pending_totp_secret = generate_totp_secret() + request.session[session_secret_key] = pending_totp_secret + avatar_form = AccountAvatarForm(instance=profile) details_form = AccountDetailsForm(user=request.user, profile=profile) + totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret) + totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile) account_edit_open = False + totp_edit_open = False if request.method == 'POST': form_kind = (request.POST.get('account_form') or '').strip() if form_kind == 'avatar': @@ -149,6 +162,28 @@ def account_profile_page(request): messages.success(request, _('Profildaten gespeichert.')) return redirect('account_profile_page') messages.error(request, _('Profildaten konnten nicht gespeichert werden.')) + elif form_kind == 'totp_enable': + totp_edit_open = True + totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret) + if totp_enable_form.is_valid(): + profile.enable_totp(pending_totp_secret) + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde aktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht aktiviert werden.')) + elif form_kind == 'totp_disable': + totp_edit_open = True + totp_disable_form = AccountTOTPDisableForm(request.POST, user=request.user, profile=profile) + if totp_disable_form.is_valid(): + profile.disable_totp() + request.session.pop(session_secret_key, None) + messages.success(request, _('TOTP wurde deaktiviert.')) + return redirect('account_profile_page') + messages.error(request, _('TOTP konnte nicht deaktiviert werden.')) + + branding_context = get_branding_email_copy() + totp_account_name = (request.user.email or request.user.username or '').strip() + totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip() return render( request, 'workflows/account_profile.html', @@ -157,8 +192,13 @@ def account_profile_page(request): 'account_user_profile': profile, 'avatar_form': avatar_form, 'details_form': details_form, + 'totp_enable_form': totp_enable_form, + 'totp_disable_form': totp_disable_form, 'account_edit_open': account_edit_open, + 'totp_edit_open': totp_edit_open, 'role_label': get_user_role_label(request.user), + 'totp_pending_secret': pending_totp_secret, + 'totp_otpauth_uri': '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer), }, ) @@ -426,6 +466,7 @@ def job_monitor_page(request): @require_POST def save_portal_app_registry(request): rows = get_portal_app_registry_rows() + updated_configs = [] for row in rows: config = row['config'] key = config.key @@ -448,6 +489,9 @@ def save_portal_app_registry(request): config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip() config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip() config.save() + updated_configs.append(config) + + normalize_portal_app_sort_orders() _audit( request, @@ -607,6 +651,8 @@ def portal_branding_page(request): { 'form': form, 'branding': branding, + 'branding_sections': _build_branding_sections(form, branding), + 'editing_branding_section': '', }, ) @@ -615,7 +661,18 @@ def portal_branding_page(request): @require_POST def save_portal_branding(request): branding, created = PortalBranding.objects.get_or_create(name='Default') - form = PortalBrandingForm(request.POST, request.FILES, instance=branding) + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalBrandingForm.Meta.fields: + if field_name not in data: + field = PortalBranding._meta.get_field(field_name) + if getattr(field, 'many_to_many', False): + continue + if getattr(field, 'null', False) and getattr(branding, field_name, None) is None: + data[field_name] = '' + else: + data[field_name] = getattr(branding, field_name, '') or '' + form = PortalBrandingForm(data, request.FILES, instance=branding) if not form.is_valid(): messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) return render( @@ -624,6 +681,8 @@ def save_portal_branding(request): { 'form': form, 'branding': branding, + 'branding_sections': _build_branding_sections(form, branding), + 'editing_branding_section': section_key, }, status=400, ) @@ -650,10 +709,76 @@ def save_portal_branding(request): { 'form': PortalBrandingForm(instance=branding), 'branding': branding, + 'branding_sections': _build_branding_sections(PortalBrandingForm(instance=branding), branding), + 'editing_branding_section': '', }, ) +def _build_branding_sections(form, branding): + sections = [ + { + 'key': 'identity', + 'title': _('Identität'), + 'subtitle': _('Titel, Firmenname und zentrale Spracheinstellungen.'), + 'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'], + 'field_full': {'login_subtitle'}, + 'hint_map': { + 'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co.'), + }, + }, + { + 'key': 'appearance', + 'title': _('Farben & Erscheinungsbild'), + 'subtitle': _('Zentrale visuelle Markenwerte und Browser-Icon.'), + 'fields': ['primary_color', 'secondary_color', 'logo_image', 'favicon_image'], + 'field_full': set(), + 'hint_map': { + 'logo_image': _('Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB.'), + 'favicon_image': _('Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB.'), + }, + }, + { + 'key': 'communication', + 'title': _('Kommunikation'), + 'subtitle': _('Absender, Support und PDF-Branding für ausgehende Kommunikation.'), + 'fields': ['support_email', 'sender_display_name', 'pdf_letterhead'], + 'field_full': {'pdf_letterhead'}, + 'hint_map': { + 'sender_display_name': _('Wird für ausgehende System-E-Mails als Anzeigename verwendet.'), + 'pdf_letterhead': _('Erlaubtes Format: PDF. Maximal 10 MB.'), + }, + }, + { + 'key': 'legal', + 'title': _('Footer & Rechtliches'), + 'subtitle': _('Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell.'), + 'fields': ['footer_text', 'legal_notice', 'footer_text_en', 'legal_notice_en'], + 'field_full': {'legal_notice', 'legal_notice_en'}, + 'hint_map': {}, + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + value = getattr(branding, field_name, '') or '' + is_file = bool(getattr(field.field.widget, 'input_type', '') == 'file') + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': value, + 'is_file': is_file, + 'is_full': field_name in section.get('field_full', set()), + 'hint': section.get('hint_map', {}).get(field_name, ''), + } + ) + section['rows'] = rows + return sections + + @_require_capability('manage_company_config') def portal_company_config_page(request): company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') @@ -664,6 +789,8 @@ def portal_company_config_page(request): { 'form': form, 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(form, company_config), + 'editing_company_section': '', }, ) @@ -672,7 +799,12 @@ def portal_company_config_page(request): @require_POST def save_portal_company_config(request): company_config, created = PortalCompanyConfig.objects.get_or_create(name='Default') - form = PortalCompanyConfigForm(request.POST, instance=company_config) + section_key = (request.POST.get('section_key') or '').strip() + data = request.POST.copy() + for field_name in PortalCompanyConfigForm.Meta.fields: + if field_name not in data: + data[field_name] = getattr(company_config, field_name, '') or '' + form = PortalCompanyConfigForm(data, instance=company_config) if not form.is_valid(): messages.error(request, _('Firmenkonfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.')) return render( @@ -681,6 +813,8 @@ def save_portal_company_config(request): { 'form': form, 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(form, company_config), + 'editing_company_section': section_key, }, status=400, ) @@ -708,10 +842,56 @@ def save_portal_company_config(request): { 'form': PortalCompanyConfigForm(instance=company_config), 'company_config': company_config, + 'company_config_sections': _build_company_config_sections(PortalCompanyConfigForm(instance=company_config), company_config), + 'editing_company_section': '', }, ) +def _build_company_config_sections(form, company_config): + sections = [ + { + 'key': 'profile', + 'title': _('Firmenprofil'), + 'subtitle': _('Rechtlicher Name und zentrale Stammdaten der Firma.'), + 'fields': ['legal_company_name', 'phone_number', 'website_url', 'country'], + }, + { + 'key': 'address', + 'title': _('Adresse & Register'), + 'subtitle': _('Anschrift sowie optionale Register- und Steuerangaben.'), + 'fields': ['street_address', 'postal_code', 'city', 'registration_number', 'vat_id'], + }, + { + 'key': 'contacts', + 'title': _('Kontaktpunkte'), + 'subtitle': _('Zentrale Ansprechpartner für HR, IT und Operations.'), + 'fields': ['hr_contact_email', 'it_contact_email', 'operations_contact_email'], + }, + { + 'key': 'public', + 'title': _('Recht & Öffentlichkeit'), + 'subtitle': _('Öffentliche Links für Website, Impressum und Datenschutz.'), + 'fields': ['imprint_url', 'privacy_url'], + 'hint': _('Diese Links können später im Portal-Footer oder in öffentlichen Seiten verwendet werden.'), + }, + ] + for section in sections: + rows = [] + for field_name in section['fields']: + field = form[field_name] + rows.append( + { + 'name': field_name, + 'bound_field': field, + 'label': field.label, + 'value': getattr(company_config, field_name, '') or '', + } + ) + section['rows'] = rows + return sections + + @_require_capability('manage_trial_lifecycle') def portal_trial_config_page(request): trial_config = get_portal_trial_config() From f2c9b3b65db582a1a454dc69d2497f44bfca9182 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 03:04:02 +0100 Subject: [PATCH 14/45] snapshot: preserve account security and profile UI cleanup --- backend/locale/en/LC_MESSAGES/django.po | 563 ++++++++++-------- backend/requirements.txt | 1 + backend/workflows/forms.py | 89 ++- .../0050_userprofile_totp_recovery_codes.py | 16 + backend/workflows/models.py | 26 +- .../static/workflows/css/account.css | 145 +++-- .../static/workflows/css/app_chrome.css | 11 + .../templates/workflows/account_profile.html | 141 +++-- .../templates/workflows/auth/login.html | 4 + backend/workflows/tests/test_account_ui.py | 15 +- backend/workflows/totp.py | 17 + backend/workflows/views.py | 41 +- 12 files changed, 699 insertions(+), 370 deletions(-) create mode 100644 backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 99412a3..2a65f22 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 01:45+0000\n" +"POT-Creation-Date: 2026-03-27 01:48+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/app_registry.py:35 workflows/models.py:389 workflows/models.py:470 +#: workflows/app_registry.py:35 workflows/models.py:409 workflows/models.py:490 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:46 workflows/models.py:390 workflows/models.py:471 +#: workflows/app_registry.py:46 workflows/models.py:410 workflows/models.py:491 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -263,7 +263,7 @@ msgstr "" msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:317 workflows/models.py:166 +#: workflows/app_registry.py:317 workflows/models.py:186 #: workflows/templates/workflows/app_registry.html:44 #: workflows/templates/workflows/app_registry.html:100 msgid "Apps" @@ -273,7 +273,7 @@ msgstr "Apps" msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:323 workflows/models.py:167 +#: workflows/app_registry.py:323 workflows/models.py:187 #: workflows/templates/workflows/app_registry.html:45 #: workflows/templates/workflows/app_registry.html:96 msgid "Platform Apps" @@ -285,7 +285,7 @@ msgstr "" msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:329 workflows/models.py:168 +#: workflows/app_registry.py:329 workflows/models.py:188 #: workflows/templates/workflows/app_registry.html:46 #: workflows/templates/workflows/app_registry.html:98 msgid "Admin Apps" @@ -384,7 +384,7 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:106 workflows/forms.py:326 +#: workflows/forms.py:106 workflows/forms.py:399 #: workflows/templates/workflows/account_profile.html:66 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 @@ -395,77 +395,84 @@ msgstr "" msgid "Passwort" msgstr "Password" -#: workflows/forms.py:109 workflows/forms.py:265 workflows/forms.py:297 +#: workflows/forms.py:109 workflows/forms.py:279 workflows/forms.py:311 +#: workflows/forms.py:357 msgid "TOTP-Code" msgstr "" -#: workflows/forms.py:117 workflows/forms.py:286 workflows/forms.py:319 +#: workflows/forms.py:115 workflows/forms.py:317 workflows/forms.py:363 +msgid "Recovery-Code" +msgstr "" + +#: workflows/forms.py:123 workflows/forms.py:300 workflows/forms.py:343 +#: workflows/forms.py:389 msgid "Der TOTP-Code ist ungültig." msgstr "" -#: workflows/forms.py:118 +#: workflows/forms.py:124 msgid "Bitte geben Sie Ihren TOTP-Code ein." msgstr "" -#: workflows/forms.py:143 workflows/forms.py:207 workflows/forms.py:327 +#: workflows/forms.py:157 workflows/forms.py:221 workflows/forms.py:400 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:148 workflows/forms.py:167 +#: workflows/forms.py:162 workflows/forms.py:181 #: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:154 workflows/forms.py:173 +#: workflows/forms.py:168 workflows/forms.py:187 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:162 workflows/forms.py:260 workflows/forms.py:292 +#: workflows/forms.py:176 workflows/forms.py:274 workflows/forms.py:306 +#: workflows/forms.py:352 #, fuzzy #| msgid "Neues Passwort" msgid "Aktuelles Passwort" msgstr "New password" -#: workflows/forms.py:184 workflows/templates/workflows/account_profile.html:36 +#: workflows/forms.py:198 workflows/templates/workflows/account_profile.html:36 #: workflows/templates/workflows/includes/app_header.html:27 msgid "Profilbild" msgstr "" -#: workflows/forms.py:200 +#: workflows/forms.py:214 msgid "Das Profilbild darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:205 workflows/forms.py:324 +#: workflows/forms.py:219 workflows/forms.py:397 #: workflows/templates/workflows/account_profile.html:112 msgid "Vorname" msgstr "" -#: workflows/forms.py:206 workflows/forms.py:325 +#: workflows/forms.py:220 workflows/forms.py:398 #: workflows/templates/workflows/account_profile.html:116 msgid "Nachname" msgstr "" -#: workflows/forms.py:208 +#: workflows/forms.py:222 #: workflows/templates/workflows/account_profile.html:120 msgid "Telefon" msgstr "" -#: workflows/forms.py:209 +#: workflows/forms.py:223 #: workflows/templates/workflows/account_profile.html:124 msgid "Mobil" msgstr "" -#: workflows/forms.py:210 workflows/templates/workflows/account_profile.html:70 +#: workflows/forms.py:224 workflows/templates/workflows/account_profile.html:70 #: workflows/templates/workflows/account_profile.html:128 #, fuzzy #| msgid "Produktion" msgid "Position" msgstr "Production" -#: workflows/forms.py:211 workflows/models.py:350 +#: workflows/forms.py:225 workflows/models.py:370 #: workflows/templates/workflows/account_profile.html:74 #: workflows/templates/workflows/account_profile.html:132 #: workflows/templates/workflows/onboarding_intro_session.html:28 @@ -473,27 +480,35 @@ msgstr "Production" msgid "Abteilung" msgstr "Department" -#: workflows/forms.py:212 +#: workflows/forms.py:226 #: workflows/templates/workflows/account_profile.html:136 msgid "Standort" msgstr "" -#: workflows/forms.py:214 +#: workflows/forms.py:228 #: workflows/templates/workflows/account_profile.html:140 #, fuzzy #| msgid "Einweisung" msgid "Hinweise" msgstr "Introduction" -#: workflows/forms.py:278 workflows/forms.py:310 +#: workflows/forms.py:292 workflows/forms.py:331 workflows/forms.py:377 msgid "Das aktuelle Passwort ist nicht korrekt." msgstr "" -#: workflows/forms.py:284 workflows/forms.py:316 +#: workflows/forms.py:298 msgid "Bitte geben Sie einen gültigen TOTP-Code ein." msgstr "" -#: workflows/forms.py:328 workflows/templates/workflows/account_profile.html:62 +#: workflows/forms.py:339 workflows/forms.py:385 +msgid "Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein." +msgstr "" + +#: workflows/forms.py:346 workflows/forms.py:392 +msgid "Der Recovery-Code ist ungültig." +msgstr "" + +#: workflows/forms.py:401 workflows/templates/workflows/account_profile.html:62 #: workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 @@ -502,207 +517,207 @@ msgstr "" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:342 +#: workflows/forms.py:415 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:351 workflows/views.py:987 +#: workflows/forms.py:424 workflows/views.py:1020 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:353 workflows/views.py:990 +#: workflows/forms.py:426 workflows/views.py:1023 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:392 +#: workflows/forms.py:465 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:393 +#: workflows/forms.py:466 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:394 +#: workflows/forms.py:467 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:395 +#: workflows/forms.py:468 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:396 +#: workflows/forms.py:469 msgid "Absender-Anzeigename" msgstr "" -#: workflows/forms.py:397 +#: workflows/forms.py:470 msgid "Login-Untertitel" msgstr "" -#: workflows/forms.py:398 +#: workflows/forms.py:471 msgid "Footer-Text DE" msgstr "" -#: workflows/forms.py:399 +#: workflows/forms.py:472 msgid "Footer-Text EN" msgstr "" -#: workflows/forms.py:400 +#: workflows/forms.py:473 msgid "Rechtlicher Hinweis DE" msgstr "" -#: workflows/forms.py:401 +#: workflows/forms.py:474 msgid "Rechtlicher Hinweis EN" msgstr "" -#: workflows/forms.py:402 +#: workflows/forms.py:475 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:403 +#: workflows/forms.py:476 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:404 +#: workflows/forms.py:477 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:405 +#: workflows/forms.py:478 msgid "Favicon" msgstr "" -#: workflows/forms.py:406 +#: workflows/forms.py:479 #: workflows/templates/workflows/branding_settings.html:89 #: workflows/templates/workflows/branding_settings.html:162 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:407 +#: workflows/forms.py:480 #: workflows/templates/workflows/branding_settings.html:90 #: workflows/templates/workflows/branding_settings.html:163 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:424 +#: workflows/forms.py:497 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:432 +#: workflows/forms.py:505 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:440 +#: workflows/forms.py:513 msgid "Das Favicon darf maximal 2 MB groß sein." msgstr "" -#: workflows/forms.py:464 +#: workflows/forms.py:537 #, fuzzy #| msgid "Firmenname" msgid "Rechtlicher Firmenname" msgstr "Company name" -#: workflows/forms.py:465 +#: workflows/forms.py:538 msgid "Straße und Hausnummer" msgstr "" -#: workflows/forms.py:466 +#: workflows/forms.py:539 msgid "Postleitzahl" msgstr "" -#: workflows/forms.py:467 +#: workflows/forms.py:540 msgid "Stadt" msgstr "" -#: workflows/forms.py:468 +#: workflows/forms.py:541 msgid "Land" msgstr "" -#: workflows/forms.py:469 workflows/templates/workflows/base_shell.html:64 +#: workflows/forms.py:542 workflows/templates/workflows/base_shell.html:64 msgid "Website" msgstr "" -#: workflows/forms.py:470 +#: workflows/forms.py:543 msgid "Impressum-URL" msgstr "" -#: workflows/forms.py:471 +#: workflows/forms.py:544 msgid "Datenschutz-URL" msgstr "" -#: workflows/forms.py:472 +#: workflows/forms.py:545 msgid "HR-Kontakt" msgstr "" -#: workflows/forms.py:473 +#: workflows/forms.py:546 msgid "IT-Kontakt" msgstr "" -#: workflows/forms.py:474 +#: workflows/forms.py:547 #, fuzzy #| msgid "Operations" msgid "Operations-Kontakt" msgstr "Operations" -#: workflows/forms.py:475 +#: workflows/forms.py:548 msgid "Zentrale Telefonnummer" msgstr "" -#: workflows/forms.py:476 +#: workflows/forms.py:549 msgid "USt-IdNr." msgstr "" -#: workflows/forms.py:477 +#: workflows/forms.py:550 msgid "Register- oder Handelsnummer" msgstr "" -#: workflows/forms.py:494 +#: workflows/forms.py:567 msgid "Trial-Modus aktiv" msgstr "" -#: workflows/forms.py:495 +#: workflows/forms.py:568 msgid "Trial-Beginn" msgstr "" -#: workflows/forms.py:496 +#: workflows/forms.py:569 msgid "Trial-Ende" msgstr "" -#: workflows/forms.py:497 +#: workflows/forms.py:570 msgid "Produktive Integrationen begrenzen" msgstr "" -#: workflows/forms.py:498 +#: workflows/forms.py:571 msgid "Cleanup nach Ablauf zulassen" msgstr "" -#: workflows/forms.py:499 +#: workflows/forms.py:572 msgid "Banner-Text DE" msgstr "" -#: workflows/forms.py:500 +#: workflows/forms.py:573 msgid "Banner-Text EN" msgstr "" -#: workflows/forms.py:520 +#: workflows/forms.py:593 msgid "Bitte ein Trial-Ende festlegen." msgstr "" -#: workflows/forms.py:522 +#: workflows/forms.py:595 msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:661 workflows/forms.py:846 +#: workflows/forms.py:734 workflows/forms.py:919 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:683 workflows/forms.py:860 +#: workflows/forms.py:756 workflows/forms.py:933 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:768 +#: workflows/forms.py:841 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " @@ -754,251 +769,251 @@ msgid "" "ausführen." msgstr "" -#: workflows/models.py:215 workflows/views.py:460 +#: workflows/models.py:235 workflows/views.py:493 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:216 workflows/views.py:460 +#: workflows/models.py:236 workflows/views.py:493 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:217 workflows/models.py:270 workflows/models.py:524 +#: workflows/models.py:237 workflows/models.py:290 workflows/models.py:544 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:286 -#: workflows/views.py:460 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:319 +#: workflows/views.py:493 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:267 workflows/views.py:283 +#: workflows/models.py:287 workflows/views.py:316 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:268 workflows/views.py:284 +#: workflows/models.py:288 workflows/views.py:317 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:269 workflows/models.py:584 workflows/views.py:285 +#: workflows/models.py:289 workflows/models.py:604 workflows/views.py:318 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:277 +#: workflows/models.py:297 msgid "Herr" msgstr "" -#: workflows/models.py:277 +#: workflows/models.py:297 msgid "Frau" msgstr "" -#: workflows/models.py:277 +#: workflows/models.py:297 msgid "Divers" msgstr "" -#: workflows/models.py:287 +#: workflows/models.py:307 msgid "befristet" msgstr "" -#: workflows/models.py:287 +#: workflows/models.py:307 msgid "unbefristet" msgstr "" -#: workflows/models.py:351 +#: workflows/models.py:371 msgid "Geräte" msgstr "" -#: workflows/models.py:352 +#: workflows/models.py:372 msgid "Software" msgstr "" -#: workflows/models.py:353 +#: workflows/models.py:373 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:354 +#: workflows/models.py:374 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:355 +#: workflows/models.py:375 msgid "Ressourcen" msgstr "" -#: workflows/models.py:356 +#: workflows/models.py:376 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:382 +#: workflows/models.py:402 msgid "Automatisch" msgstr "" -#: workflows/models.py:383 workflows/views.py:102 +#: workflows/models.py:403 workflows/views.py:103 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:384 workflows/views.py:103 +#: workflows/models.py:404 workflows/views.py:104 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:385 workflows/views.py:104 +#: workflows/models.py:405 workflows/views.py:105 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:386 workflows/views.py:105 +#: workflows/models.py:406 workflows/views.py:106 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:428 +#: workflows/models.py:448 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:429 +#: workflows/models.py:449 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:430 +#: workflows/models.py:450 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:431 +#: workflows/models.py:451 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:432 +#: workflows/models.py:452 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:433 +#: workflows/models.py:453 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:434 +#: workflows/models.py:454 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:435 +#: workflows/models.py:455 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:436 +#: workflows/models.py:456 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:437 +#: workflows/models.py:457 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:438 +#: workflows/models.py:458 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:474 +#: workflows/models.py:494 msgid "Immer" msgstr "" -#: workflows/models.py:475 workflows/models.py:553 +#: workflows/models.py:495 workflows/models.py:573 msgid "Enthält" msgstr "" -#: workflows/models.py:476 workflows/models.py:554 +#: workflows/models.py:496 workflows/models.py:574 msgid "Ist gleich" msgstr "" -#: workflows/models.py:477 +#: workflows/models.py:497 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:478 +#: workflows/models.py:498 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:520 +#: workflows/models.py:540 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:521 +#: workflows/models.py:541 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:522 +#: workflows/models.py:542 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:523 +#: workflows/models.py:543 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:546 workflows/tasks.py:600 +#: workflows/models.py:566 workflows/tasks.py:600 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:547 workflows/tasks.py:601 +#: workflows/models.py:567 workflows/tasks.py:601 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:548 workflows/tasks.py:602 +#: workflows/models.py:568 workflows/tasks.py:602 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:549 workflows/tasks.py:603 +#: workflows/models.py:569 workflows/tasks.py:603 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:552 +#: workflows/models.py:572 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:555 +#: workflows/models.py:575 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:556 +#: workflows/models.py:576 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:583 +#: workflows/models.py:603 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:603 +#: workflows/models.py:623 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:604 +#: workflows/models.py:624 msgid "S3" msgstr "" -#: workflows/models.py:605 +#: workflows/models.py:625 msgid "NFS" msgstr "" @@ -1133,7 +1148,7 @@ msgid "" msgstr "" #: workflows/templates/registration/login.html:37 -#: workflows/templates/workflows/auth/login.html:39 +#: workflows/templates/workflows/auth/login.html:43 msgid "Anmelden" msgstr "Sign in" @@ -1318,7 +1333,7 @@ msgid "Direkte Aktionen für Ihr Workdock-Konto." msgstr "Direct actions for your Workdock account." #: workflows/templates/workflows/account_profile.html:179 -#: workflows/templates/workflows/account_profile.html:278 +#: workflows/templates/workflows/account_profile.html:315 #: workflows/templates/workflows/auth/password_change_form.html:4 #: workflows/templates/workflows/auth/password_change_form.html:17 #: workflows/templates/workflows/includes/app_header.html:48 @@ -1392,31 +1407,45 @@ msgstr "" msgid "TOTP deaktivieren" msgstr "Enable" -#: workflows/templates/workflows/account_profile.html:247 +#: workflows/templates/workflows/account_profile.html:259 +msgid "Recovery-Codes neu erzeugen" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:268 #, fuzzy #| msgid "Onboarding starten" msgid "Manueller Schlüssel" msgstr "Start onboarding" -#: workflows/templates/workflows/account_profile.html:251 +#: workflows/templates/workflows/account_profile.html:272 #, fuzzy #| msgid "Setup Mail" msgid "Setup-Link" msgstr "Setup Mail" -#: workflows/templates/workflows/account_profile.html:255 +#: workflows/templates/workflows/account_profile.html:276 msgid "" "Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den " "otpauth-Link manuell ein." msgstr "" -#: workflows/templates/workflows/account_profile.html:271 +#: workflows/templates/workflows/account_profile.html:292 #, fuzzy #| msgid "Aktivieren" msgid "TOTP aktivieren" msgstr "Enable" -#: workflows/templates/workflows/account_profile.html:281 +#: workflows/templates/workflows/account_profile.html:301 +msgid "Recovery-Codes" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:302 +msgid "" +"Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie " +"genau einmal verwenden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:318 #: workflows/templates/workflows/includes/app_header.html:51 msgid "Abmelden" msgstr "Log out" @@ -1731,6 +1760,10 @@ msgstr "" msgid "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." msgstr "" +#: workflows/templates/workflows/auth/login.html:41 +msgid "Alternativ können Sie einen einmaligen Recovery-Code verwenden." +msgstr "" + #: workflows/templates/workflows/auth/password_change_done.html:4 #: workflows/templates/workflows/auth/password_change_done.html:17 #: workflows/templates/workflows/user_management.html:174 @@ -2877,7 +2910,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1242 +#: workflows/views.py:1275 msgid "Vertragsbeginn" msgstr "Contract start" @@ -3681,302 +3714,312 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:102 +#: workflows/views.py:103 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:103 +#: workflows/views.py:104 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:104 +#: workflows/views.py:105 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:105 +#: workflows/views.py:106 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:154 +#: workflows/views.py:158 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:156 +#: workflows/views.py:160 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:162 +#: workflows/views.py:166 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:164 +#: workflows/views.py:168 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:171 +#: workflows/views.py:177 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP wurde aktiviert." msgstr "Disabled" -#: workflows/views.py:173 +#: workflows/views.py:179 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht aktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:180 +#: workflows/views.py:186 msgid "TOTP wurde deaktiviert." msgstr "" -#: workflows/views.py:182 +#: workflows/views.py:188 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht deaktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:212 workflows/views.py:1328 workflows/views.py:1333 +#: workflows/views.py:197 +msgid "Recovery-Codes wurden neu erzeugt." +msgstr "" + +#: workflows/views.py:199 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Recovery-Codes konnten nicht neu erzeugt werden." +msgstr "Password could not be saved" + +#: workflows/views.py:245 workflows/views.py:1361 workflows/views.py:1366 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:293 +#: workflows/views.py:326 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:294 +#: workflows/views.py:327 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:295 +#: workflows/views.py:328 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:296 +#: workflows/views.py:329 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:297 +#: workflows/views.py:330 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:298 +#: workflows/views.py:331 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:299 +#: workflows/views.py:332 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:300 +#: workflows/views.py:333 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:301 +#: workflows/views.py:334 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:302 +#: workflows/views.py:335 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:303 +#: workflows/views.py:336 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:304 +#: workflows/views.py:337 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:305 +#: workflows/views.py:338 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:306 +#: workflows/views.py:339 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:307 +#: workflows/views.py:340 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:308 +#: workflows/views.py:341 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:309 +#: workflows/views.py:342 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:310 +#: workflows/views.py:343 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:311 +#: workflows/views.py:344 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:312 +#: workflows/views.py:345 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:313 +#: workflows/views.py:346 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:314 +#: workflows/views.py:347 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:315 +#: workflows/views.py:348 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:316 +#: workflows/views.py:349 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:317 +#: workflows/views.py:350 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:318 +#: workflows/views.py:351 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:319 +#: workflows/views.py:352 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:320 +#: workflows/views.py:353 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:321 +#: workflows/views.py:354 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:322 +#: workflows/views.py:355 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:323 +#: workflows/views.py:356 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:324 +#: workflows/views.py:357 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:325 +#: workflows/views.py:358 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:326 +#: workflows/views.py:359 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:327 +#: workflows/views.py:360 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:328 +#: workflows/views.py:361 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:329 +#: workflows/views.py:362 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:330 +#: workflows/views.py:363 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:331 +#: workflows/views.py:364 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:503 +#: workflows/views.py:536 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:602 +#: workflows/views.py:635 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:611 +#: workflows/views.py:644 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:613 +#: workflows/views.py:646 #, python-format msgid "" "Hallo %(name)s,\n" @@ -3989,12 +4032,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:624 +#: workflows/views.py:657 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:626 +#: workflows/views.py:659 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4007,7 +4050,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:677 +#: workflows/views.py:710 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4015,69 +4058,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:705 +#: workflows/views.py:738 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:722 +#: workflows/views.py:755 msgid "Identität" msgstr "" -#: workflows/views.py:723 +#: workflows/views.py:756 msgid "Titel, Firmenname und zentrale Spracheinstellungen." msgstr "" -#: workflows/views.py:727 +#: workflows/views.py:760 msgid "" "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " "B. tub.co." msgstr "" -#: workflows/views.py:732 +#: workflows/views.py:765 msgid "Farben & Erscheinungsbild" msgstr "" -#: workflows/views.py:733 +#: workflows/views.py:766 msgid "Zentrale visuelle Markenwerte und Browser-Icon." msgstr "" -#: workflows/views.py:737 +#: workflows/views.py:770 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/views.py:738 +#: workflows/views.py:771 msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." msgstr "" -#: workflows/views.py:743 +#: workflows/views.py:776 #, fuzzy #| msgid "Produktion" msgid "Kommunikation" msgstr "Production" -#: workflows/views.py:744 +#: workflows/views.py:777 msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." msgstr "" -#: workflows/views.py:748 +#: workflows/views.py:781 msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." msgstr "" -#: workflows/views.py:749 +#: workflows/views.py:782 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/views.py:754 +#: workflows/views.py:787 msgid "Footer & Rechtliches" msgstr "" -#: workflows/views.py:755 +#: workflows/views.py:788 msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." msgstr "" -#: workflows/views.py:809 +#: workflows/views.py:842 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4086,53 +4129,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:838 +#: workflows/views.py:871 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:855 +#: workflows/views.py:888 #, fuzzy #| msgid "Firmenname" msgid "Firmenprofil" msgstr "Company name" -#: workflows/views.py:856 +#: workflows/views.py:889 msgid "Rechtlicher Name und zentrale Stammdaten der Firma." msgstr "" -#: workflows/views.py:861 +#: workflows/views.py:894 msgid "Adresse & Register" msgstr "" -#: workflows/views.py:862 +#: workflows/views.py:895 msgid "Anschrift sowie optionale Register- und Steuerangaben." msgstr "" -#: workflows/views.py:867 +#: workflows/views.py:900 msgid "Kontaktpunkte" msgstr "" -#: workflows/views.py:868 +#: workflows/views.py:901 msgid "Zentrale Ansprechpartner für HR, IT und Operations." msgstr "" -#: workflows/views.py:873 +#: workflows/views.py:906 msgid "Recht & Öffentlichkeit" msgstr "" -#: workflows/views.py:874 +#: workflows/views.py:907 msgid "Öffentliche Links für Website, Impressum und Datenschutz." msgstr "" -#: workflows/views.py:876 +#: workflows/views.py:909 msgid "" "Diese Links können später im Portal-Footer oder in öffentlichen Seiten " "verwendet werden." msgstr "" -#: workflows/views.py:916 +#: workflows/views.py:949 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4141,21 +4184,21 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:943 +#: workflows/views.py:976 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:960 +#: workflows/views.py:993 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:973 +#: workflows/views.py:1006 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:995 +#: workflows/views.py:1028 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4166,14 +4209,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:998 +#: workflows/views.py:1031 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1001 +#: workflows/views.py:1034 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4184,7 +4227,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1004 +#: workflows/views.py:1037 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4195,18 +4238,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1021 +#: workflows/views.py:1054 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:1043 +#: workflows/views.py:1076 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1055 +#: workflows/views.py:1088 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4216,7 +4259,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1058 +#: workflows/views.py:1091 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4226,7 +4269,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1061 +#: workflows/views.py:1094 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4235,7 +4278,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1064 +#: workflows/views.py:1097 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4244,121 +4287,121 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1077 +#: workflows/views.py:1110 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1166 +#: workflows/views.py:1199 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:1168 +#: workflows/views.py:1201 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1184 +#: workflows/views.py:1217 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1186 +#: workflows/views.py:1219 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1202 +#: workflows/views.py:1235 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1204 +#: workflows/views.py:1237 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1230 +#: workflows/views.py:1263 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1232 +#: workflows/views.py:1265 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1244 +#: workflows/views.py:1277 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1254 +#: workflows/views.py:1287 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1256 +#: workflows/views.py:1289 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1265 +#: workflows/views.py:1298 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1291 +#: workflows/views.py:1324 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1303 +#: workflows/views.py:1336 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/views.py:1342 +#: workflows/views.py:1375 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1385 +#: workflows/views.py:1418 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1387 +#: workflows/views.py:1420 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1389 +#: workflows/views.py:1422 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1617 +#: workflows/views.py:1650 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1634 +#: workflows/views.py:1667 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1663 +#: workflows/views.py:1696 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1677 +#: workflows/views.py:1710 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1690 +#: workflows/views.py:1723 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." diff --git a/backend/requirements.txt b/backend/requirements.txt index 918b900..172d22d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ pypdf==5.1.0 jinja2==3.1.4 xhtml2pdf==0.2.16 gunicorn==23.0.0 +qrcode==8.2 diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index ccb0a3d..a9f71a7 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -11,7 +11,7 @@ from .branding import get_company_email_domain from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role -from .totp import normalize_totp_token, verify_totp_token +from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -111,6 +111,12 @@ class AppAuthenticationForm(AuthenticationForm): max_length=12, widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), ) + recovery_code = forms.CharField( + label=gettext_lazy('Recovery-Code'), + required=False, + max_length=32, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), + ) error_messages = { **AuthenticationForm.error_messages, @@ -126,6 +132,14 @@ class AppAuthenticationForm(AuthenticationForm): profile, _ = UserProfile.objects.get_or_create(user=user) if profile.totp_enabled: otp_code = normalize_totp_token(cleaned_data.get('otp_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if recovery_code: + if not profile.consume_recovery_code(recovery_code): + raise ValidationError( + self.error_messages['invalid_otp'], + code='invalid_otp', + ) + return cleaned_data if not otp_code: raise ValidationError( self.error_messages['missing_otp'], @@ -296,8 +310,15 @@ class AccountTOTPDisableForm(forms.Form): verification_code = forms.CharField( label=gettext_lazy('TOTP-Code'), max_length=12, + required=False, widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), ) + recovery_code = forms.CharField( + label=gettext_lazy('Recovery-Code'), + max_length=32, + required=False, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), + ) def __init__(self, *args, user=None, profile=None, **kwargs): super().__init__(*args, **kwargs) @@ -310,14 +331,66 @@ class AccountTOTPDisableForm(forms.Form): raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) return password - def clean_verification_code(self): - code = normalize_totp_token(self.cleaned_data.get('verification_code')) - if not code: - raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code ein.')) + def clean(self): + cleaned_data = super().clean() + code = normalize_totp_token(cleaned_data.get('verification_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if not code and not recovery_code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.')) secret = getattr(self.profile, 'totp_secret', '') or '' - if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): - raise ValidationError(_('Der TOTP-Code ist ungültig.')) - return code + if code: + if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return cleaned_data + if not self.profile.consume_recovery_code(recovery_code): + raise ValidationError(_('Der Recovery-Code ist ungültig.')) + return cleaned_data + + +class AccountTOTPRegenerateRecoveryCodesForm(forms.Form): + current_password = forms.CharField( + label=gettext_lazy('Aktuelles Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + verification_code = forms.CharField( + label=gettext_lazy('TOTP-Code'), + max_length=12, + required=False, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), + ) + recovery_code = forms.CharField( + label=gettext_lazy('Recovery-Code'), + max_length=32, + required=False, + widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), + ) + + def __init__(self, *args, user=None, profile=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + self.profile = profile + + def clean_current_password(self): + password = self.cleaned_data.get('current_password') or '' + if not self.user or not self.user.check_password(password): + raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) + return password + + def clean(self): + cleaned_data = super().clean() + code = normalize_totp_token(cleaned_data.get('verification_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if not code and not recovery_code: + raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.')) + secret = getattr(self.profile, 'totp_secret', '') or '' + if code: + if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): + raise ValidationError(_('Der TOTP-Code ist ungültig.')) + return cleaned_data + if not self.profile.consume_recovery_code(recovery_code): + raise ValidationError(_('Der Recovery-Code ist ungültig.')) + return cleaned_data class UserManagementCreateForm(forms.Form): diff --git a/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py b/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py new file mode 100644 index 0000000..f1fd757 --- /dev/null +++ b/backend/workflows/migrations/0050_userprofile_totp_recovery_codes.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0049_userprofile_totp_fields'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='totp_recovery_codes', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index b637743..0b5f3eb 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.contrib.auth.hashers import check_password, make_password from django.core.validators import FileExtensionValidator from django.db import models from django.utils.translation import get_language @@ -43,6 +44,7 @@ class UserProfile(models.Model): totp_secret = models.CharField(max_length=64, blank=True, default='') totp_enabled = models.BooleanField(default=False) totp_confirmed_at = models.DateTimeField(null=True, blank=True) + totp_recovery_codes = models.JSONField(default=list, blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -56,13 +58,31 @@ class UserProfile(models.Model): self.totp_secret = '' self.totp_enabled = False self.totp_confirmed_at = None - self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + self.totp_recovery_codes = [] + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at']) - def enable_totp(self, secret: str) -> None: + def enable_totp(self, secret: str, recovery_codes: list[str]) -> None: self.totp_secret = secret self.totp_enabled = True self.totp_confirmed_at = timezone.now() - self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'updated_at']) + self.set_recovery_codes(recovery_codes) + self.save(update_fields=['totp_secret', 'totp_enabled', 'totp_confirmed_at', 'totp_recovery_codes', 'updated_at']) + + def set_recovery_codes(self, recovery_codes: list[str]) -> None: + self.totp_recovery_codes = [make_password(code) for code in recovery_codes] + + def consume_recovery_code(self, raw_code: str) -> bool: + remaining_hashes = [] + matched = False + for hashed_code in self.totp_recovery_codes or []: + if not matched and check_password(raw_code, hashed_code): + matched = True + continue + remaining_hashes.append(hashed_code) + if matched: + self.totp_recovery_codes = remaining_hashes + self.save(update_fields=['totp_recovery_codes', 'updated_at']) + return matched class PortalBranding(models.Model): diff --git a/backend/workflows/static/workflows/css/account.css b/backend/workflows/static/workflows/css/account.css index 839bd3f..bd235a8 100644 --- a/backend/workflows/static/workflows/css/account.css +++ b/backend/workflows/static/workflows/css/account.css @@ -291,13 +291,46 @@ body { word-break: break-word; } -.account-action-grid { +.account-security-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; } +.account-security-item { + padding: 16px 18px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: + radial-gradient(circle at top right, rgba(30, 64, 175, 0.06), transparent 26%), + linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9)); +} + +.account-security-item span { + display: block; + margin-bottom: 6px; + color: #6b7a90; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.account-security-item strong { + display: block; + color: #132238; + font-size: 20px; + line-height: 1.2; +} + +.account-security-item p { + margin: 8px 0 0; + color: #617389; + font-size: 13px; + line-height: 1.5; +} + .account-totp-card { margin-bottom: 18px; padding: 18px; @@ -326,56 +359,77 @@ body { margin-top: 14px; } -.account-action-card { - display: grid; - gap: 6px; - padding: 16px 18px; +.account-qr-card, +.account-recovery-card { + margin-top: 14px; + padding: 16px; border-radius: 18px; border: 1px solid #dbe5f2; - background: - radial-gradient(circle at top right, rgba(30, 64, 175, 0.08), transparent 28%), - #f9fbff; - color: inherit; - text-decoration: none; - transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 160ms cubic-bezier(0.2, 0.8, 0.2, 1); + background: rgba(255, 255, 255, 0.82); } -.account-action-card:hover { - transform: translateY(-1px); - box-shadow: 0 10px 24px rgba(28, 45, 79, 0.08); +.account-qr-card svg { + display: block; + width: min(220px, 100%); + height: auto; + margin: 0 auto; } -.account-action-card strong { - color: #132238; - font-size: 15px; +.account-secret-panel { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #dbe5f2; } -.account-action-card span { - color: #617389; - font-size: 13px; - line-height: 1.5; -} - -.account-action-card-muted { - cursor: default; -} - -.account-action-card-muted:hover { - transform: none; - box-shadow: none; -} - -.account-actions { +.account-secret-head { display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.account-secret-head span { + display: block; + margin-bottom: 4px; + color: #6b7a90; + font-size: 12px; +} + +.account-secret-head strong { + color: #132238; + font-size: 14px; +} + +.account-secret-toggle { + min-width: 48px; + padding-left: 0; + padding-right: 0; +} + +.account-secret-body { + margin-top: 12px; +} + +.account-secret-body.is-hidden { + display: none; +} + +.account-recovery-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } -.account-actions .btn { - width: auto; -} - -.account-actions form { - margin: 0; +.account-recovery-code { + padding: 12px 14px; + border-radius: 14px; + border: 1px dashed #c8d7ea; + background: #f7faff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + color: #17345e; } .account-inline-view.is-hidden, @@ -491,23 +545,16 @@ body { } .account-detail-grid, - .account-action-grid, - .account-form-grid { + .account-security-overview, + .account-form-grid, + .account-recovery-grid { grid-template-columns: 1fr; } - .account-actions { - flex-direction: column; - } - .account-inline-actions { flex-direction: column; } - .account-actions .btn { - width: 100%; - } - .account-panel-head { flex-direction: column; } diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index addc72f..c67924e 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -275,6 +275,7 @@ display: grid; gap: 4px; z-index: 40; + overflow: hidden; } .app-user-panel-head { @@ -299,7 +300,10 @@ .app-user-panel a, .app-user-panel button { + display: flex; + align-items: center; width: 100%; + min-height: 42px; border: 0; border-radius: 12px; background: transparent; @@ -322,6 +326,13 @@ color: var(--app-brand-blue); } +.app-user-panel a:focus-visible, +.app-user-panel button:focus-visible, +.app-user-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10); +} + .app-user-panel form { margin: 0; } diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html index 6124e0c..349aa89 100644 --- a/backend/workflows/templates/workflows/account_profile.html +++ b/backend/workflows/templates/workflows/account_profile.html @@ -20,10 +20,6 @@

    {% trans "Profil" %}

    {% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}

    - +
    + {{ form.recovery_code.label_tag }}{{ form.recovery_code }} +
    {% trans "Alternativ können Sie einen einmaligen Recovery-Code verwenden." %}
    +
    diff --git a/backend/workflows/tests/test_account_ui.py b/backend/workflows/tests/test_account_ui.py index 86b19fa..c76c4c2 100644 --- a/backend/workflows/tests/test_account_ui.py +++ b/backend/workflows/tests/test_account_ui.py @@ -90,12 +90,15 @@ class AccountUISmokeTests(TestCase): profile.refresh_from_db() self.assertTrue(profile.totp_enabled) self.assertTrue(profile.totp_secret) + self.assertEqual(len(profile.totp_recovery_codes), 8) + self.assertContains(response, 'Recovery-Codes') def test_login_requires_totp_when_enabled(self): profile = self.user.profile profile.totp_secret = 'JBSWY3DPEHPK3PXP' profile.totp_enabled = True - profile.save(update_fields=['totp_secret', 'totp_enabled', 'updated_at']) + profile.set_recovery_codes(['ABCDE-12345']) + profile.save(update_fields=['totp_secret', 'totp_enabled', 'totp_recovery_codes', 'updated_at']) client = Client() response = client.post( @@ -113,3 +116,13 @@ class AccountUISmokeTests(TestCase): HTTP_HOST='localhost', ) self.assertEqual(response.status_code, 302) + + client = Client() + response = client.post( + '/accounts/login/', + {'username': 'profile-user', 'password': 'secret-12345', 'recovery_code': 'ABCDE-12345'}, + HTTP_HOST='localhost', + ) + self.assertEqual(response.status_code, 302) + profile.refresh_from_db() + self.assertEqual(profile.totp_recovery_codes, []) diff --git a/backend/workflows/totp.py b/backend/workflows/totp.py index a15063b..7283a31 100644 --- a/backend/workflows/totp.py +++ b/backend/workflows/totp.py @@ -5,6 +5,7 @@ import hashlib import hmac import secrets import struct +import string from urllib.parse import quote @@ -47,3 +48,19 @@ def build_otpauth_uri(secret: str, *, account_name: str, issuer: str) -> str: label = quote(f'{issuer}:{account_name}') issuer_q = quote(issuer) return f'otpauth://totp/{label}?secret={secret}&issuer={issuer_q}&algorithm=SHA1&digits=6&period=30' + + +def normalize_recovery_code(value: str | None) -> str: + raw = (value or '').strip().upper().replace(' ', '') + return raw + + +def generate_recovery_codes(count: int = 8) -> list[str]: + alphabet = string.ascii_uppercase + string.digits + codes = [] + for _ in range(count): + parts = [] + for _part in range(2): + parts.append(''.join(secrets.choice(alphabet) for _ in range(5))) + codes.append('-'.join(parts)) + return codes diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 695f488..f363e71 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -2,6 +2,7 @@ from pathlib import Path from datetime import timedelta from tempfile import NamedTemporaryFile import json +from io import BytesIO from functools import wraps from celery import current_app @@ -33,7 +34,7 @@ from .backup_ops import ( verify_backup_bundle, ) from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired -from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm +from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, LOCKED_FIELD_RULES, @@ -46,7 +47,7 @@ from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfi from .emailing import send_system_email from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud -from .totp import build_otpauth_uri, generate_totp_secret +from .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret from .tasks import ( _generate_onboarding_intro_pdf, _generate_onboarding_intro_session_pdf, @@ -130,7 +131,9 @@ def healthz(request): @login_required def account_profile_page(request): session_secret_key = 'account_totp_pending_secret' + session_codes_key = 'account_totp_recovery_codes' profile, created = UserProfile.objects.get_or_create(user=request.user) + recovery_codes = request.session.pop(session_codes_key, []) pending_totp_secret = request.session.get(session_secret_key) or '' if profile.totp_enabled: pending_totp_secret = '' @@ -143,6 +146,7 @@ def account_profile_page(request): details_form = AccountDetailsForm(user=request.user, profile=profile) totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret) totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile) + totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile) account_edit_open = False totp_edit_open = False if request.method == 'POST': @@ -166,7 +170,9 @@ def account_profile_page(request): totp_edit_open = True totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret) if totp_enable_form.is_valid(): - profile.enable_totp(pending_totp_secret) + recovery_codes = generate_recovery_codes() + profile.enable_totp(pending_totp_secret, recovery_codes) + request.session[session_codes_key] = recovery_codes request.session.pop(session_secret_key, None) messages.success(request, _('TOTP wurde aktiviert.')) return redirect('account_profile_page') @@ -180,10 +186,34 @@ def account_profile_page(request): messages.success(request, _('TOTP wurde deaktiviert.')) return redirect('account_profile_page') messages.error(request, _('TOTP konnte nicht deaktiviert werden.')) + elif form_kind == 'totp_regenerate_codes': + totp_edit_open = True + totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(request.POST, user=request.user, profile=profile) + if totp_regenerate_form.is_valid(): + recovery_codes = generate_recovery_codes() + profile.set_recovery_codes(recovery_codes) + profile.save(update_fields=['totp_recovery_codes', 'updated_at']) + request.session[session_codes_key] = recovery_codes + messages.success(request, _('Recovery-Codes wurden neu erzeugt.')) + return redirect('account_profile_page') + messages.error(request, _('Recovery-Codes konnten nicht neu erzeugt werden.')) branding_context = get_branding_email_copy() totp_account_name = (request.user.email or request.user.username or '').strip() totp_issuer = (branding_context.get('portal_title') or branding_context.get('company_name') or 'Workdock').strip() + totp_otpauth_uri = '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer) + totp_qr_svg = '' + if totp_otpauth_uri: + try: + import qrcode + import qrcode.image.svg + + qr_image = qrcode.make(totp_otpauth_uri, image_factory=qrcode.image.svg.SvgPathImage) + stream = BytesIO() + qr_image.save(stream) + totp_qr_svg = stream.getvalue().decode('utf-8') + except Exception: + totp_qr_svg = '' return render( request, 'workflows/account_profile.html', @@ -194,11 +224,14 @@ def account_profile_page(request): 'details_form': details_form, 'totp_enable_form': totp_enable_form, 'totp_disable_form': totp_disable_form, + 'totp_regenerate_form': totp_regenerate_form, 'account_edit_open': account_edit_open, 'totp_edit_open': totp_edit_open, 'role_label': get_user_role_label(request.user), 'totp_pending_secret': pending_totp_secret, - 'totp_otpauth_uri': '' if profile.totp_enabled else build_otpauth_uri(pending_totp_secret, account_name=totp_account_name, issuer=totp_issuer), + 'totp_otpauth_uri': totp_otpauth_uri, + 'totp_qr_svg': totp_qr_svg, + 'totp_recovery_codes': recovery_codes, }, ) From fe3a8933fd92e81179d952d95e400970b0d53a09 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 03:09:29 +0100 Subject: [PATCH 15/45] snapshot: preserve uniform header chip and account UI polish --- .../workflows/static/workflows/css/app_chrome.css | 14 ++++++++------ .../templates/workflows/offboarding_success.html | 3 +-- .../templates/workflows/onboarding_success.html | 3 +-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index c67924e..ad297ff 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -272,8 +272,9 @@ border-radius: 18px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16); - display: grid; - gap: 4px; + display: flex; + flex-direction: column; + gap: 2px; z-index: 40; overflow: hidden; } @@ -281,8 +282,8 @@ .app-user-panel-head { display: grid; gap: 2px; - padding: 4px 6px 8px; - margin-bottom: 4px; + padding: 4px 6px 6px; + margin-bottom: 2px; border-bottom: 1px solid rgba(217, 227, 238, 0.85); } @@ -303,7 +304,7 @@ display: flex; align-items: center; width: 100%; - min-height: 42px; + min-height: 0; border: 0; border-radius: 12px; background: transparent; @@ -311,9 +312,10 @@ font: inherit; font-size: 13px; font-weight: 700; + line-height: 1.2; text-align: left; text-decoration: none; - padding: 10px 12px; + padding: 8px 12px; cursor: pointer; transition: background-color var(--motion-fast) var(--motion-ease), diff --git a/backend/workflows/templates/workflows/offboarding_success.html b/backend/workflows/templates/workflows/offboarding_success.html index cbffd51..3a1d23f 100644 --- a/backend/workflows/templates/workflows/offboarding_success.html +++ b/backend/workflows/templates/workflows/offboarding_success.html @@ -6,7 +6,7 @@ {% block shell_header %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} {% endblock %} {% block extra_css %} @@ -30,4 +30,3 @@ {% trans "Zum Dashboard" %}
    {% endblock %} - diff --git a/backend/workflows/templates/workflows/onboarding_success.html b/backend/workflows/templates/workflows/onboarding_success.html index cd01905..8d61112 100644 --- a/backend/workflows/templates/workflows/onboarding_success.html +++ b/backend/workflows/templates/workflows/onboarding_success.html @@ -6,7 +6,7 @@ {% block shell_header %} -{% include 'workflows/includes/app_header.html' with header_show_home=1 %} +{% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} {% endblock %} {% block extra_css %} @@ -29,4 +29,3 @@ {% trans "Zum Dashboard" %}
    {% endblock %} - From aa54f41731a645694dd982ec1bc8d74ac416b727 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 11:26:57 +0100 Subject: [PATCH 16/45] snapshot: preserve role-aware notification preferences and operational alerts --- PRODUCTIZATION_ROADMAP.md | 52 +- backend/config/urls.py | 6 +- backend/locale/en/LC_MESSAGES/django.mo | Bin 35857 -> 35430 bytes backend/locale/en/LC_MESSAGES/django.po | 1230 +++++++++++------ backend/workflows/context_processors.py | 12 + backend/workflows/forms.py | 200 ++- backend/workflows/middleware.py | 10 +- .../migrations/0051_usernotification.py | 31 + ...52_userprofile_notification_preferences.py | 16 + backend/workflows/models.py | 73 + backend/workflows/notifications.py | 35 + .../static/workflows/css/account.css | 323 ++++- .../static/workflows/css/app_chrome.css | 221 +++ .../workflows/static/workflows/css/login.css | 48 + backend/workflows/tasks.py | 71 +- .../templates/workflows/account_profile.html | 378 ++++- .../templates/workflows/auth/login.html | 62 +- .../workflows/includes/app_header.html | 52 + backend/workflows/tests/test_account_ui.py | 67 +- .../workflows/tests/test_bilingual_smoke.py | 24 +- backend/workflows/tests/test_notifications.py | 352 +++++ .../workflows/tests/test_offboarding_flow.py | 10 +- .../workflows/tests/test_onboarding_flow.py | 74 +- backend/workflows/urls.py | 2 + backend/workflows/views.py | 242 +++- 25 files changed, 2958 insertions(+), 633 deletions(-) create mode 100644 backend/workflows/migrations/0051_usernotification.py create mode 100644 backend/workflows/migrations/0052_userprofile_notification_preferences.py create mode 100644 backend/workflows/notifications.py create mode 100644 backend/workflows/tests/test_notifications.py diff --git a/PRODUCTIZATION_ROADMAP.md b/PRODUCTIZATION_ROADMAP.md index cb4480a..9d46875 100644 --- a/PRODUCTIZATION_ROADMAP.md +++ b/PRODUCTIZATION_ROADMAP.md @@ -79,7 +79,7 @@ Snapshot commit: ### Phase 1. Product Core Standardization -Status: next +Status: completed Purpose: @@ -117,8 +117,18 @@ Deliverables: - branding flow - PDF/letterhead override behavior +Delivered: + +- generic branding model and management UI +- shared branding context across shell/auth/pages +- configurable favicon, logo, sender display, footer/legal text, and PDF letterhead +- company-domain-driven email defaults and validation +- platform vs company admin separation for product-level controls + ### Phase 2. App Registry and Navigation +Status: completed + Purpose: - stop hardcoding app cards and app visibility in the homepage template @@ -129,9 +139,13 @@ Deliverables: - title / subtitle / icon / route / required capability / enabled flag - homepage and navigation driven by registry data - ability to enable/disable apps per deployment +- role-based app visibility and section grouping +- drag-and-drop ordering with filter-safe behavior ### Phase 3. Trial Mode Lifecycle +Status: completed + Purpose: - allow limited-time test environments for demos and sales @@ -145,9 +159,18 @@ Deliverables: - cleanup command / scheduled deletion - DB/media cleanup policy +Delivered: + +- platform-only trial management UI +- shared trial banner and expiry enforcement +- integration restriction during trial mode +- cleanup/verification management commands + ### Phase 4. New Business Apps -Only start after phases 1-3 are stable. +Status: next + +Only start after phases 1-3 are stable and the workflow regression suite is green. Candidate apps: @@ -191,22 +214,21 @@ These should move into configuration progressively, not all at once in one risky ## Immediate Next Slice -Implement first: +Implement next: -1. `PortalBranding` model -2. branding management page -3. shared branding context processor -4. replace header/logo/title references on: - - home - - shared header - - login/auth pages -5. make PDF letterhead configurable +1. restore and keep green the onboarding/offboarding regression suite +2. extend dynamic onboarding configuration: + - field visibility + - section visibility + - guarded required/optional controls +3. remove remaining hardcoded customer/product leakage from docs, fixtures, and fallback assets +4. continue security and observability hardening before the next business app -This is the first productization slice because it gives: +This is the next productization slice because it gives: -- generic portal identity -- customer-specific configurability -- a cleaner base for every future app +- reliable core workflow behavior +- safer deployment-neutral product defaults +- a configurable onboarding experience for future customers ## Guardrails diff --git a/backend/config/urls.py b/backend/config/urls.py index 81ef653..b783ae7 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -4,16 +4,18 @@ from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path -from workflows.forms import AppAuthenticationForm, AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm +from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm +from workflows import views as workflow_views urlpatterns = [ path('admin/', admin.site.urls), path('i18n/', include('django.conf.urls.i18n')), path( 'accounts/login/', - auth_views.LoginView.as_view(template_name='workflows/auth/login.html', authentication_form=AppAuthenticationForm), + workflow_views.login_page, name='login', ), + path('accounts/login/totp/', workflow_views.login_totp_page, name='login_totp'), path( 'accounts/logout/', auth_views.LogoutView.as_view(), diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index 612fd69a6fb861f051063ff5331277fe4bbcfe36..c9e43614907a9a468e70d2cea9f81d3e14258113 100644 GIT binary patch delta 9539 zcmYk>33yLe8prWV_6Q*ok`RJOV&5WSZ3GcZ#8N8OAVlnYER8D^m1unVxKH2z=RN11d+*uqO*^w{xz$@vN^VhL8l8|aGOp5v9lean7}3ylG!r$z0&Ig@P}hCikoi|nZc(8T_%WR3*cMZ8686ORQB&*L*vvp8 z>H(?P8CPQmJdeFFw27I?EYx0@f*QaA)KV<9u5ZHpYX){xp|v?;EkSmta|gR&Fk4m+ z7>3%#8&MrUfK~7uM&NA>!}5$K2V+npEkwrP>_N@o_n3~&U53juoaGpVpP(9^#5wp7 zGcc!xnVEB_cID#CYg7Z7Z6^&i6Z25lA3~Zsw~#l`sol~nMGE$#JPiZT{enb2KaJXa zcQFkgpr)*QyqTFkSb=gDYJ{^;GxRbB;$EzXM^Kxu7|Y=W^v7$cJ$4`Uy$E2Vuy0+b z35jMP9V2lWYJ@wH!8-@AGTuRr$dfI;6cbQ4K7@Kpj-wuU3iI(>ERR{OO^0Tp2AGSQ zfh8EN_rHWh?|T`xW>xe-ZLXTA5syJVV5Tk4MeT)U7>mcSbmmYUePFG?j#US1qX#xZ zO?fj6#|}EL_dlIvBxa&UeiSu@C8!I2#8AAAwa~YnDc486rkzkD9f|756x2-2K~42y zTfYM}pgpMj9YR+X=k1BBs0)9yWsmmehC!%~RI}w+%%I#1vv4KqJ`b#ZOpo?XD5_&M zQ8UvB)vhCIpuIaV|2i>>3O~%j>X?VxwVP0z>PFw89&{ID(Yw2;Z-Q!{gxa*}*a*j=AFf5+=QVV_N!}vS zl)Z;OcpTM{)5z>Q7i~GZhnbNUsOvhQI^G43Nz$-BX4&&AF_H2*)W}OvBe`w;9rd6xspi2!m_a!T{c$mBQ@(7= zg{jQH{(9}CLJ$53)#FpB56O?VVNfr#=^{`!Scr3Q1(wHhX=dufP!DQ|8fhzA?ueS< zo~VwG#z36wl4vUPu^+BLEx~16i#Ks3F6nJP9M@1Cy^XrgtB=_$0jQ3JVl|Ax#@Gop zlhZIA^HCl92@}!1MUqU?q_3H}S*X`(DMsTqEQeoV0G>k+yo{Cbnyvp6wflV;g*sRV z)uGm?>rznn>5m%V2y@Y0q8yHzfp!>(olqZ^ zewdBZaRFYiw)`VT zQT`ul3F@%0>Of13!Op1bCtx$2f!dUB*!n$ql=6q@>cf#Y$ZVFK7)kj&hT#KLkAnxB z8Hz*I4??|`Gf+!Wh-!Bjo8u3tJrq2|bfh_IsoJ16Uq@TtV+ixFihfjRlMJ;b*pd40YXU48juBjNG*K|HUSh{~E&jYm+q`YNoP1R;HYW zTDwuGHC~4;a69UO|Hf8$1vP*g!%Vwa)RM&8atf*g15h2vwoXM)%5z+j#w7DmJ>G?N za4$yVIn-J|!Z?f?ZrY`zI-Y@g;8avc7oi@w0;}Ry48>0{9E(w#`VOkS>y=?{SQ*<; z5sop~AJrfi_4>VlnzA*hH9m^E;R95=kP)VXbx<=h5cQyusDb2QJ=}y%@farZeCH1m z-M9^(IX!3`_QYA(0Y685AIkDayf3DpzWM7>@9|dD%iR3#m*+c!v&?3C2erAbpqAh+>NN`+%U2FNqc&k4YN__2&fmeBSS{Q9S#67Y zn{rVd&qrU}f$HEM^upiJ)#iCfqBRU0XGe%ND7QjQ@c{J4G3bp`(GO>%9<&g(8DBzm zWHUzMA&kNwP@BnIj(z%D)3w}KC5o#dECNlrJ!AUAK(qfFm%UB!3Cz+14N1abW z&DcN;z;USSb8I;mLn!B?9;V9Vw6MaD+7$)qxhM8BW1uz5l~(gKel0y=y&S z{Sq~`-=L=UGU~y9p_U|YhS|KqsJ+k~HO0MA?S`ZF!Zcf+gPN)3n85R$4J4YPVvNM^ zP`mgM>V`vSnyDOx+Qm7j2fu_G;c6U>g{T2|%`!6*geo_{U`#;GY${g9vFOH=JWHZA zD?;`B2&&;}>n+qs{Bq0#tD|llgPQU|+kCo74wi!?e>VDN`GymFL zjj2$BHaG@*qIy__p?DNE(u=ly1Jz;AIcDS`s2k2heWD961b1LnJczpgS)7koQTNN5 z%lzxYX>?ahuo!is0M&tYwtg3eQ{Ini|21aePpFybInT^oI;!1R)O{9N3sCLWp`P;w zYNmI)BmHb_R1vGW}9K1hnm5Ks0Xh{ z-G3`~Lw65}Zu}>z$9}nHO@mQAtd6>%0mfqjYGx**Zny|_-E!1-WDUmQ7F&N7)uA6S z46mXFT$XictoJ{ZL_P0{%`p|VTNj`n@QQU4>cZ`)4j;g$@i^-GNS0DF5{>Fmye)UM zOWX%FkWAE!O+^pA{~L)+Dz>0T?7hJJPbDE(IzrT%FGtiRbP`mqU z9FEseOVDkR`S*iVJ_!qhwQMKpH)I_63)&g}w0_ujzs0(|e);=3G z!dz5G3$S#ltea3XxgE91_M`6SEH(qIfLf}k#mv7hjH5!f$J&^To|uig(InK`=i(q- zg}lPfEgXnrmzX_p7}fqLs-tI7Z_jmXi{4Ak^+~7>Y+TCxHze6*D@t&PF0kdU&zqTf z2GyZV^ulaxiIcG|Zo`^*9KG=d`rHa#1UdP<@brlN36}?w-uU|0=@qqlC&VE z(I9}(y13Yh7)ks{xeX>0I#P+=rJ4Co9Jl#(TuGaXwp|wGLF6A{lInq9b{I;&t*KgpT%)CD*H0 z*Ov9|DLu4#bo@pPboAm}Z+kui zr&D*DXhg18?-Ef}-+vuV45h!v_&rawqG9tV>OUjjNu&|{3z1X9-aHq}5x&%|!}`Q% zLPwtUX=@wmMiR-yTB7v%{+xV{ctaH&HOcD`!wDTTt#6TkPjsgI34THJBGyvgW!vz1 zaDF0m=({nI`lbXQN{7F^&M(A22_5qY_iKLE6Aft8kVqv)5VxrFAasTqR7_Wq0ZCh3&=TK=M{ecO3HP86;YIFEyh*N4@dxDNaWkRA&)}@aOkxW$)TL1*F@eyLh=Wa9`upgK z{CAs&)8;Rm_q5kFqO8A+nKe#6^*cDT3T@K|A#1>ob zMt+%a|3*bR$-6`r`Et~;#Nd>tt}OXAVurmqk$fPLPi!DYQ9r<*J43k>5k~YRk_a7B zh?7Ji(T*5OaJbGIla&5HSf<8QdG?8P~9ATl{99qJj>;RxDbc_C^gS>SA{{xz-P?G=v delta 9937 zcmY+}33yG{-pBDBL6AfYAwkF?#1Ilu1T{n&a~fmR+#JavsSJ8Vh@mHDHP+OtN~@Go zRNK&^idM&~S{-m*N_(~HZBexPcF^{Ie`l?{k9R-M-{-&9UVE=`AJ^@bwG~#aui(2_ ztKvF`>u`YMMB&{~#~EC~aV|7at>d_wIZi8Vj7gY=u{a;qeit^u53nWvfRR{-Mr|=3 z)&61rh{lDsemllEj?Xzu62y%^uqp;Ncbs4h#cEg&8I#i#YhXL9i4R~E%s@Ri&YFW^ zl)YFV=i2&BSc&orsQ2!{%JlCXAW;WLQ7=4+YIp|w;svaQ%~}{cVFKj=7>+YB1)o55 z{5jr_mu)$kW$8pY0c&Fp*1=iWfc~A;_Qp%7j^4&9cp9tXMXZQdQCn~wL$De9Lh7`` zHaG?oaS5u!!x)5jP%HN*YUQfOnX(IgTB1Y}%_I#Y(2XuE!A7_NgYh6%#&>ZDeuTW! ziEeFP+#l7^7}P-Xu{Ul*4fGmnf^`{f7i`&v_1B29sn9@*Q8QSD?eIAE#a}QDd&HZi zU4|i)KR|VG8dEWfed&&FOvlZrmHZLI@K@9Xs&F#36=7{zegeABDGx2Yvsgv2>v|EFW#o3PRhI1CR5`i7e^L>#=oMPn5bY4Vl#VH($zTa&{ zdPlRQ(@`^6fLgk(I1qQEmhLXr!9X^+I@UwYv;}IVQcz1h0c&6}>JTo%U|fmS@M(N z$H~)xnqw7x{|O{oicT1d%TV8c88SlWAnK5PikfjGAGbco=BRRO)S0*+Ti^`zuN-Qi zyRFAi1N;I5@e+pU`@c+5A8*cO41ydO2QcTfX4Y0Lk{Ov;zBFQ&2_ z6LC4}eRr%Cdzdp+8#TcCJy?G&Q41>6uq$fD15owjuqt{n3TL5C@l&Y7b`)FTWsJw_ z$)>&=)}`!5ZaLFYXX*gzxvQvtLVEhl>5cAb_PjG{k9t`9TQe}6`b^Y|r(!ZLL!I)I z){Cf#{Am5l8k%CxQWWa>_NX&7z(>Lt=nO&~x`n78ie;$J>>1RO??b)#P1Ha>Lf-9s zZObtaa3CpnLM?qJYU@5gt>hP|6}*b-xDr1S+CpD7l6;Z~)W{ZKL)?h!@DS>7eu#R( zH>jlx=w+5R9915M>S!XW-AvR3)}!A05^81NMZNbFat3_PC0lVFqq%Vx7h@FPoc4Ab z>P4qeU%?gB3fw@=FtCs5I1IBX$Jp{>^iKrU@gdY9e-}02ids zeAM^93AH8r@iBZK^}^o#g`gJ=#wwU?%lTN3a;dG~fLg(=sDU3rH-3(_=-=tk-@K?V zYUxI!W;Px5naoG+)oNS+tgYXLTH1rC0h~e&=p3q}YuEw<2AKL*sP^4ZhjtkH+K_li zs^S{di#B0p+>Tm_Js5=Vp$76XvP;f+TW*|YRwN$vTxZn4lQ9I-u_KN~ZRKjzitb2b z{WX$9RA>MvP^bE|Z5Z*Od0`vWgY8jU(jQylczb_2CR1LEn)zweM83EFgzD!ms^idf z^Me(g&iYrQqLd1q%B86C2GrkT+fW_vM?H7~^+WO{s$J+nbLb*bD>NIc;{q(fwHS)k z2bnF1LOqv&ns67Ntw=>J^+41Naxe^Mqn30z4#l;oj;`W*youxS@xkWz;~Hwfw^7ek z8Dh>-7;0b*P=~w~#-pz{iI#LKj>P4tk$s2Bcnf=DJ9@8yC8*D83C7|M48~6|1kYn2 zUPZ0gHCz7|>MIByW(F9G49MqnCDDTeP%j#RnqjtS;N+nW+f3BV-oi$B7B!$>P|pQr zn1R>E#*`bPR-gxJC3~ZOUxs5Y7GtTt|F3L?d${>&Jb`-P6zV(v3Y+2Yn2fH6%=hcY zaLUKAHU0~=*Of*%&ZAfxRbGHv`ZcJDZL#$)Vh4Ty2S{4t4b%uD*@sHl2{p6s7>E5) z?TS$^+Kk%E9q7V$Z24PkN;zPZ*@9Tq06JlF?1Or~5PgXx(@1nGciJ2K@O{dMQ9mH_ zN1Ma42VImeU_Ja3HQZ%fnG;B-5I2@6SN(@myPe95vHbsDW%oP3#bAMc=jM6R79TjAi|6k$g#oI=pFb z{E7*b|3)3MgmGpmlQEw15Uh=Jumi3`?ez(~56_`G4t?1CODGaGu|cSIqflQ*j?Y%i zKn-LuY9LQrw_-)gyHFj!j2ihRjKM1yi`6pCo+n~^%EM9ZmZB!G8r9)e)ByIQ`tuzp zi6A+Hk$3~^W02dN?k1=Ql29+~gxDKBxg^ zAS>c?mXPQ(T8o;=PHc&%ur2%`X7r8qZ>&qX{seQX6H$k6AQoXR z>QG)pZQ*t7g;5h7=Rx{+#*^rCc^S1Q=TR$g1NDN)95bWkm_+$2)ZvQEHCqsadVTgOWrQ2sE9{f{QOON9%g^2{mjhB}nvQ60{}L|leC zD@RbD-RC$DtLB>ymt!F1^%#sBaS86gP`t0eOr$3UQSMW~`tyl91F6v7@39TuLJi=3 zOvOv6nKUglFNj0UG!fflPi%t4sF^>7VfZ|10{buo-$SkB=eB&#M^cB1E2s|c;$RG% zY|cUkYUy%NGw`B5(^7QdQ>f<;p|nl0FhT9Lh|_Z>p5#Br>p@Bb7@7!{YX z2L6QFii%Ur1CgjbZ;V>vju?d_F&2w${U+3Bc?>n7^Vk+|qqZof$oyiCKst72VI=)K zD@n9OJ5U3888zZ#))N>``3!2Qe?ZMNV5(`?7d4}y)=XW8QYYDuS~3+JE?^A^Wv1??!ca z6xGi^Q7e9SI_s~BOH}B=Yd8agO3Yr(#hR42qh@pf^}-{l@B34G|4W=e`A5_Mhx3w1 zd<50;JX>CY8t@CKi65B3{_BN}W}08JM65%307jr2HGmR)440x_SZ$Vht`2GoTA=C^ zQ3L2|>jzyH|6CJw^_tfcS%IEj|zL)1*p+4482J^L9olb|_f$-+^8 z$s}VorlMxP6E*W!FdvVhW*R@&yeA3u{{E=XFav|>-x)=s8BRbQwo=q7U5#3St(cB4 zqB{BwpTGeA>Kl!#QSEM^W_rh#gXf#Cq!w!CjZkN-HEN~%piifK7Rkf77PS>WV;B4j zdt>JX<^`pwGqD02;R~o4y@y(%Pq89iM0I!>^}buE=l(#Qf$)W9f-M%Z{u+5_DzsF| z)_$m^9D+J*Zqy5Rp=NjxwPl~6p1WkdicKirLJcH*k$F!&)N?Ix6m~~`Dx9YlvHl}S zYA-g2DG$}L7d6s3sLyB}-jA=KUT_^XfWD8LfBTIbHr(JZIsf&bt<&mg?|&m$EdO7<=~zhQ5arjeB%|#! zs?%>rB<1IDIM&7g5Ov9?Vn6Im|IXXQEDGC*I>axO%dZg1jcwr|`3vL^*z!a?Nt`AY z5F82rHIP65AQsVntEqBcC3h27D7Pk_C)ed$WAy(glrPEMgnqBnh<=0)=e^f*`=IIq z$@A|i7m%+cKIUFaTtX(^LU_?xc9na3%zW8KIK8g zP9oUeYt20m(UE9OOt$rX$aSqC`V-Glo`FyJ>skMG6m%UWx)VvmY@vh$Ym& zg{!eQF_ipiq8{-Yaf}#Cd`Rfh-+(=ccT}*~&(@n*-{$GOOaIB=cYum8;u~TU70=oR z`f(USJWgFVB8S*Rj3Dk(ubgh|vDu zCb>-L3MG#CGxJ9t52upuAiCQ6M{zsxlP%Y<#?UsHh@t$ywr)HYQ#Zwy#XOroiDmTf zte`;Ic=KX2J`RqGqH(e)9rmw1WTKwUH*!g!)9 zv74Z4XN`|!9Z`N|lC&gVuyu3njaAr?y5ab5q6T?0+eS?mlkXtr-%~e?`X6+Ys}IqT zSjzoeqL8?c&>);M`h%;4id4c&{AjA2nHWV`*IHsQ`CNaA14{li`6TR5==vW*lS;q| zLURALYzhPB3co1ZR2mr_Yh+#Par-ZKTkYD?!lTw z2KmF-f*3=%hz>+gVm-oj+vr6c=U;!4QA8DP>Z*l9iF&qdYWZgqNg(le zc?G^gn>b=3@i1|Yy3O|f2J2_m&#^br-j*|I@9%#y$$Pd^)Tf+6oFYGA>jz+Sn`d$F zb@E6eis(*RmkVbS1BmUm?tSWCCZ13Odrjls^W<0W-GAL{z>NaDqypD3X<8n_bn(E2R^SJ(hqvMCid%RO}vvP`DQwy?P)0E{- z^e~jH9FI50lUp=}C+VkbMYlg{l+8*1xO#lXAn6&8EE#krnoE;j?t&7aIdxoJ1W!xOnhCFPxzZJInetgKS0yV`#Nd41H( diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 2a65f22..469ca79 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,14 +2,14 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 01:48+0000\n" +"POT-Creation-Date: 2026-03-27 10:24+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/app_registry.py:35 workflows/models.py:409 workflows/models.py:490 +#: workflows/app_registry.py:35 workflows/models.py:482 workflows/models.py:563 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -36,7 +36,7 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:46 workflows/models.py:410 workflows/models.py:491 +#: workflows/app_registry.py:46 workflows/models.py:483 workflows/models.py:564 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -90,7 +90,6 @@ msgid "Suche" msgstr "Search" #: workflows/app_registry.py:62 -#: workflows/templates/workflows/account_profile.html:218 #: workflows/templates/workflows/app_registry.html:32 #: workflows/templates/workflows/backup_recovery.html:72 #: workflows/templates/workflows/job_monitor.html:29 @@ -127,6 +126,7 @@ msgstr "" #: workflows/app_registry.py:142 workflows/app_registry.py:151 #: workflows/app_registry.py:160 workflows/app_registry.py:169 #: workflows/app_registry.py:178 workflows/app_registry.py:187 +#: workflows/templates/workflows/includes/app_header.html:57 msgid "Öffnen" msgstr "Open" @@ -263,7 +263,7 @@ msgstr "" msgid "Alle Firmenrollen" msgstr "" -#: workflows/app_registry.py:317 workflows/models.py:186 +#: workflows/app_registry.py:317 workflows/models.py:259 #: workflows/templates/workflows/app_registry.html:44 #: workflows/templates/workflows/app_registry.html:100 msgid "Apps" @@ -273,7 +273,7 @@ msgstr "Apps" msgid "Wählen Sie den gewünschten Prozess." msgstr "Choose the desired process." -#: workflows/app_registry.py:323 workflows/models.py:187 +#: workflows/app_registry.py:323 workflows/models.py:260 #: workflows/templates/workflows/app_registry.html:45 #: workflows/templates/workflows/app_registry.html:96 msgid "Platform Apps" @@ -285,7 +285,7 @@ msgstr "" msgid "Produktweite Konfiguration und Produktsteuerung." msgstr "Configuration, tests, and controls." -#: workflows/app_registry.py:329 workflows/models.py:188 +#: workflows/app_registry.py:329 workflows/models.py:261 #: workflows/templates/workflows/app_registry.html:46 #: workflows/templates/workflows/app_registry.html:98 msgid "Admin Apps" @@ -384,132 +384,222 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:106 workflows/forms.py:399 -#: workflows/templates/workflows/account_profile.html:66 +#: workflows/forms.py:106 workflows/forms.py:473 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:107 +#: workflows/forms.py:108 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:109 workflows/forms.py:279 workflows/forms.py:311 -#: workflows/forms.py:357 +#: workflows/forms.py:114 +msgid "Benutzername oder Passwort sind nicht korrekt." +msgstr "" + +#: workflows/forms.py:115 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Dieses Konto ist deaktiviert." +msgstr "Disabled" + +#: workflows/forms.py:141 workflows/forms.py:304 workflows/forms.py:356 msgid "TOTP-Code" msgstr "" -#: workflows/forms.py:115 workflows/forms.py:317 workflows/forms.py:363 +#: workflows/forms.py:147 workflows/forms.py:362 msgid "Recovery-Code" msgstr "" -#: workflows/forms.py:123 workflows/forms.py:300 workflows/forms.py:343 -#: workflows/forms.py:389 +#: workflows/forms.py:154 workflows/forms.py:325 workflows/forms.py:382 msgid "Der TOTP-Code ist ungültig." msgstr "" -#: workflows/forms.py:124 +#: workflows/forms.py:155 msgid "Bitte geben Sie Ihren TOTP-Code ein." msgstr "" -#: workflows/forms.py:157 workflows/forms.py:221 workflows/forms.py:400 +#: workflows/forms.py:182 workflows/forms.py:246 workflows/forms.py:474 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:162 workflows/forms.py:181 +#: workflows/forms.py:187 workflows/forms.py:206 #: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:168 workflows/forms.py:187 +#: workflows/forms.py:193 workflows/forms.py:212 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:176 workflows/forms.py:274 workflows/forms.py:306 -#: workflows/forms.py:352 +#: workflows/forms.py:201 workflows/forms.py:299 workflows/forms.py:331 #, fuzzy #| msgid "Neues Passwort" msgid "Aktuelles Passwort" msgstr "New password" -#: workflows/forms.py:198 workflows/templates/workflows/account_profile.html:36 -#: workflows/templates/workflows/includes/app_header.html:27 +#: workflows/forms.py:223 workflows/templates/workflows/account_profile.html:36 +#: workflows/templates/workflows/includes/app_header.html:79 msgid "Profilbild" msgstr "" -#: workflows/forms.py:214 +#: workflows/forms.py:239 msgid "Das Profilbild darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:219 workflows/forms.py:397 -#: workflows/templates/workflows/account_profile.html:112 +#: workflows/forms.py:244 workflows/forms.py:471 +#: workflows/templates/workflows/account_profile.html:116 msgid "Vorname" msgstr "" -#: workflows/forms.py:220 workflows/forms.py:398 -#: workflows/templates/workflows/account_profile.html:116 +#: workflows/forms.py:245 workflows/forms.py:472 +#: workflows/templates/workflows/account_profile.html:120 msgid "Nachname" msgstr "" -#: workflows/forms.py:222 -#: workflows/templates/workflows/account_profile.html:120 +#: workflows/forms.py:247 +#: workflows/templates/workflows/account_profile.html:124 msgid "Telefon" msgstr "" -#: workflows/forms.py:223 -#: workflows/templates/workflows/account_profile.html:124 +#: workflows/forms.py:248 +#: workflows/templates/workflows/account_profile.html:128 msgid "Mobil" msgstr "" -#: workflows/forms.py:224 workflows/templates/workflows/account_profile.html:70 -#: workflows/templates/workflows/account_profile.html:128 +#: workflows/forms.py:249 +#: workflows/templates/workflows/account_profile.html:132 #, fuzzy #| msgid "Produktion" msgid "Position" msgstr "Production" -#: workflows/forms.py:225 workflows/models.py:370 -#: workflows/templates/workflows/account_profile.html:74 -#: workflows/templates/workflows/account_profile.html:132 +#: workflows/forms.py:250 workflows/models.py:443 +#: workflows/templates/workflows/account_profile.html:136 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/forms.py:226 -#: workflows/templates/workflows/account_profile.html:136 +#: workflows/forms.py:251 +#: workflows/templates/workflows/account_profile.html:140 msgid "Standort" msgstr "" -#: workflows/forms.py:228 -#: workflows/templates/workflows/account_profile.html:140 +#: workflows/forms.py:253 +#: workflows/templates/workflows/account_profile.html:144 #, fuzzy #| msgid "Einweisung" msgid "Hinweise" msgstr "Introduction" -#: workflows/forms.py:292 workflows/forms.py:331 workflows/forms.py:377 +#: workflows/forms.py:317 workflows/forms.py:344 msgid "Das aktuelle Passwort ist nicht korrekt." msgstr "" -#: workflows/forms.py:298 +#: workflows/forms.py:323 msgid "Bitte geben Sie einen gültigen TOTP-Code ein." msgstr "" -#: workflows/forms.py:339 workflows/forms.py:385 +#: workflows/forms.py:350 +#, fuzzy +#| msgid "Deaktivieren" +msgid "TOTP ist für dieses Konto nicht aktiv." +msgstr "Disabled" + +#: workflows/forms.py:378 msgid "Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein." msgstr "" -#: workflows/forms.py:346 workflows/forms.py:392 +#: workflows/forms.py:385 msgid "Der Recovery-Code ist ungültig." msgstr "" -#: workflows/forms.py:401 workflows/templates/workflows/account_profile.html:62 -#: workflows/templates/workflows/user_management.html:74 +#: workflows/forms.py:390 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Onboarding erfolgreich" +msgstr "Save offboarding request" + +#: workflows/forms.py:391 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Onboarding fehlgeschlagen" +msgstr "Failed" + +#: workflows/forms.py:392 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding erfolgreich" +msgstr "Save offboarding request" + +#: workflows/forms.py:393 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding fehlgeschlagen" +msgstr "Save offboarding request" + +#: workflows/forms.py:394 +#, fuzzy +#| msgid "Eingereicht" +msgid "Backup erfolgreich" +msgstr "Submitted" + +#: workflows/forms.py:395 workflows/views.py:1348 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Backup fehlgeschlagen" +msgstr "Failed" + +#: workflows/forms.py:396 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail erfolgreich" +msgstr "Welcome Emails" + +#: workflows/forms.py:397 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail fehlgeschlagen" +msgstr "Welcome Emails" + +#: workflows/forms.py:398 +#, fuzzy +#| msgid "Einweisung" +msgid "Trial-Hinweise" +msgstr "Introduction" + +#: workflows/forms.py:399 +#, fuzzy +#| msgid "Einweisung" +msgid "System-Hinweise" +msgstr "Introduction" + +#: workflows/forms.py:415 +#, fuzzy +#| msgid "Workflow-Regeln" +msgid "Workflow" +msgstr "Workflow rules" + +#: workflows/forms.py:416 workflows/views.py:1505 +#, fuzzy +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail" +msgstr "Welcome Emails" + +#: workflows/forms.py:417 workflows/templates/workflows/handbook.html:21 +msgid "Operations" +msgstr "Operations" + +#: workflows/forms.py:418 +msgid "Platform" +msgstr "" + +#: workflows/forms.py:475 workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 #, fuzzy @@ -517,207 +607,207 @@ msgstr "" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:415 +#: workflows/forms.py:489 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:424 workflows/views.py:1020 +#: workflows/forms.py:498 workflows/views.py:1157 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:426 workflows/views.py:1023 +#: workflows/forms.py:500 workflows/views.py:1160 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:465 +#: workflows/forms.py:539 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:466 +#: workflows/forms.py:540 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:467 +#: workflows/forms.py:541 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:468 +#: workflows/forms.py:542 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:469 +#: workflows/forms.py:543 msgid "Absender-Anzeigename" msgstr "" -#: workflows/forms.py:470 +#: workflows/forms.py:544 msgid "Login-Untertitel" msgstr "" -#: workflows/forms.py:471 +#: workflows/forms.py:545 msgid "Footer-Text DE" msgstr "" -#: workflows/forms.py:472 +#: workflows/forms.py:546 msgid "Footer-Text EN" msgstr "" -#: workflows/forms.py:473 +#: workflows/forms.py:547 msgid "Rechtlicher Hinweis DE" msgstr "" -#: workflows/forms.py:474 +#: workflows/forms.py:548 msgid "Rechtlicher Hinweis EN" msgstr "" -#: workflows/forms.py:475 +#: workflows/forms.py:549 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:476 +#: workflows/forms.py:550 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:477 +#: workflows/forms.py:551 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:478 +#: workflows/forms.py:552 msgid "Favicon" msgstr "" -#: workflows/forms.py:479 +#: workflows/forms.py:553 #: workflows/templates/workflows/branding_settings.html:89 #: workflows/templates/workflows/branding_settings.html:162 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:480 +#: workflows/forms.py:554 #: workflows/templates/workflows/branding_settings.html:90 #: workflows/templates/workflows/branding_settings.html:163 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:497 +#: workflows/forms.py:571 msgid "Das Logo darf maximal 5 MB groß sein." msgstr "" -#: workflows/forms.py:505 +#: workflows/forms.py:579 msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." msgstr "" -#: workflows/forms.py:513 +#: workflows/forms.py:587 msgid "Das Favicon darf maximal 2 MB groß sein." msgstr "" -#: workflows/forms.py:537 +#: workflows/forms.py:611 #, fuzzy #| msgid "Firmenname" msgid "Rechtlicher Firmenname" msgstr "Company name" -#: workflows/forms.py:538 +#: workflows/forms.py:612 msgid "Straße und Hausnummer" msgstr "" -#: workflows/forms.py:539 +#: workflows/forms.py:613 msgid "Postleitzahl" msgstr "" -#: workflows/forms.py:540 +#: workflows/forms.py:614 msgid "Stadt" msgstr "" -#: workflows/forms.py:541 +#: workflows/forms.py:615 msgid "Land" msgstr "" -#: workflows/forms.py:542 workflows/templates/workflows/base_shell.html:64 +#: workflows/forms.py:616 workflows/templates/workflows/base_shell.html:64 msgid "Website" msgstr "" -#: workflows/forms.py:543 +#: workflows/forms.py:617 msgid "Impressum-URL" msgstr "" -#: workflows/forms.py:544 +#: workflows/forms.py:618 msgid "Datenschutz-URL" msgstr "" -#: workflows/forms.py:545 +#: workflows/forms.py:619 msgid "HR-Kontakt" msgstr "" -#: workflows/forms.py:546 +#: workflows/forms.py:620 msgid "IT-Kontakt" msgstr "" -#: workflows/forms.py:547 +#: workflows/forms.py:621 #, fuzzy #| msgid "Operations" msgid "Operations-Kontakt" msgstr "Operations" -#: workflows/forms.py:548 +#: workflows/forms.py:622 msgid "Zentrale Telefonnummer" msgstr "" -#: workflows/forms.py:549 +#: workflows/forms.py:623 msgid "USt-IdNr." msgstr "" -#: workflows/forms.py:550 +#: workflows/forms.py:624 msgid "Register- oder Handelsnummer" msgstr "" -#: workflows/forms.py:567 +#: workflows/forms.py:641 msgid "Trial-Modus aktiv" msgstr "" -#: workflows/forms.py:568 +#: workflows/forms.py:642 msgid "Trial-Beginn" msgstr "" -#: workflows/forms.py:569 +#: workflows/forms.py:643 msgid "Trial-Ende" msgstr "" -#: workflows/forms.py:570 +#: workflows/forms.py:644 msgid "Produktive Integrationen begrenzen" msgstr "" -#: workflows/forms.py:571 +#: workflows/forms.py:645 msgid "Cleanup nach Ablauf zulassen" msgstr "" -#: workflows/forms.py:572 +#: workflows/forms.py:646 msgid "Banner-Text DE" msgstr "" -#: workflows/forms.py:573 +#: workflows/forms.py:647 msgid "Banner-Text EN" msgstr "" -#: workflows/forms.py:593 +#: workflows/forms.py:667 msgid "Bitte ein Trial-Ende festlegen." msgstr "" -#: workflows/forms.py:595 +#: workflows/forms.py:669 msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:734 workflows/forms.py:919 +#: workflows/forms.py:808 workflows/forms.py:993 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:756 workflows/forms.py:933 +#: workflows/forms.py:830 workflows/forms.py:1007 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:841 +#: workflows/forms.py:915 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " @@ -763,257 +853,275 @@ msgid "" "an." msgstr "" -#: workflows/middleware.py:165 +#: workflows/middleware.py:171 msgid "" "Bitte bestätigen Sie Ihre Identität erneut, bevor Sie diese sensible Aktion " "ausführen." msgstr "" -#: workflows/models.py:235 workflows/views.py:493 +#: workflows/models.py:129 +msgid "Info" +msgstr "" + +#: workflows/models.py:130 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolg" +msgstr "Submitted" + +#: workflows/models.py:131 +msgid "Warnung" +msgstr "" + +#: workflows/models.py:132 workflows/templates/workflows/job_monitor.html:53 +msgid "Fehler" +msgstr "" + +#: workflows/models.py:308 workflows/views.py:601 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:236 workflows/views.py:493 +#: workflows/models.py:309 workflows/views.py:601 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:237 workflows/models.py:290 workflows/models.py:544 +#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:617 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:319 -#: workflows/views.py:493 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:427 +#: workflows/views.py:601 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:287 workflows/views.py:316 +#: workflows/models.py:360 workflows/views.py:424 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:288 workflows/views.py:317 +#: workflows/models.py:361 workflows/views.py:425 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:289 workflows/models.py:604 workflows/views.py:318 +#: workflows/models.py:362 workflows/models.py:677 workflows/views.py:426 msgid "Abgeschlossen" msgstr "Completed" -#: workflows/models.py:297 +#: workflows/models.py:370 msgid "Herr" msgstr "" -#: workflows/models.py:297 +#: workflows/models.py:370 msgid "Frau" msgstr "" -#: workflows/models.py:297 +#: workflows/models.py:370 msgid "Divers" msgstr "" -#: workflows/models.py:307 +#: workflows/models.py:380 msgid "befristet" msgstr "" -#: workflows/models.py:307 +#: workflows/models.py:380 msgid "unbefristet" msgstr "" -#: workflows/models.py:371 +#: workflows/models.py:444 msgid "Geräte" msgstr "" -#: workflows/models.py:372 +#: workflows/models.py:445 msgid "Software" msgstr "" -#: workflows/models.py:373 +#: workflows/models.py:446 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:374 +#: workflows/models.py:447 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:375 +#: workflows/models.py:448 msgid "Ressourcen" msgstr "" -#: workflows/models.py:376 +#: workflows/models.py:449 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:402 +#: workflows/models.py:475 msgid "Automatisch" msgstr "" -#: workflows/models.py:403 workflows/views.py:103 +#: workflows/models.py:476 workflows/views.py:119 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:404 workflows/views.py:104 +#: workflows/models.py:477 workflows/views.py:120 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:405 workflows/views.py:105 +#: workflows/models.py:478 workflows/views.py:121 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:406 workflows/views.py:106 +#: workflows/models.py:479 workflows/views.py:122 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:448 +#: workflows/models.py:521 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:449 +#: workflows/models.py:522 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:450 +#: workflows/models.py:523 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:451 +#: workflows/models.py:524 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:452 +#: workflows/models.py:525 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:453 +#: workflows/models.py:526 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:454 +#: workflows/models.py:527 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:455 +#: workflows/models.py:528 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:456 +#: workflows/models.py:529 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:457 +#: workflows/models.py:530 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:458 +#: workflows/models.py:531 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:494 +#: workflows/models.py:567 msgid "Immer" msgstr "" -#: workflows/models.py:495 workflows/models.py:573 +#: workflows/models.py:568 workflows/models.py:646 msgid "Enthält" msgstr "" -#: workflows/models.py:496 workflows/models.py:574 +#: workflows/models.py:569 workflows/models.py:647 msgid "Ist gleich" msgstr "" -#: workflows/models.py:497 +#: workflows/models.py:570 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:498 +#: workflows/models.py:571 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:540 +#: workflows/models.py:613 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:541 +#: workflows/models.py:614 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:542 +#: workflows/models.py:615 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:543 +#: workflows/models.py:616 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:566 workflows/tasks.py:600 +#: workflows/models.py:639 workflows/tasks.py:627 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:567 workflows/tasks.py:601 +#: workflows/models.py:640 workflows/tasks.py:628 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:568 workflows/tasks.py:602 +#: workflows/models.py:641 workflows/tasks.py:629 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:569 workflows/tasks.py:603 +#: workflows/models.py:642 workflows/tasks.py:630 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:572 +#: workflows/models.py:645 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:575 +#: workflows/models.py:648 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:576 +#: workflows/models.py:649 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:603 +#: workflows/models.py:676 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:623 +#: workflows/models.py:696 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:624 +#: workflows/models.py:697 msgid "S3" msgstr "" -#: workflows/models.py:625 +#: workflows/models.py:698 msgid "NFS" msgstr "" @@ -1040,94 +1148,146 @@ msgstr "IT Staff" msgid "Mitarbeiter" msgstr "Staff" -#: workflows/tasks.py:616 +#: workflows/tasks.py:270 +#, fuzzy, python-format +#| msgid "Welcome E-Mails" +msgid "Welcome E-Mail gesendet: %(name)s" +msgstr "Welcome Emails" + +#: workflows/tasks.py:272 +#, fuzzy, python-format +#| msgid "Fehlgeschlagen" +msgid "Welcome E-Mail fehlgeschlagen: %(name)s" +msgstr "Failed" + +#: workflows/tasks.py:643 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" msgstr "%(item)s handed over and basic functions explained" -#: workflows/tasks.py:618 +#: workflows/tasks.py:645 #, python-format msgid "%(item)s gezeigt bzw. Nutzung erklärt" msgstr "%(item)s shown or usage explained" -#: workflows/tasks.py:620 +#: workflows/tasks.py:647 #, python-format msgid "Telefonnummer / Direktwahl erklärt: %(value)s" msgstr "Phone number / direct extension explained: %(value)s" -#: workflows/tasks.py:622 +#: workflows/tasks.py:649 msgid "Arbeitsplatz, Geräte und allgemeine Nutzung besprochen" msgstr "Workplace, devices, and general usage reviewed" -#: workflows/tasks.py:624 +#: workflows/tasks.py:651 #, python-format msgid "%(item)s Zugang erklärt" msgstr "%(item)s access explained" -#: workflows/tasks.py:625 +#: workflows/tasks.py:652 #, python-format msgid "%(item)s Gruppe / Berechtigung erläutert" msgstr "%(item)s group / permission explained" -#: workflows/tasks.py:627 +#: workflows/tasks.py:654 #, python-format msgid "Dienstliche E-Mail-Adresse erläutert: %(value)s" msgstr "Work email address explained: %(value)s" -#: workflows/tasks.py:629 +#: workflows/tasks.py:656 #, python-format msgid "Gruppenpostfach erklärt: %(item)s" msgstr "Group mailbox explained: %(item)s" -#: workflows/tasks.py:631 +#: workflows/tasks.py:658 msgid "Zugänge, Konten und Anmeldelogik besprochen" msgstr "Accesses, accounts, and login logic reviewed" -#: workflows/tasks.py:633 +#: workflows/tasks.py:660 #, python-format msgid "%(item)s Einführung durchgeführt" msgstr "%(item)s introduction completed" -#: workflows/tasks.py:634 +#: workflows/tasks.py:661 #, python-format msgid "%(item)s zusätzlich besprochen" msgstr "%(item)s discussed additionally" -#: workflows/tasks.py:636 +#: workflows/tasks.py:663 msgid "Benötigte Standardsoftware und tägliche Nutzung erklärt" msgstr "Required standard software and daily usage explained" -#: workflows/tasks.py:639 +#: workflows/tasks.py:666 msgid "Passwortregeln und sicherer Umgang besprochen" msgstr "Password rules and secure handling reviewed" -#: workflows/tasks.py:640 +#: workflows/tasks.py:667 msgid "Dateiablage, Nextcloud und Freigaben erklärt" msgstr "File storage, Nextcloud, and sharing explained" -#: workflows/tasks.py:641 +#: workflows/tasks.py:668 msgid "Kommunikationswege und Support-Prozess erklärt" msgstr "Communication channels and support process explained" -#: workflows/tasks.py:644 +#: workflows/tasks.py:671 #, python-format msgid "%(item)s als zusätzliche Ausstattung besprochen" msgstr "%(item)s discussed as additional equipment" -#: workflows/tasks.py:646 +#: workflows/tasks.py:673 #, python-format msgid "Zusätzlicher Zugang besprochen: %(item)s" msgstr "Additional access discussed: %(item)s" -#: workflows/tasks.py:648 +#: workflows/tasks.py:675 #, python-format msgid "Übergabe-/Nachfolgekontext besprochen: %(value)s" msgstr "Handover / successor context reviewed: %(value)s" +#: workflows/tasks.py:1363 +#, fuzzy, python-format +#| msgid "Einweisung wurde als abgeschlossen gespeichert." +msgid "Onboarding abgeschlossen: %(name)s" +msgstr "Introduction was saved as completed." + +#: workflows/tasks.py:1364 +msgid "Die Onboarding-Anfrage wurde erfolgreich verarbeitet." +msgstr "" + +#: workflows/tasks.py:1375 +#, fuzzy, python-format +#| msgid "Fehlgeschlagen" +msgid "Onboarding fehlgeschlagen: %(name)s" +msgstr "Failed" + +#: workflows/tasks.py:1464 +#, fuzzy, python-format +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding abgeschlossen: %(name)s" +msgstr "Save offboarding request" + +#: workflows/tasks.py:1465 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die Offboarding-Anfrage wurde erfolgreich verarbeitet." +msgstr "Save offboarding request" + +#: workflows/tasks.py:1476 +#, fuzzy, python-format +#| msgid "Offboarding-Anfrage speichern" +msgid "Offboarding fehlgeschlagen: %(name)s" +msgstr "Save offboarding request" + +#: workflows/tasks.py:1551 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die geplante Welcome E-Mail wurde erfolgreich versendet." +msgstr "Save offboarding request" + #: workflows/templates/registration/login.html:4 #: workflows/templates/registration/login.html:19 #: workflows/templates/workflows/auth/login.html:4 -#: workflows/templates/workflows/auth/login.html:17 +#: workflows/templates/workflows/auth/login.html:27 msgid "Anmeldung" msgstr "Sign in" @@ -1136,19 +1296,20 @@ msgid "Bitte melden Sie sich mit Ihrem Benutzerkonto an." msgstr "Please sign in with your user account." #: workflows/templates/registration/login.html:30 -#: workflows/templates/workflows/auth/login.html:28 +#: workflows/templates/workflows/auth/login.html:43 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Anmeldung fehlgeschlagen" msgstr "Failed" #: workflows/templates/registration/login.html:31 +#: workflows/templates/workflows/auth/login.html:44 msgid "" "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." msgstr "" #: workflows/templates/registration/login.html:37 -#: workflows/templates/workflows/auth/login.html:43 +#: workflows/templates/workflows/auth/login.html:74 msgid "Anmelden" msgstr "Sign in" @@ -1257,7 +1418,7 @@ msgstr "Request link" #: workflows/templates/workflows/account_profile.html:4 #: workflows/templates/workflows/account_profile.html:20 -#: workflows/templates/workflows/includes/app_header.html:47 +#: workflows/templates/workflows/includes/app_header.html:99 msgid "Profil" msgstr "Profile" @@ -1266,27 +1427,46 @@ msgid "Konto" msgstr "Account" #: workflows/templates/workflows/account_profile.html:21 -msgid "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." +#, fuzzy +#| msgid "" +#| "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." +msgid "Ihre aktuelle Kontoübersicht und wichtige Sicherheitsaktionen." msgstr "Your current Workdock account overview and important security actions." +#: workflows/templates/workflows/account_profile.html:23 +#: workflows/templates/workflows/user_management.html:76 +msgid "Letzte Anmeldung" +msgstr "Last login" + #: workflows/templates/workflows/account_profile.html:54 msgid "Klicken Sie auf das Bild, um ein neues Profilbild auszuwählen." msgstr "Click the image to choose a new profile picture." -#: workflows/templates/workflows/account_profile.html:78 -#: workflows/templates/workflows/user_management.html:76 -msgid "Letzte Anmeldung" -msgstr "Last login" - -#: workflows/templates/workflows/account_profile.html:88 +#: workflows/templates/workflows/account_profile.html:67 +#: workflows/templates/workflows/account_profile.html:92 msgid "Kontodaten" msgstr "Account details" -#: workflows/templates/workflows/account_profile.html:89 +#: workflows/templates/workflows/account_profile.html:75 +#: workflows/templates/workflows/account_profile.html:256 +msgid "Sicherheit & Aktionen" +msgstr "Security & actions" + +#: workflows/templates/workflows/account_profile.html:83 +#: workflows/templates/workflows/account_profile.html:178 +#: workflows/templates/workflows/includes/app_header.html:24 +#: workflows/templates/workflows/includes/app_header.html:37 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigungen" +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:93 msgid "Die wichtigsten Stammdaten Ihres aktuellen Kontos." msgstr "The most important master data of your current account." -#: workflows/templates/workflows/account_profile.html:97 +#: workflows/templates/workflows/account_profile.html:101 +#: workflows/templates/workflows/account_profile.html:187 #: workflows/templates/workflows/branding_settings.html:32 #: workflows/templates/workflows/company_config.html:25 #, fuzzy @@ -1294,14 +1474,14 @@ msgstr "The most important master data of your current account." msgid "Bearbeiten" msgstr "Processing" -#: workflows/templates/workflows/account_profile.html:104 +#: workflows/templates/workflows/account_profile.html:108 #: workflows/templates/workflows/onboarding_intro_session.html:27 #: workflows/templates/workflows/request_timeline.html:66 #: workflows/templates/workflows/user_management.html:71 msgid "Name" msgstr "Name" -#: workflows/templates/workflows/account_profile.html:108 +#: workflows/templates/workflows/account_profile.html:112 #: workflows/templates/workflows/request_timeline.html:74 #: workflows/templates/workflows/requests_dashboard.html:190 #: workflows/templates/workflows/user_management.html:73 @@ -1309,14 +1489,18 @@ msgstr "Name" msgid "E-Mail" msgstr "Email" -#: workflows/templates/workflows/account_profile.html:165 +#: workflows/templates/workflows/account_profile.html:169 +#: workflows/templates/workflows/account_profile.html:248 #: workflows/templates/workflows/branding_settings.html:177 #: workflows/templates/workflows/company_config.html:54 #: workflows/templates/workflows/user_management.html:115 msgid "Speichern" msgstr "Save" -#: workflows/templates/workflows/account_profile.html:166 +#: workflows/templates/workflows/account_profile.html:170 +#: workflows/templates/workflows/account_profile.html:249 +#: workflows/templates/workflows/account_profile.html:332 +#: workflows/templates/workflows/account_profile.html:386 #: workflows/templates/workflows/base_shell.html:79 #: workflows/templates/workflows/branding_settings.html:178 #: workflows/templates/workflows/company_config.html:55 @@ -1324,57 +1508,14 @@ msgstr "Save" msgid "Abbrechen" msgstr "Cancel" -#: workflows/templates/workflows/account_profile.html:173 -msgid "Sicherheit & Aktionen" -msgstr "Security & actions" - -#: workflows/templates/workflows/account_profile.html:174 -msgid "Direkte Aktionen für Ihr Workdock-Konto." -msgstr "Direct actions for your Workdock account." - #: workflows/templates/workflows/account_profile.html:179 -#: workflows/templates/workflows/account_profile.html:315 -#: workflows/templates/workflows/auth/password_change_form.html:4 -#: workflows/templates/workflows/auth/password_change_form.html:17 -#: workflows/templates/workflows/includes/app_header.html:48 -msgid "Passwort ändern" -msgstr "Change password" - -#: workflows/templates/workflows/account_profile.html:180 -msgid "Aktualisieren Sie Ihr Passwort direkt im Konto." -msgstr "Update your password directly in your account." - -#: workflows/templates/workflows/account_profile.html:184 -msgid "TOTP" -msgstr "" - -#: workflows/templates/workflows/account_profile.html:187 -msgid "Zweiter Faktor ist aktiv und wird bei der Anmeldung geprüft." -msgstr "" - -#: workflows/templates/workflows/account_profile.html:189 -msgid "Standardmäßig deaktiviert. Kann hier jederzeit aktiviert werden." -msgstr "" - -#: workflows/templates/workflows/account_profile.html:195 -msgid "Sitzung" -msgstr "Session" - -#: workflows/templates/workflows/account_profile.html:196 -msgid "Sie können sich jederzeit sicher vom aktuellen Gerät abmelden." -msgstr "" - -#: workflows/templates/workflows/account_profile.html:203 -msgid "Zwei-Faktor-Authentifizierung" -msgstr "" - -#: workflows/templates/workflows/account_profile.html:204 msgid "" -"Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es " -"ausgeschaltet." +"Legen Sie fest, welche Workflow-Ereignisse im Header als Benachrichtigung " +"erscheinen sollen." msgstr "" -#: workflows/templates/workflows/account_profile.html:208 +#: workflows/templates/workflows/account_profile.html:199 +#: workflows/templates/workflows/account_profile.html:262 #: workflows/templates/workflows/app_registry.html:35 #: workflows/templates/workflows/app_registry.html:84 #: workflows/templates/workflows/form_builder.html:91 @@ -1385,70 +1526,178 @@ msgstr "" msgid "Aktiv" msgstr "Active" -#: workflows/templates/workflows/account_profile.html:210 +#: workflows/templates/workflows/account_profile.html:199 +#: workflows/templates/workflows/account_profile.html:262 #, fuzzy #| msgid "Auf" msgid "Aus" msgstr "To" -#: workflows/templates/workflows/account_profile.html:219 +#: workflows/templates/workflows/account_profile.html:223 +msgid "Benachrichtigung nach erfolgreich abgeschlossenem Onboarding." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:224 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn ein Onboarding fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:225 +msgid "Benachrichtigung nach erfolgreich abgeschlossenem Offboarding." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:226 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn ein Offboarding fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:227 +msgid "Benachrichtigung bei erfolgreicher Backup-Erstellung oder Verifikation." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:228 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn Backup-Aktionen fehlschlagen." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:229 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "" +"Benachrichtigung wenn eine geplante Welcome E-Mail erfolgreich gesendet " +"wurde." +msgstr "Password could not be saved" + +#: workflows/templates/workflows/account_profile.html:230 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigung wenn eine geplante Welcome E-Mail fehlschlägt." +msgstr "Save offboarding request" + +#: workflows/templates/workflows/account_profile.html:231 +msgid "Hinweise zu Trial-Ablauf, Ablaufdatum oder Deaktivierung." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:232 +msgid "Hinweise aus Systemtests wie SMTP oder Nextcloud." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:261 +msgid "TOTP" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:265 +msgid "Anmeldung wird zusätzlich mit einem zweiten Faktor geschützt." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:267 +msgid "Optional. Kann bei Bedarf direkt unten aktiviert werden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:272 +#: workflows/templates/workflows/account_profile.html:441 +msgid "Recovery-Codes" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:282 +msgid "Einmal-Codes für Notfälle oder verlorene Authenticator-Geräte." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:284 +msgid "Werden automatisch erzeugt, sobald TOTP aktiviert wird." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:293 +msgid "Zwei-Faktor-Authentifizierung" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:294 +msgid "" +"Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es " +"ausgeschaltet." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:301 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP ist aktiviert." msgstr "Disabled" -#: workflows/templates/workflows/account_profile.html:222 +#: workflows/templates/workflows/account_profile.html:302 msgid "Bestätigt am" msgstr "" -#: workflows/templates/workflows/account_profile.html:241 +#: workflows/templates/workflows/account_profile.html:310 #, fuzzy #| msgid "Aktivieren" msgid "TOTP deaktivieren" msgstr "Enable" -#: workflows/templates/workflows/account_profile.html:259 +#: workflows/templates/workflows/account_profile.html:331 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Deaktivierung bestätigen" +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:340 +#: workflows/templates/workflows/account_profile.html:349 msgid "Recovery-Codes neu erzeugen" msgstr "" -#: workflows/templates/workflows/account_profile.html:268 +#: workflows/templates/workflows/account_profile.html:341 +msgid "" +"Neue Recovery-Codes sollten nur erzeugt werden, wenn die bisherigen Codes " +"nicht mehr sicher sind." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:368 +msgid "Stattdessen Recovery-Code verwenden" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:385 +#, fuzzy +#| msgid "Deaktivieren" +msgid "Erzeugung bestätigen" +msgstr "Disabled" + +#: workflows/templates/workflows/account_profile.html:397 #, fuzzy #| msgid "Onboarding starten" msgid "Manueller Schlüssel" msgstr "Start onboarding" -#: workflows/templates/workflows/account_profile.html:272 -#, fuzzy -#| msgid "Setup Mail" -msgid "Setup-Link" -msgstr "Setup Mail" - -#: workflows/templates/workflows/account_profile.html:276 -msgid "" -"Wenn Ihre App keinen QR-Code scannen kann, tragen Sie den Schlüssel oder den " -"otpauth-Link manuell ein." +#: workflows/templates/workflows/account_profile.html:398 +msgid "Nur bei Bedarf anzeigen" msgstr "" -#: workflows/templates/workflows/account_profile.html:292 +#: workflows/templates/workflows/account_profile.html:406 +msgid "Manuellen Schlüssel anzeigen oder ausblenden" +msgstr "" + +#: workflows/templates/workflows/account_profile.html:416 +msgid "" +"Scannen Sie den QR-Code mit Ihrer Authenticator-App. Den manuellen Schlüssel " +"können Sie bei Bedarf einblenden." +msgstr "" + +#: workflows/templates/workflows/account_profile.html:432 #, fuzzy #| msgid "Aktivieren" msgid "TOTP aktivieren" msgstr "Enable" -#: workflows/templates/workflows/account_profile.html:301 -msgid "Recovery-Codes" -msgstr "" - -#: workflows/templates/workflows/account_profile.html:302 +#: workflows/templates/workflows/account_profile.html:442 msgid "" "Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie " "genau einmal verwenden." msgstr "" -#: workflows/templates/workflows/account_profile.html:318 -#: workflows/templates/workflows/includes/app_header.html:51 -msgid "Abmelden" -msgstr "Log out" +#: workflows/templates/workflows/account_profile.html:445 +msgid "Herunterladen" +msgstr "" #: workflows/templates/workflows/app_registry.html:3 #, fuzzy @@ -1750,20 +1999,44 @@ msgstr "" msgid "Noch keine Audit-Einträge vorhanden." msgstr "No requests available yet." -#: workflows/templates/workflows/auth/login.html:29 -msgid "" -"Anmeldedaten oder TOTP-Code sind nicht korrekt. Bitte versuchen Sie es " -"erneut." +#: workflows/templates/workflows/auth/login.html:18 +msgid "Zwei-Faktor-Prüfung" msgstr "" -#: workflows/templates/workflows/auth/login.html:37 -msgid "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." +#: workflows/templates/workflows/auth/login.html:19 +msgid "Geben Sie Ihren TOTP-Code ein, um die Anmeldung abzuschließen." msgstr "" +#: workflows/templates/workflows/auth/login.html:40 +#, fuzzy +#| msgid "Link ungültig" +msgid "Code ungültig" +msgstr "Invalid link" + #: workflows/templates/workflows/auth/login.html:41 -msgid "Alternativ können Sie einen einmaligen Recovery-Code verwenden." +msgid "" +"Der eingegebene TOTP- oder Recovery-Code ist nicht korrekt. Bitte versuchen " +"Sie es erneut." msgstr "" +#: workflows/templates/workflows/auth/login.html:60 +msgid "Recovery-Code verwenden" +msgstr "" + +#: workflows/templates/workflows/auth/login.html:66 +msgid "Nutzen Sie stattdessen einen einmaligen Recovery-Code." +msgstr "" + +#: workflows/templates/workflows/auth/login.html:69 +msgid "Code prüfen" +msgstr "" + +#: workflows/templates/workflows/auth/login.html:70 +#, fuzzy +#| msgid "Zur Anmeldung" +msgid "Zurück zur Anmeldung" +msgstr "Back to sign in" + #: workflows/templates/workflows/auth/password_change_done.html:4 #: workflows/templates/workflows/auth/password_change_done.html:17 #: workflows/templates/workflows/user_management.html:174 @@ -1780,6 +2053,12 @@ msgstr "" msgid "Zum Profil" msgstr "" +#: workflows/templates/workflows/auth/password_change_form.html:4 +#: workflows/templates/workflows/auth/password_change_form.html:17 +#: workflows/templates/workflows/includes/app_header.html:100 +msgid "Passwort ändern" +msgstr "Change password" + #: workflows/templates/workflows/auth/password_change_form.html:18 #, fuzzy #| msgid "Bitte vergeben Sie jetzt ein neues Passwort für Ihr Konto." @@ -2159,10 +2438,6 @@ msgstr "" "Single documentation entry point for both operational knowledge and long-" "term engineering knowledge." -#: workflows/templates/workflows/handbook.html:21 -msgid "Operations" -msgstr "Operations" - #: workflows/templates/workflows/handbook.html:22 msgid "Project Wiki" msgstr "Project Wiki" @@ -2339,6 +2614,28 @@ msgstr "Back to home" msgid "Zum Dashboard" msgstr "Go to dashboard" +#: workflows/templates/workflows/includes/app_header.html:42 +#, fuzzy +#| msgid "Alle auswählen" +msgid "Alle als gelesen" +msgstr "Select all" + +#: workflows/templates/workflows/includes/app_header.html:63 +#, fuzzy +#| msgid "Gesendet" +msgid "Gelesen" +msgstr "Sent" + +#: workflows/templates/workflows/includes/app_header.html:71 +#, fuzzy +#| msgid "Noch keine Vorgänge vorhanden." +msgid "Keine Benachrichtigungen vorhanden." +msgstr "No backup bundles available yet." + +#: workflows/templates/workflows/includes/app_header.html:103 +msgid "Abmelden" +msgstr "Log out" + #: workflows/templates/workflows/integrations_setup.html:4 #: workflows/templates/workflows/integrations_setup.html:14 msgid "Integrationen Setup" @@ -2715,10 +3012,6 @@ msgstr "" msgid "Task ID" msgstr "" -#: workflows/templates/workflows/job_monitor.html:53 -msgid "Fehler" -msgstr "" - #: workflows/templates/workflows/job_monitor.html:67 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." @@ -2910,7 +3203,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1275 +#: workflows/views.py:1444 msgid "Vertragsbeginn" msgstr "Contract start" @@ -3714,312 +4007,324 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:103 +#: workflows/views.py:119 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:104 +#: workflows/views.py:120 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:105 +#: workflows/views.py:121 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:106 +#: workflows/views.py:122 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:158 +#: workflows/views.py:255 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:160 +#: workflows/views.py:257 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:166 +#: workflows/views.py:263 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:168 +#: workflows/views.py:265 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:177 +#: workflows/views.py:271 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Benachrichtigungseinstellungen gespeichert." +msgstr "Save offboarding request" + +#: workflows/views.py:273 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Benachrichtigungseinstellungen konnten nicht gespeichert werden." +msgstr "Password could not be saved" + +#: workflows/views.py:282 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP wurde aktiviert." msgstr "Disabled" -#: workflows/views.py:179 +#: workflows/views.py:284 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht aktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:186 +#: workflows/views.py:291 msgid "TOTP wurde deaktiviert." msgstr "" -#: workflows/views.py:188 +#: workflows/views.py:293 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht deaktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:197 +#: workflows/views.py:302 msgid "Recovery-Codes wurden neu erzeugt." msgstr "" -#: workflows/views.py:199 +#: workflows/views.py:304 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Recovery-Codes konnten nicht neu erzeugt werden." msgstr "Password could not be saved" -#: workflows/views.py:245 workflows/views.py:1361 workflows/views.py:1366 +#: workflows/views.py:353 workflows/views.py:1530 workflows/views.py:1535 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:326 +#: workflows/views.py:434 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:327 +#: workflows/views.py:435 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:328 +#: workflows/views.py:436 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:329 +#: workflows/views.py:437 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:330 +#: workflows/views.py:438 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:331 +#: workflows/views.py:439 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:332 +#: workflows/views.py:440 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:333 +#: workflows/views.py:441 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:334 +#: workflows/views.py:442 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:335 +#: workflows/views.py:443 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:336 +#: workflows/views.py:444 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:337 +#: workflows/views.py:445 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:338 +#: workflows/views.py:446 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:339 +#: workflows/views.py:447 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:340 +#: workflows/views.py:448 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:341 +#: workflows/views.py:449 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:342 +#: workflows/views.py:450 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:343 +#: workflows/views.py:451 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:344 +#: workflows/views.py:452 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:345 +#: workflows/views.py:453 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:346 +#: workflows/views.py:454 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:347 +#: workflows/views.py:455 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:348 +#: workflows/views.py:456 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:349 +#: workflows/views.py:457 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:350 +#: workflows/views.py:458 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:351 +#: workflows/views.py:459 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:352 +#: workflows/views.py:460 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:353 +#: workflows/views.py:461 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:354 +#: workflows/views.py:462 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:355 +#: workflows/views.py:463 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:356 +#: workflows/views.py:464 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:357 +#: workflows/views.py:465 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:358 +#: workflows/views.py:466 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:359 +#: workflows/views.py:467 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:360 +#: workflows/views.py:468 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:361 +#: workflows/views.py:469 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:362 +#: workflows/views.py:470 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:363 +#: workflows/views.py:471 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:364 +#: workflows/views.py:472 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:536 +#: workflows/views.py:644 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:635 +#: workflows/views.py:743 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:644 +#: workflows/views.py:752 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:646 +#: workflows/views.py:754 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4032,12 +4337,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:657 +#: workflows/views.py:765 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:659 +#: workflows/views.py:767 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4050,7 +4355,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:710 +#: workflows/views.py:818 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4058,69 +4363,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:738 +#: workflows/views.py:846 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:755 +#: workflows/views.py:863 msgid "Identität" msgstr "" -#: workflows/views.py:756 +#: workflows/views.py:864 msgid "Titel, Firmenname und zentrale Spracheinstellungen." msgstr "" -#: workflows/views.py:760 +#: workflows/views.py:868 msgid "" "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " -"B. tub.co." +"B. workdock.de." msgstr "" -#: workflows/views.py:765 +#: workflows/views.py:873 msgid "Farben & Erscheinungsbild" msgstr "" -#: workflows/views.py:766 +#: workflows/views.py:874 msgid "Zentrale visuelle Markenwerte und Browser-Icon." msgstr "" -#: workflows/views.py:770 +#: workflows/views.py:878 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/views.py:771 +#: workflows/views.py:879 msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." msgstr "" -#: workflows/views.py:776 +#: workflows/views.py:884 #, fuzzy #| msgid "Produktion" msgid "Kommunikation" msgstr "Production" -#: workflows/views.py:777 +#: workflows/views.py:885 msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." msgstr "" -#: workflows/views.py:781 +#: workflows/views.py:889 msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." msgstr "" -#: workflows/views.py:782 +#: workflows/views.py:890 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/views.py:787 +#: workflows/views.py:895 msgid "Footer & Rechtliches" msgstr "" -#: workflows/views.py:788 +#: workflows/views.py:896 msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." msgstr "" -#: workflows/views.py:842 +#: workflows/views.py:950 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4129,53 +4434,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:871 +#: workflows/views.py:979 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:888 +#: workflows/views.py:996 #, fuzzy #| msgid "Firmenname" msgid "Firmenprofil" msgstr "Company name" -#: workflows/views.py:889 +#: workflows/views.py:997 msgid "Rechtlicher Name und zentrale Stammdaten der Firma." msgstr "" -#: workflows/views.py:894 +#: workflows/views.py:1002 msgid "Adresse & Register" msgstr "" -#: workflows/views.py:895 +#: workflows/views.py:1003 msgid "Anschrift sowie optionale Register- und Steuerangaben." msgstr "" -#: workflows/views.py:900 +#: workflows/views.py:1008 msgid "Kontaktpunkte" msgstr "" -#: workflows/views.py:901 +#: workflows/views.py:1009 msgid "Zentrale Ansprechpartner für HR, IT und Operations." msgstr "" -#: workflows/views.py:906 +#: workflows/views.py:1014 msgid "Recht & Öffentlichkeit" msgstr "" -#: workflows/views.py:907 +#: workflows/views.py:1015 msgid "Öffentliche Links für Website, Impressum und Datenschutz." msgstr "" -#: workflows/views.py:909 +#: workflows/views.py:1017 msgid "" "Diese Links können später im Portal-Footer oder in öffentlichen Seiten " "verwendet werden." msgstr "" -#: workflows/views.py:949 +#: workflows/views.py:1057 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4184,21 +4489,54 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:976 +#: workflows/views.py:1089 +#, fuzzy +#| msgid "Trial abgelaufen" +msgid "Trial ist abgelaufen" +msgstr "Trial expired" + +#: workflows/views.py:1090 +msgid "" +"Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt " +"blockiert." +msgstr "" + +#: workflows/views.py:1098 +msgid "Trial läuft bald ab" +msgstr "" + +#: workflows/views.py:1099 +#, python-format +msgid "Der Trial endet am %(date)s." +msgstr "" + +#: workflows/views.py:1107 +#, fuzzy +#| msgid "Trial-Modus" +msgid "Trial-Modus deaktiviert" +msgstr "Trial mode" + +#: workflows/views.py:1108 +#, fuzzy +#| msgid "Nextcloud schalten" +msgid "Der Trial-Modus wurde ausgeschaltet." +msgstr "Toggle Nextcloud" + +#: workflows/views.py:1113 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:993 +#: workflows/views.py:1130 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:1006 +#: workflows/views.py:1143 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1028 +#: workflows/views.py:1165 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4209,14 +4547,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1031 +#: workflows/views.py:1168 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1034 +#: workflows/views.py:1171 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4227,7 +4565,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1037 +#: workflows/views.py:1174 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4238,18 +4576,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1054 +#: workflows/views.py:1191 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:1076 +#: workflows/views.py:1213 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1088 +#: workflows/views.py:1225 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4259,7 +4597,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1091 +#: workflows/views.py:1228 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4269,7 +4607,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1094 +#: workflows/views.py:1231 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4278,7 +4616,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1097 +#: workflows/views.py:1234 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4287,124 +4625,206 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1110 +#: workflows/views.py:1247 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1199 +#: workflows/views.py:1338 +#, fuzzy, python-format +#| msgid "Anfrage gespeichert" +msgid "Backup erstellt: %(name)s" +msgstr "Request saved" + +#: workflows/views.py:1339 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Das Backup-Bundle wurde erfolgreich erstellt." +msgstr "Save offboarding request" + +#: workflows/views.py:1344 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:1201 +#: workflows/views.py:1354 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1217 +#: workflows/views.py:1372 +#, fuzzy, python-format +#| msgid "Backup wird verifiziert" +msgid "Backup verifiziert: %(name)s" +msgstr "Backup is being verified" + +#: workflows/views.py:1373 +#, fuzzy +#| msgid "Backup wird verifiziert" +msgid "Das Backup wurde erfolgreich verifiziert." +msgstr "Backup is being verified" + +#: workflows/views.py:1378 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1219 +#: workflows/views.py:1382 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Backup-Verifikation fehlgeschlagen" +msgstr "Failed" + +#: workflows/views.py:1388 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1235 +#: workflows/views.py:1404 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1237 +#: workflows/views.py:1406 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1263 +#: workflows/views.py:1432 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1265 +#: workflows/views.py:1434 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1277 +#: workflows/views.py:1446 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1287 +#: workflows/views.py:1456 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1289 +#: workflows/views.py:1458 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1298 +#: workflows/views.py:1467 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1324 +#: workflows/views.py:1493 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1336 -#, fuzzy -#| msgid "Welcome E-Mails" -msgid "Welcome E-Mail" -msgstr "Welcome Emails" - -#: workflows/views.py:1375 +#: workflows/views.py:1544 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1418 +#: workflows/views.py:1587 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1420 +#: workflows/views.py:1589 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1422 +#: workflows/views.py:1591 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1650 +#: workflows/views.py:1819 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1667 +#: workflows/views.py:1836 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1696 +#: workflows/views.py:1865 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1710 +#: workflows/views.py:1879 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1723 +#: workflows/views.py:1892 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." +#: workflows/views.py:2677 +#, fuzzy +#| msgid "SMTP-Test starten" +msgid "SMTP-Test erfolgreich" +msgstr "Run SMTP test" + +#: workflows/views.py:2678 +#, fuzzy +#| msgid "Offboarding-Anfrage speichern" +msgid "Die SMTP-Testmail wurde erfolgreich gesendet." +msgstr "Save offboarding request" + +#: workflows/views.py:2687 +#, fuzzy +#| msgid "SMTP-Test" +msgid "SMTP-Test fehlgeschlagen" +msgstr "SMTP test" + +#: workflows/views.py:2693 +#, fuzzy, python-format +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "SMTP-Testmail konnte nicht gesendet werden: %(error)s" +msgstr "Password could not be saved" + +#: workflows/views.py:2718 +#, fuzzy +#| msgid "Nextcloud-Test starten" +msgid "Nextcloud-Test erfolgreich" +msgstr "Run Nextcloud test" + +#: workflows/views.py:2719 +msgid "Der Testupload nach Nextcloud war erfolgreich." +msgstr "" + +#: workflows/views.py:2729 workflows/views.py:2739 +#, fuzzy +#| msgid "Nextcloud-Test starten" +msgid "Nextcloud-Test fehlgeschlagen" +msgstr "Run Nextcloud test" + +#: workflows/views.py:2730 +msgid "Der Testupload nach Nextcloud ist fehlgeschlagen." +msgstr "" + +#~ msgid "Direkte Aktionen für Ihr Workdock-Konto." +#~ msgstr "Direct actions for your Workdock account." + +#~ msgid "Aktualisieren Sie Ihr Passwort direkt im Konto." +#~ msgstr "Update your password directly in your account." + +#~ msgid "Sitzung" +#~ msgstr "Session" + +#, fuzzy +#~| msgid "Setup Mail" +#~ msgid "Setup-Link" +#~ msgstr "Setup Mail" + #~ msgid "Branding speichern" #~ msgstr "Save branding" diff --git a/backend/workflows/context_processors.py b/backend/workflows/context_processors.py index 91208d2..e31f0e4 100644 --- a/backend/workflows/context_processors.py +++ b/backend/workflows/context_processors.py @@ -1,4 +1,5 @@ from .branding import get_branding_context, get_trial_context +from .models import UserNotification from .roles import template_role_context @@ -6,4 +7,15 @@ def role_context(request): context = template_role_context(getattr(request, 'user', None)) context.update(get_branding_context()) context.update(get_trial_context()) + user = getattr(request, 'user', None) + if getattr(user, 'is_authenticated', False): + notifications = list(UserNotification.objects.filter(user=user).order_by('-created_at')[:8]) + context.update( + { + 'header_notifications': notifications, + 'header_unread_notification_count': UserNotification.objects.filter(user=user, read_at__isnull=True).count(), + } + ) + else: + context.update({'header_notifications': [], 'header_unread_notification_count': 0}) return context diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index a9f71a7..4bf0d1f 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -1,7 +1,7 @@ from django import forms from pathlib import Path from datetime import timedelta -from django.contrib.auth import get_user_model, password_validation +from django.contrib.auth import authenticate, get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm from django.core.exceptions import ValidationError from django.utils import timezone @@ -10,7 +10,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy from .branding import get_company_email_domain from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig -from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role +from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token @@ -102,9 +102,41 @@ HARDWARE_EXTRA_CHOICES = [('Smartphone', 'Smartphone'), ('Anderes', 'Anderes')] SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')] -class AppAuthenticationForm(AuthenticationForm): +class AppLoginForm(forms.Form): username = forms.CharField(label=gettext_lazy('Benutzername')) - password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'})) + password = forms.CharField( + label=gettext_lazy('Passwort'), + strip=False, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), + ) + + error_messages = { + 'invalid_login': gettext_lazy('Benutzername oder Passwort sind nicht korrekt.'), + 'inactive': gettext_lazy('Dieses Konto ist deaktiviert.'), + } + + def __init__(self, request=None, *args, **kwargs): + self.request = request + self.user_cache = None + super().__init__(*args, **kwargs) + + def clean(self): + cleaned_data = super().clean() + username = cleaned_data.get('username') + password = cleaned_data.get('password') + if username and password: + self.user_cache = authenticate(self.request, username=username, password=password) + if self.user_cache is None: + raise ValidationError(self.error_messages['invalid_login'], code='invalid_login') + if not self.user_cache.is_active: + raise ValidationError(self.error_messages['inactive'], code='inactive') + return cleaned_data + + def get_user(self): + return self.user_cache + + +class AppTOTPChallengeForm(forms.Form): otp_code = forms.CharField( label=gettext_lazy('TOTP-Code'), required=False, @@ -119,37 +151,30 @@ class AppAuthenticationForm(AuthenticationForm): ) error_messages = { - **AuthenticationForm.error_messages, 'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'), 'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'), } + def __init__(self, *args, profile=None, **kwargs): + self.profile = profile + super().__init__(*args, **kwargs) + def clean(self): cleaned_data = super().clean() - user = self.get_user() - if not user: + profile = self.profile + if not profile or not profile.totp_enabled: return cleaned_data - profile, _ = UserProfile.objects.get_or_create(user=user) - if profile.totp_enabled: - otp_code = normalize_totp_token(cleaned_data.get('otp_code')) - recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) - if recovery_code: - if not profile.consume_recovery_code(recovery_code): - raise ValidationError( - self.error_messages['invalid_otp'], - code='invalid_otp', - ) - return cleaned_data - if not otp_code: - raise ValidationError( - self.error_messages['missing_otp'], - code='missing_otp', - ) - if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())): - raise ValidationError( - self.error_messages['invalid_otp'], - code='invalid_otp', - ) + + otp_code = normalize_totp_token(cleaned_data.get('otp_code')) + recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) + if recovery_code: + if not profile.consume_recovery_code(recovery_code): + raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp') + return cleaned_data + if not otp_code: + raise ValidationError(self.error_messages['missing_otp'], code='missing_otp') + if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())): + raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp') return cleaned_data @@ -307,18 +332,6 @@ class AccountTOTPDisableForm(forms.Form): strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), ) - verification_code = forms.CharField( - label=gettext_lazy('TOTP-Code'), - max_length=12, - required=False, - widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}), - ) - recovery_code = forms.CharField( - label=gettext_lazy('Recovery-Code'), - max_length=32, - required=False, - widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}), - ) def __init__(self, *args, user=None, profile=None, **kwargs): super().__init__(*args, **kwargs) @@ -333,26 +346,12 @@ class AccountTOTPDisableForm(forms.Form): def clean(self): cleaned_data = super().clean() - code = normalize_totp_token(cleaned_data.get('verification_code')) - recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code')) - if not code and not recovery_code: - raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.')) - secret = getattr(self.profile, 'totp_secret', '') or '' - if code: - if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())): - raise ValidationError(_('Der TOTP-Code ist ungültig.')) - return cleaned_data - if not self.profile.consume_recovery_code(recovery_code): - raise ValidationError(_('Der Recovery-Code ist ungültig.')) + if not self.profile or not self.profile.totp_enabled: + raise ValidationError(_('TOTP ist für dieses Konto nicht aktiv.')) return cleaned_data class AccountTOTPRegenerateRecoveryCodesForm(forms.Form): - current_password = forms.CharField( - label=gettext_lazy('Aktuelles Passwort'), - strip=False, - widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), - ) verification_code = forms.CharField( label=gettext_lazy('TOTP-Code'), max_length=12, @@ -371,12 +370,6 @@ class AccountTOTPRegenerateRecoveryCodesForm(forms.Form): self.user = user self.profile = profile - def clean_current_password(self): - password = self.cleaned_data.get('current_password') or '' - if not self.user or not self.user.check_password(password): - raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.')) - return password - def clean(self): cleaned_data = super().clean() code = normalize_totp_token(cleaned_data.get('verification_code')) @@ -393,6 +386,87 @@ class AccountTOTPRegenerateRecoveryCodesForm(forms.Form): return cleaned_data +class AccountNotificationPreferencesForm(forms.Form): + onboarding_success = forms.BooleanField(label=gettext_lazy('Onboarding erfolgreich'), required=False) + onboarding_failure = forms.BooleanField(label=gettext_lazy('Onboarding fehlgeschlagen'), required=False) + offboarding_success = forms.BooleanField(label=gettext_lazy('Offboarding erfolgreich'), required=False) + offboarding_failure = forms.BooleanField(label=gettext_lazy('Offboarding fehlgeschlagen'), required=False) + backup_success = forms.BooleanField(label=gettext_lazy('Backup erfolgreich'), required=False) + backup_failure = forms.BooleanField(label=gettext_lazy('Backup fehlgeschlagen'), required=False) + welcome_email_success = forms.BooleanField(label=gettext_lazy('Welcome E-Mail erfolgreich'), required=False) + welcome_email_failure = forms.BooleanField(label=gettext_lazy('Welcome E-Mail fehlgeschlagen'), required=False) + trial_alerts = forms.BooleanField(label=gettext_lazy('Trial-Hinweise'), required=False) + system_alerts = forms.BooleanField(label=gettext_lazy('System-Hinweise'), required=False) + + FIELD_TO_EVENT = { + 'onboarding_success': UserProfile.NOTIFICATION_ONBOARDING_SUCCESS, + 'onboarding_failure': UserProfile.NOTIFICATION_ONBOARDING_FAILURE, + 'offboarding_success': UserProfile.NOTIFICATION_OFFBOARDING_SUCCESS, + 'offboarding_failure': UserProfile.NOTIFICATION_OFFBOARDING_FAILURE, + 'backup_success': UserProfile.NOTIFICATION_BACKUP_SUCCESS, + 'backup_failure': UserProfile.NOTIFICATION_BACKUP_FAILURE, + 'welcome_email_success': UserProfile.NOTIFICATION_WELCOME_EMAIL_SUCCESS, + 'welcome_email_failure': UserProfile.NOTIFICATION_WELCOME_EMAIL_FAILURE, + 'trial_alerts': UserProfile.NOTIFICATION_TRIAL_ALERTS, + 'system_alerts': UserProfile.NOTIFICATION_SYSTEM_ALERTS, + } + + GROUPS = [ + ('workflow', gettext_lazy('Workflow'), ['onboarding_success', 'onboarding_failure', 'offboarding_success', 'offboarding_failure']), + ('welcome', gettext_lazy('Welcome E-Mail'), ['welcome_email_success', 'welcome_email_failure']), + ('operations', gettext_lazy('Operations'), ['backup_success', 'backup_failure', 'system_alerts']), + ('platform', gettext_lazy('Platform'), ['trial_alerts']), + ] + + def __init__(self, *args, profile=None, user=None, **kwargs): + self.profile = profile + self.user = user + initial = kwargs.setdefault('initial', {}) + if profile is not None and not args: + prefs = profile.get_notification_preferences() + for field_name, event_key in self.FIELD_TO_EVENT.items(): + initial.setdefault(field_name, prefs.get(event_key, True)) + super().__init__(*args, **kwargs) + self.visible_field_names = self._compute_visible_field_names() + for field_name in list(self.fields.keys()): + if field_name not in self.visible_field_names: + self.fields.pop(field_name) + + def _compute_visible_field_names(self) -> list[str]: + visible = [ + 'onboarding_success', + 'onboarding_failure', + 'offboarding_success', + 'offboarding_failure', + 'welcome_email_success', + 'welcome_email_failure', + ] + if user_has_capability(self.user, 'manage_backups'): + visible.extend(['backup_success', 'backup_failure']) + if user_has_capability(self.user, 'manage_integrations'): + visible.append('system_alerts') + if user_has_capability(self.user, 'manage_trial_lifecycle'): + visible.append('trial_alerts') + return visible + + def grouped_fields(self): + groups = [] + for key, label, field_names in self.GROUPS: + rows = [self[name] for name in field_names if name in self.fields] + if rows: + groups.append({'key': key, 'label': label, 'fields': rows}) + return groups + + def save(self): + prefs = self.profile.get_notification_preferences() + for field_name in self.visible_field_names: + event_key = self.FIELD_TO_EVENT[field_name] + prefs[event_key] = bool(self.cleaned_data.get(field_name)) + self.profile.notification_preferences = prefs + self.profile.save(update_fields=['notification_preferences', 'updated_at']) + return self.profile + + class UserManagementCreateForm(forms.Form): first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False) last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False) @@ -604,7 +678,7 @@ class OnboardingRequestForm(forms.ModelForm): department = forms.ChoiceField(label='Abteilung', choices=DEPARTMENT_CHOICES, required=True) work_email = forms.EmailField( label='Gewünschte dienstliche E-Mail-Adresse', - help_text='Bitte nutzen Sie das Format name@tub.co.', + help_text='', ) contract_start = forms.DateField(label='Vertragsbeginn', widget=forms.DateInput(attrs={'type': 'date'})) employment_type = forms.ChoiceField(label='Beschäftigungsverhältnis', choices=EMPLOYMENT_CHOICES, required=True) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index e19f025..f378f73 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -39,7 +39,7 @@ class RequestIDMiddleware: class RateLimitMiddleware: - LOGIN_PATHS = ('/accounts/login/',) + LOGIN_PATHS = ('/accounts/login/', '/accounts/login/totp/') PASSWORD_RESET_PATHS = ('/accounts/password_reset/',) # Keep this list path-prefix based so new platform actions get protected # without having to wire every single view into a second permission layer. @@ -157,7 +157,13 @@ class AuthSessionHardeningMiddleware: login_url = reverse('login') return redirect(f'{login_url}?next={request.get_full_path()}') - if request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES): + is_sensitive_post = request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES) + if request.method == 'POST' and path == '/account/': + account_form = (request.POST.get('account_form') or '').strip() + if account_form in {'totp_disable', 'totp_regenerate_codes'}: + is_sensitive_post = True + + if is_sensitive_post: fresh_window = max(60, settings.SENSITIVE_ACTION_REAUTH_SECONDS) auth_fresh_ts = int(request.session.get('auth_fresh_ts') or last_activity_ts) if now_ts - auth_fresh_ts > fresh_window: diff --git a/backend/workflows/migrations/0051_usernotification.py b/backend/workflows/migrations/0051_usernotification.py new file mode 100644 index 0000000..de26bc8 --- /dev/null +++ b/backend/workflows/migrations/0051_usernotification.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0050_userprofile_totp_recovery_codes'), + ] + + operations = [ + migrations.CreateModel( + name='UserNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('body', models.TextField(blank=True, default='')), + ('level', models.CharField(choices=[('info', 'Info'), ('success', 'Erfolg'), ('warning', 'Warnung'), ('error', 'Fehler')], default='info', max_length=20)), + ('link_url', models.CharField(blank=True, default='', max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('read_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'User Notification', + 'verbose_name_plural': 'User Notifications', + 'ordering': ['-created_at', '-id'], + }, + ), + ] diff --git a/backend/workflows/migrations/0052_userprofile_notification_preferences.py b/backend/workflows/migrations/0052_userprofile_notification_preferences.py new file mode 100644 index 0000000..33eda56 --- /dev/null +++ b/backend/workflows/migrations/0052_userprofile_notification_preferences.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0051_usernotification'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='notification_preferences', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 0b5f3eb..963315f 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -28,6 +28,29 @@ class EmployeeProfile(models.Model): class UserProfile(models.Model): + NOTIFICATION_ONBOARDING_SUCCESS = 'onboarding_success' + NOTIFICATION_ONBOARDING_FAILURE = 'onboarding_failure' + NOTIFICATION_OFFBOARDING_SUCCESS = 'offboarding_success' + NOTIFICATION_OFFBOARDING_FAILURE = 'offboarding_failure' + NOTIFICATION_BACKUP_SUCCESS = 'backup_success' + NOTIFICATION_BACKUP_FAILURE = 'backup_failure' + NOTIFICATION_WELCOME_EMAIL_SUCCESS = 'welcome_email_success' + NOTIFICATION_WELCOME_EMAIL_FAILURE = 'welcome_email_failure' + NOTIFICATION_TRIAL_ALERTS = 'trial_alerts' + NOTIFICATION_SYSTEM_ALERTS = 'system_alerts' + NOTIFICATION_PREFERENCE_DEFAULTS = { + NOTIFICATION_ONBOARDING_SUCCESS: True, + NOTIFICATION_ONBOARDING_FAILURE: True, + NOTIFICATION_OFFBOARDING_SUCCESS: True, + NOTIFICATION_OFFBOARDING_FAILURE: True, + NOTIFICATION_BACKUP_SUCCESS: True, + NOTIFICATION_BACKUP_FAILURE: True, + NOTIFICATION_WELCOME_EMAIL_SUCCESS: True, + NOTIFICATION_WELCOME_EMAIL_FAILURE: True, + NOTIFICATION_TRIAL_ALERTS: True, + NOTIFICATION_SYSTEM_ALERTS: True, + } + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile') avatar_image = models.FileField( upload_to='profiles/', @@ -45,6 +68,7 @@ class UserProfile(models.Model): totp_enabled = models.BooleanField(default=False) totp_confirmed_at = models.DateTimeField(null=True, blank=True) totp_recovery_codes = models.JSONField(default=list, blank=True) + notification_preferences = models.JSONField(default=dict, blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: @@ -84,6 +108,55 @@ class UserProfile(models.Model): self.save(update_fields=['totp_recovery_codes', 'updated_at']) return matched + def get_notification_preferences(self) -> dict[str, bool]: + current = self.notification_preferences or {} + prefs = dict(self.NOTIFICATION_PREFERENCE_DEFAULTS) + for key in prefs: + if key in current: + prefs[key] = bool(current[key]) + return prefs + + def notification_enabled(self, event_key: str) -> bool: + return bool(self.get_notification_preferences().get(event_key, True)) + + +class UserNotification(models.Model): + LEVEL_INFO = 'info' + LEVEL_SUCCESS = 'success' + LEVEL_WARNING = 'warning' + LEVEL_ERROR = 'error' + LEVEL_CHOICES = [ + (LEVEL_INFO, _('Info')), + (LEVEL_SUCCESS, _('Erfolg')), + (LEVEL_WARNING, _('Warnung')), + (LEVEL_ERROR, _('Fehler')), + ] + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications') + title = models.CharField(max_length=255) + body = models.TextField(blank=True, default='') + level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default=LEVEL_INFO) + link_url = models.CharField(max_length=500, blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + read_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at', '-id'] + verbose_name = 'User Notification' + verbose_name_plural = 'User Notifications' + + def __str__(self) -> str: + return f'{self.user_id} | {self.level} | {self.title}' + + @property + def is_unread(self) -> bool: + return self.read_at is None + + def mark_read(self) -> None: + if self.read_at is None: + self.read_at = timezone.now() + self.save(update_fields=['read_at']) + class PortalBranding(models.Model): name = models.CharField(max_length=80, default='Default', unique=True) diff --git a/backend/workflows/notifications.py b/backend/workflows/notifications.py new file mode 100644 index 0000000..e7f35b0 --- /dev/null +++ b/backend/workflows/notifications.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model + +from .models import UserNotification, UserProfile + + +def create_user_notification(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '') -> UserNotification: + return UserNotification.objects.create( + user=user, + title=(title or '').strip(), + body=(body or '').strip(), + level=level, + link_url=(link_url or '').strip(), + ) + + +def notify_user(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool: + if not user or not getattr(user, 'is_authenticated', False): + return False + profile, _ = UserProfile.objects.get_or_create(user=user) + if event_key and not profile.notification_enabled(event_key): + return False + create_user_notification(user=user, title=title, body=body, level=level, link_url=link_url) + return True + + +def notify_user_by_email(*, email: str, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool: + normalized_email = (email or '').strip().lower() + if not normalized_email: + return False + user = get_user_model().objects.filter(email__iexact=normalized_email).first() + if not user: + return False + return notify_user(user=user, title=title, body=body, level=level, link_url=link_url, event_key=event_key) diff --git a/backend/workflows/static/workflows/css/account.css b/backend/workflows/static/workflows/css/account.css index bd235a8..648f1b0 100644 --- a/backend/workflows/static/workflows/css/account.css +++ b/backend/workflows/static/workflows/css/account.css @@ -19,7 +19,7 @@ body { } .account-shell-body { - padding: 28px; + padding: 24px; background: radial-gradient(90% 120% at 10% 0%, rgba(31, 79, 214, 0.06), rgba(31, 79, 214, 0)), linear-gradient(180deg, rgba(255,255,255,0.72), rgba(248,251,255,0.48)); @@ -29,7 +29,7 @@ body { width: min(1120px, 100%); margin: 0 auto; display: grid; - gap: 22px; + gap: 18px; } .account-hero { @@ -37,13 +37,14 @@ body { justify-content: space-between; gap: 18px; align-items: flex-start; - padding: 26px 28px; + padding: 22px 24px; border: 1px solid #d9e3f0; border-radius: 24px; background: radial-gradient(circle at top right, rgba(30, 64, 175, 0.1), transparent 24%), linear-gradient(135deg, rgba(255,255,255,0.96), rgba(244,248,255,0.9)); box-shadow: 0 14px 34px rgba(28, 45, 79, 0.08); + animation: accountFadeUp 320ms cubic-bezier(0.2, 0.8, 0.2, 1); } .account-kicker { @@ -72,6 +73,16 @@ body { line-height: 1.55; } +.account-hero-submeta { + margin-top: 14px !important; + color: #6a7a8f; + font-size: 13px; +} + +.account-hero-submeta strong { + color: #132238; +} + .account-hero-badges { display: flex; gap: 10px; @@ -111,12 +122,20 @@ body { border-radius: 22px; background: rgba(255, 255, 255, 0.94); box-shadow: 0 14px 32px rgba(28, 45, 79, 0.09); + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } .account-profile-card { - padding: 24px; + padding: 20px; position: sticky; top: 24px; + animation: accountFadeUp 360ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-profile-card:hover, +.account-panel:hover { + transform: translateY(-1px); + box-shadow: 0 18px 34px rgba(28, 45, 79, 0.10); } .account-avatar-form { @@ -205,30 +224,36 @@ body { word-break: break-word; } -.account-profile-meta { +.account-nav { display: grid; - gap: 12px; + gap: 10px; margin-top: 22px; } -.account-profile-meta div { - padding: 12px 14px; - border-radius: 14px; - background: #f7faff; +.account-nav-item { + width: 100%; + padding: 14px 16px; + text-align: left; + border-radius: 16px; border: 1px solid #dce6f2; + background: #f7faff; + color: #17345e; + font: inherit; + font-weight: 700; + cursor: pointer; + transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1); } -.account-profile-meta span { - display: block; - margin-bottom: 4px; - color: #6b7a90; - font-size: 12px; +.account-nav-item:hover { + border-color: #c5d5ea; + background: #f3f8ff; } -.account-profile-meta strong { - color: #132238; - font-size: 14px; - line-height: 1.4; +.account-nav-item.is-active { + border-color: rgba(0, 0, 120, 0.18); + background: linear-gradient(180deg, rgba(238,243,255,0.95), rgba(231,239,255,0.92)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.7); + transform: translateX(3px); } .account-main { @@ -236,8 +261,129 @@ body { gap: 22px; } +.account-notification-pref-grid { + display: grid; + gap: 12px; +} + +.account-notification-group + .account-notification-group { + margin-top: 18px; +} + +.account-notification-group h3 { + margin: 0 0 10px; + color: #17345e; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.account-notification-pref-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border: 1px solid #dce6f2; + border-radius: 16px; + background: #f7faff; +} + +.account-notification-pref-item span { + color: #17345e; + font-weight: 700; +} + +.account-notification-pref-item strong { + color: #617389; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.account-notification-pref-grid-edit { + gap: 14px; +} + +.account-notification-pref-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 16px; + border: 1px solid #dce6f2; + border-radius: 16px; + background: #f7faff; +} + +.account-notification-pref-copy { + display: grid; + gap: 4px; +} + +.account-notification-pref-copy strong { + color: #17345e; + font-size: 14px; +} + +.account-notification-pref-copy small { + color: #617389; + font-size: 12px; + line-height: 1.45; +} + +.account-toggle-control { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} + +.account-toggle-control input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.account-toggle-slider { + position: relative; + width: 50px; + height: 30px; + border-radius: 999px; + background: #d7e1ee; + transition: background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-toggle-slider::after { + content: ""; + position: absolute; + top: 4px; + left: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 4px 10px rgba(18, 34, 56, 0.12); + transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-toggle-control input:checked + .account-toggle-slider { + background: #0f8f57; +} + +.account-toggle-control input:checked + .account-toggle-slider::after { + transform: translateX(20px); +} + .account-panel { padding: 24px; + animation: accountFadeUp 380ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.account-panel.is-entering { + animation: accountPanelSwap 260ms cubic-bezier(0.2, 0.8, 0.2, 1); } .account-panel-head { @@ -299,7 +445,7 @@ body { } .account-security-item { - padding: 16px 18px; + padding: 14px 16px; border-radius: 18px; border: 1px solid #dbe5f2; background: @@ -307,6 +453,13 @@ body { linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9)); } +.account-security-item-active { + border-color: rgba(34, 139, 86, 0.26); + background: + radial-gradient(circle at top right, rgba(34, 139, 86, 0.10), transparent 26%), + linear-gradient(180deg, rgba(242,255,247,0.96), rgba(236,251,242,0.92)); +} + .account-security-item span { display: block; margin-bottom: 6px; @@ -333,7 +486,7 @@ body { .account-totp-card { margin-bottom: 18px; - padding: 18px; + padding: 16px; border-radius: 18px; border: 1px solid #dbe5f2; background: @@ -356,7 +509,55 @@ body { } .account-totp-form { - margin-top: 14px; + margin-top: 12px; +} + +.account-totp-action-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.account-totp-action-copy strong { + display: block; + color: #132238; + font-size: 15px; +} + +.account-totp-action-copy p { + margin: 6px 0 0; + color: #617389; + font-size: 13px; + line-height: 1.45; +} + +.account-totp-toggle-form { + margin-top: 12px; +} + +.account-totp-status-row { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid #dbe5f2; + background: rgba(255,255,255,0.88); +} + +.account-totp-status-copy strong { + display: block; + color: #132238; + font-size: 16px; +} + +.account-totp-status-copy p { + margin: 6px 0 0; + color: #55718f; + font-size: 13px; + line-height: 1.45; } .account-qr-card, @@ -456,6 +657,19 @@ body { grid-column: 1 / -1; } +.account-form-field.is-hidden { + display: none; +} + +.account-totp-form.is-hidden { + display: none; +} + +.account-form-grid.is-hidden, +.account-inline-actions.is-hidden { + display: none; +} + .account-form-field label { color: #132238; font-size: 13px; @@ -506,6 +720,32 @@ body { margin-top: 16px; } +.account-recovery-toggle { + width: auto; +} + +@keyframes accountFadeUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes accountPanelSwap { + from { + opacity: 0; + transform: translateY(10px) scale(0.995); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + @media (max-width: 980px) { .account-layout { grid-template-columns: 1fr; @@ -540,10 +780,6 @@ body { font-size: 26px; } - .account-hero-badges { - justify-content: flex-start; - } - .account-detail-grid, .account-security-overview, .account-form-grid, @@ -558,4 +794,39 @@ body { .account-panel-head { flex-direction: column; } + + .account-totp-status-card { + flex-direction: column; + } + + .account-totp-status-row { + flex-direction: column; + align-items: stretch; + } + + .account-totp-action-row { + flex-direction: column; + align-items: stretch; + } +} + +@media (prefers-reduced-motion: reduce) { + .account-hero, + .account-profile-card, + .account-panel, + .account-panel.is-entering { + animation: none; + } + + .account-profile-card, + .account-panel, + .account-nav-item { + transition: none; + } + + .account-profile-card:hover, + .account-panel:hover, + .account-nav-item.is-active { + transform: none; + } } diff --git a/backend/workflows/static/workflows/css/app_chrome.css b/backend/workflows/static/workflows/css/app_chrome.css index ad297ff..f1d184f 100644 --- a/backend/workflows/static/workflows/css/app_chrome.css +++ b/backend/workflows/static/workflows/css/app_chrome.css @@ -180,10 +180,227 @@ align-items: center; } +.app-notification-menu, .app-user-menu { position: relative; } +.app-notification-trigger { + list-style: none; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--app-line); + border-radius: 999px; + background: rgba(248, 251, 255, 0.92); + color: #1f3a5f; + cursor: pointer; + transition: + border-color var(--motion-fast) var(--motion-ease), + background-color var(--motion-fast) var(--motion-ease), + transform var(--motion-fast) var(--motion-ease), + box-shadow var(--motion-fast) var(--motion-ease); +} + +.app-notification-trigger::-webkit-details-marker { + display: none; +} + +.app-notification-trigger:hover { + transform: translateY(-1px); +} + +.app-notification-menu[open] .app-notification-trigger { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08); +} + +.app-notification-bell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: #28446e; +} + +.app-notification-bell svg { + width: 18px; + height: 18px; + display: block; +} + +.app-notification-count { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #c0002b; + color: #fff; + font-size: 10px; + font-weight: 800; + line-height: 18px; + text-align: center; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.98); +} + +.app-notification-panel { + position: absolute; + top: calc(100% + 10px); + right: 0; + width: min(380px, calc(100vw - 32px)); + max-height: min(70vh, 520px); + padding: 10px; + border: 1px solid rgba(217, 227, 238, 0.96); + border-radius: 18px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16); + display: flex; + flex-direction: column; + gap: 10px; + z-index: 45; + overflow: hidden; +} + +.app-notification-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 4px 6px 8px; + border-bottom: 1px solid rgba(217, 227, 238, 0.85); +} + +.app-notification-panel-head strong { + color: #132238; + font-size: 13px; + line-height: 1.2; +} + +.app-notification-panel-head form { + margin: 0; +} + +.app-notification-panel-head button { + border: 0; + background: transparent; + color: var(--app-brand-blue); + font-size: 12px; + font-weight: 700; + cursor: pointer; + padding: 0; +} + +.app-notification-list { + display: grid; + gap: 8px; + overflow: auto; + padding-right: 2px; +} + +.app-notification-item { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(217, 227, 238, 0.92); + border-radius: 14px; + background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92)); +} + +.app-notification-item.is-unread { + border-color: rgba(0, 0, 120, 0.22); + box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9); +} + +.app-notification-success { + background: linear-gradient(180deg, #f4fcf7, #edf9f1); + border-color: #cce9d5; +} + +.app-notification-error { + background: linear-gradient(180deg, #fff7f7, #fff1f1); + border-color: #f0c8c8; +} + +.app-notification-warning { + background: linear-gradient(180deg, #fffaf0, #fff4dd); + border-color: #f3d9a7; +} + +.app-notification-copy { + display: grid; + gap: 4px; +} + +.app-notification-copy strong { + color: #132238; + font-size: 13px; + line-height: 1.35; +} + +.app-notification-copy p { + margin: 0; + color: #51657f; + font-size: 12px; + line-height: 1.45; +} + +.app-notification-copy span { + color: #7a8ca3; + font-size: 11px; + line-height: 1.3; +} + +.app-notification-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.app-notification-actions a, +.app-notification-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid rgba(217, 227, 238, 0.92); + background: rgba(255, 255, 255, 0.82); + color: #1f3a5f; + font: inherit; + font-size: 12px; + font-weight: 700; + line-height: 1; + text-decoration: none; + cursor: pointer; + transition: + background-color var(--motion-fast) var(--motion-ease), + border-color var(--motion-fast) var(--motion-ease), + color var(--motion-fast) var(--motion-ease); +} + +.app-notification-actions a:hover, +.app-notification-actions button:hover, +.app-notification-panel-head button:hover { + color: var(--app-brand-blue); +} + +.app-notification-empty { + padding: 14px 8px 8px; + color: #64748b; + font-size: 13px; + line-height: 1.5; + text-align: center; +} + .app-user-trigger { list-style: none; display: inline-flex; @@ -330,6 +547,10 @@ .app-user-panel a:focus-visible, .app-user-panel button:focus-visible, +.app-notification-trigger:focus-visible, +.app-notification-actions a:focus-visible, +.app-notification-actions button:focus-visible, +.app-notification-panel-head button:focus-visible, .app-user-trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10); diff --git a/backend/workflows/static/workflows/css/login.css b/backend/workflows/static/workflows/css/login.css index 2b3664a..78a976a 100644 --- a/backend/workflows/static/workflows/css/login.css +++ b/backend/workflows/static/workflows/css/login.css @@ -47,6 +47,26 @@ body { line-height: 1.45; } +.login-step-caption { + display: grid; + gap: 2px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid #d9e3f0; + border-radius: 14px; + background: #f7faff; +} + +.login-step-caption strong { + color: #132238; + font-size: 14px; +} + +.login-step-caption span { + color: #607086; + font-size: 13px; +} + .account-card { width: min(560px, 100%); } @@ -138,6 +158,34 @@ body { width: 100%; } +.btn-inline-toggle { + width: auto; + min-width: 0; +} + +.login-recovery-toggle-row { + display: flex; + justify-content: flex-start; + margin: -2px 0 12px; +} + +.login-recovery-box.is-hidden { + display: none; +} + +.login-back-link { + display: inline-flex; + align-items: center; + margin-top: 14px; + color: #36506e; + font-size: 14px; + text-decoration: none; +} + +.login-back-link:hover { + text-decoration: underline; +} + .login-card .app-alert { margin: 0 0 12px; } diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 950e4eb..1b9aa58 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -28,6 +28,7 @@ from .forms import ( SOFTWARE_EXTRA_CHOICES, WORKSPACE_GROUP_CHOICES, ) +from .notifications import notify_user_by_email # These templates are the product-level defaults for fresh deployments. # Runtime branding and company config can override the company-facing identity @@ -251,6 +252,32 @@ DEFAULT_NOTIFICATION_TEMPLATES = { } +def _notify_request_result(*, recipient_email: str, title: str, body: str, level: str, event_key: str) -> None: + notify_user_by_email( + email=recipient_email, + title=title, + body=body, + level=level, + link_url='/requests/', + event_key=event_key, + ) + + +def _notify_welcome_email_result(*, recipient_email: str, full_name: str, body: str, level: str, event_key: str) -> None: + notify_user_by_email( + email=recipient_email, + title=( + _('Welcome E-Mail gesendet: %(name)s') % {'name': full_name} + if event_key == 'welcome_email_success' + else _('Welcome E-Mail fehlgeschlagen: %(name)s') % {'name': full_name} + ), + body=body, + level=level, + link_url='/admin-tools/welcome-emails/', + event_key=event_key, + ) + + def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog: task_request = getattr(current_task, 'request', None) return AsyncTaskLog.objects.create( @@ -1331,11 +1358,25 @@ def process_onboarding_request(onboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.onboarded_by_email, + title=_('Onboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name}, + body=_('Die Onboarding-Anfrage wurde erfolgreich verarbeitet.'), + level='success', + event_key='onboarding_success', + ) _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.onboarded_by_email, + title=_('Onboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name}, + body=str(exc), + level='error', + event_key='onboarding_failure', + ) _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @@ -1355,7 +1396,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None: try: branding_copy = get_branding_email_copy() company_contact = get_company_contact_copy() - it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails() + it_email, general_info_email, business_card_email_unused, hr_works_email, key_email_unused = _resolve_workflow_emails() pdf_path = _generate_offboarding_pdf(request_obj) request_obj.generated_pdf_path = str(pdf_path) @@ -1418,11 +1459,25 @@ def process_offboarding_request(offboarding_request_id: int) -> None: request_obj.processing_status = 'completed' request_obj.last_error = '' request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.requested_by_email, + title=_('Offboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name}, + body=_('Die Offboarding-Anfrage wurde erfolgreich verarbeitet.'), + level='success', + event_key='offboarding_success', + ) _finish_task_log(task_log, status='succeeded') except Exception as exc: request_obj.processing_status = 'failed' request_obj.last_error = str(exc) request_obj.save(update_fields=['processing_status', 'last_error']) + _notify_request_result( + recipient_email=request_obj.requested_by_email, + title=_('Offboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name}, + body=str(exc), + level='error', + event_key='offboarding_failure', + ) _finish_task_log(task_log, status='failed', error_message=str(exc)) raise @@ -1490,10 +1545,24 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals scheduled.status = 'sent' scheduled.sent_at = timezone.now() scheduled.last_error = '' + _notify_welcome_email_result( + recipient_email=request_obj.onboarded_by_email, + full_name=request_obj.full_name, + body=_('Die geplante Welcome E-Mail wurde erfolgreich versendet.'), + level='success', + event_key='welcome_email_success', + ) _finish_task_log(task_log, status='succeeded') except Exception as exc: scheduled.status = 'failed' scheduled.last_error = str(exc) + _notify_welcome_email_result( + recipient_email=request_obj.onboarded_by_email, + full_name=request_obj.full_name, + body=str(exc), + level='error', + event_key='welcome_email_failure', + ) _finish_task_log(task_log, status='failed', error_message=str(exc)) raise finally: diff --git a/backend/workflows/templates/workflows/account_profile.html b/backend/workflows/templates/workflows/account_profile.html index 349aa89..12919a8 100644 --- a/backend/workflows/templates/workflows/account_profile.html +++ b/backend/workflows/templates/workflows/account_profile.html @@ -18,7 +18,11 @@ @@ -53,24 +57,36 @@

    {{ account_user.get_full_name|default:account_user.username }}

    {{ account_user.email|default:account_user.username }}

    - + -
    -
    -

    {% trans "Optionen verwalten" %}

    -
    - - - -
    +
    +
    + {% endfor %}
    - -
    - {% csrf_token %} - - - - - - -
    - -
    - {% csrf_token %} -
    - - - - - - - - - - - - - {% for item in option_items %} - - - - - - - - - {% empty %} - - {% endfor %} - -
    {% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}Value{% trans "Aktiv" %}{% trans "Löschen" %}
    - - ⋮⋮ - - -
    {% trans "Keine Optionen in dieser Kategorie." %}
    -
    -
    - -
    -
    - - -
    -
    -

    {% trans "Feldtexte verwalten" %}

    -
    - {% csrf_token %} -
    - - - - - - - - - - - - {% for item in field_text_items %} - - - - - - - - {% empty %} - + + +
    + +
    +
    +

    {% trans "Sichtbarkeit & Regeln" %}

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

    {% trans "Abschnitte steuern" %}

    +
    + + {% csrf_token %} +
    + {% for section in section_rule_items %} + {% endfor %} -
    -
    {% trans "Feld" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Hilfetext (DE)" %}{% trans "Hilfetext (EN)" %}
    - - {{ item.field_name }} -
    {% trans "Keine Feldkonfigurationen verfügbar." %}
    +
    +
    + +
    +
    +
    + +
    +
    +

    {% trans "Feldregeln verwalten" %}

    +
    +
    + {% csrf_token %} +
    + {% for group in field_rule_groups %} +
    +
    +

    {{ group.title }}

    + {% blocktrans trimmed with count=group.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
    +
    + {% for item in group.items %} +
    +
    + + {{ item.label }} +
    {{ item.field_name }}
    +
    + + +
    + {% if item.locked %} + {% trans "Fix" %} + {% elif not item.is_visible %} + + {% elif item.is_required %} + {% trans "Pflicht" %} + {% else %} + {% trans "Flexibel" %} + {% endif %} +
    +
    + {% empty %} +
    {% trans "Keine Feldregeln verfügbar." %}
    + {% endfor %} +
    +
    + {% endfor %} +
    +
    + +
    +
    +
    +
    +
    + + +
    + +
    +
    +

    {% trans "Optionen & Texte" %}

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

    {% trans "Optionen verwalten" %}

    + {% trans "Öffnen" %} +
    +
    +
    +
    +
    + + + + + + +
    +
    + +
    + {% csrf_token %} + + + + + + +
    + +
    + {% csrf_token %} +
    + + + + + + + + + + + + + {% for item in option_items %} + + + + + + + + + {% empty %} + + {% endfor %} + +
    {% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}Value{% trans "Aktiv" %}{% trans "Löschen" %}
    + + ⋮⋮ + + +
    {% trans "Keine Optionen in dieser Kategorie." %}
    +
    +
    + +
    +
    +
    +
    + +
    + +
    +

    {% trans "Feldtexte verwalten" %}

    + {% trans "Öffnen" %} +
    +
    +
    +
    + {% csrf_token %} +
    + + + + + + + + + + + + {% for group in field_text_groups %} + + + + {% for item in group.items %} + + + + + + + + {% empty %} + + {% endfor %} + {% endfor %} + +
    {% trans "Feld" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Hilfetext (DE)" %}{% trans "Hilfetext (EN)" %}
    {{ group.title }}
    + + {{ item.field_name }} +
    {% trans "Keine Feldkonfigurationen verfügbar." %}
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + {% endblock %} diff --git a/backend/workflows/templates/workflows/offboarding_form.html b/backend/workflows/templates/workflows/offboarding_form.html index 9846e9c..489ea0d 100644 --- a/backend/workflows/templates/workflows/offboarding_form.html +++ b/backend/workflows/templates/workflows/offboarding_form.html @@ -49,19 +49,29 @@
    {% csrf_token %} -
    - {% for field in form.visible_fields %} - {% if field.name != 'search_query' %} +
    + {% for section in offboarding_sections %} +
    +
    +
    +

    {{ section.title }}

    +

    {{ section.subtitle }}

    +
    +
    +
    + {% for field in section.fields %}
    {{ field.label_tag }} {{ field }} {% if field.help_text %}
    {{ field.help_text }}
    {% endif %} {{ field.errors }}
    - {% endif %} + {% endfor %} +
    +
    {% endfor %}
    - +
    diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index b37a472..ab75efd 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,7 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormFieldConfig, FormOption +from workflows.models import FormFieldConfig, FormOption, FormSectionConfig class FormBuilderAdminTests(TestCase): @@ -94,3 +94,81 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(response.status_code, 302) self.assertTrue(FormOption.objects.filter(category='device', label='Tablet').exists()) + + def test_staff_can_save_field_rules(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + department = FormFieldConfig.objects.get(form_type='onboarding', field_name='department') + contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_field_rules', + 'field_rule_ids': [str(department.id), str(contract_start.id)], + f'is_required_{department.id}': 'required', + f'is_visible_{contract_start.id}': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + department.refresh_from_db() + contract_start.refresh_from_db() + self.assertEqual(department.is_required, True) + self.assertEqual(contract_start.is_required, None) + + def test_staff_can_save_section_rules_with_locked_sections_preserved(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_section_rules', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup') + stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') + self.assertEqual(itsetup.is_visible, False) + self.assertEqual(stammdaten.is_visible, True) + + def test_apply_onboarding_lean_preset_updates_section_and_field_rules(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'apply_preset', + 'preset_key': 'lean', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + itsetup = FormSectionConfig.objects.get(form_type='onboarding', section_key='itsetup') + gender = FormFieldConfig.objects.get(form_type='onboarding', field_name='gender') + contract_start = FormFieldConfig.objects.get(form_type='onboarding', field_name='contract_start') + self.assertEqual(itsetup.is_visible, False) + self.assertEqual(gender.is_visible, False) + self.assertEqual(contract_start.is_required, None) + + def test_apply_offboarding_hr_heavy_preset_updates_fields(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=offboarding&option_category=device', + data={ + 'builder_action': 'apply_preset', + 'preset_key': 'hr_heavy', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + notes = FormFieldConfig.objects.get(form_type='offboarding', field_name='notes') + department = FormFieldConfig.objects.get(form_type='offboarding', field_name='department') + self.assertEqual(notes.is_visible, True) + self.assertEqual(notes.is_required, True) + self.assertEqual(department.is_required, True) diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index 0d84c1a..a91a714 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import FormFieldConfig, OnboardingRequest +from workflows.models import FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): @@ -112,3 +112,34 @@ class OnboardingFlowTests(TestCase): self.assertContains(response, 'Dieses Feld ist zwingend erforderlich.') self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lina.leer@{self.company_domain}').exists()) mock_delay.assert_not_called() + + @patch('workflows.views.process_onboarding_request.delay') + def test_hidden_itsetup_section_is_removed_from_form_and_submission(self, mock_delay): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, '3. IT-Setup') + + payload = { + 'first_name': 'Nora', + 'last_name': 'Neutral', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'nora.section@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'agreement_confirm': 'on', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists()) + mock_delay.assert_called_once() diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 621c1be..0b0233c 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -37,13 +37,22 @@ from .branding import get_branding_email_copy, get_company_email_domain, get_def from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, + FORM_PRESETS, LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + OFFBOARDING_PAGE_LABELS, + OFFBOARDING_PAGE_ORDER, ONBOARDING_DEFAULT_PAGE, ONBOARDING_PAGE_LABELS, ONBOARDING_PAGE_ORDER, ensure_form_field_configs, + ensure_form_section_configs, + get_default_page_map, + get_section_labels, + get_section_order, + apply_form_preset, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email from .notifications import notify_user from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability @@ -531,13 +540,14 @@ def _section_for_block(block: dict, field_pages: dict[str, str]) -> str: return field_pages.get(fields[0].name, 'abschluss') -def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) -> list[dict]: +def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], visible_section_keys: set[str] | None = None) -> list[dict]: grouped = {key: [] for key in ONBOARDING_SECTION_ORDER} for block in blocks: section_key = _section_for_block(block, field_pages) if section_key not in grouped: section_key = 'abschluss' grouped[section_key].append(block) + visible_keys = visible_section_keys or set(ONBOARDING_SECTION_ORDER) return [ { 'key': key, @@ -546,6 +556,35 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str]) 'blocks': grouped[key], } for key in ONBOARDING_SECTION_ORDER + if key in visible_keys + ] + + +OFFBOARDING_SECTION_META = { + 'mitarbeitende': {'title': gettext_lazy('Mitarbeitende'), 'subtitle': gettext_lazy('Person, Rolle und Bereich')}, + 'austritt': {'title': gettext_lazy('Austritt'), 'subtitle': gettext_lazy('Letzter Arbeitstag')}, + 'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Hinweise und Abschlussnotizen')}, +} + + +def _build_offboarding_sections(form, visible_section_keys: set[str] | None = None) -> list[dict]: + field_pages = getattr(form, '_field_page_keys', {}) + grouped = {key: [] for key in OFFBOARDING_PAGE_ORDER} + for field_name in form.fields.keys(): + section_key = field_pages.get(field_name, 'abschluss') + if section_key not in grouped: + section_key = 'abschluss' + grouped[section_key].append(form[field_name]) + visible_keys = visible_section_keys or set(OFFBOARDING_PAGE_ORDER) + return [ + { + 'key': key, + 'title': OFFBOARDING_SECTION_META[key]['title'], + 'subtitle': OFFBOARDING_SECTION_META[key]['subtitle'], + 'fields': grouped[key], + } + for key in OFFBOARDING_PAGE_ORDER + if key in visible_keys and grouped[key] ] @@ -1780,7 +1819,12 @@ def onboarding_create(request): onboarding_blocks = _build_onboarding_layout(form) field_pages = getattr(form, '_field_page_keys', {}) - onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages) + section_configs = ensure_form_section_configs('onboarding') + visible_section_keys = { + key for key in ONBOARDING_SECTION_ORDER + if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible + } + onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) return render( request, @@ -1969,6 +2013,14 @@ def offboarding_create(request): else: form = OffboardingRequestForm(prefill_profile=selected_profile, initial={'search_query': search_query}) + field_pages = getattr(form, '_field_page_keys', {}) + section_configs = ensure_form_section_configs('offboarding') + visible_section_keys = { + key for key in OFFBOARDING_PAGE_ORDER + if key in LOCKED_SECTION_RULES.get('offboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible + } + offboarding_sections = _build_offboarding_sections(form, visible_section_keys=visible_section_keys) + return render( request, 'workflows/offboarding_form.html', @@ -1980,6 +2032,7 @@ def offboarding_create(request): 'saved': request.GET.get('saved') == '1', 'saved_request_id': request.GET.get('id', ''), 'portal_email_domain': get_company_email_domain(), + 'offboarding_sections': offboarding_sections, }, ) @@ -1997,6 +2050,9 @@ def offboarding_success(request, request_id: int): def form_builder_page(request): language_code = get_language() form_type = request.GET.get('form_type', 'onboarding') + anchor = (request.GET.get('anchor') or '').strip() + active_panel = (request.GET.get('panel') or '').strip() + active_subpanel = (request.GET.get('subpanel') or '').strip() if form_type not in DEFAULT_FIELD_ORDER: form_type = 'onboarding' option_category = request.GET.get('option_category', 'department') @@ -2017,7 +2073,7 @@ def form_builder_page(request): option.delete() _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Option wurde gelöscht.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=options#builder-content") action = request.POST.get('builder_action', '') if action == 'add_option': @@ -2068,7 +2124,7 @@ def form_builder_page(request): option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order']) except IntegrityError: messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&panel=builder-content&subpanel=options#builder-content") option_category = option.category _audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)}) messages.success(request, 'Optionen wurden gespeichert.') @@ -2087,7 +2143,67 @@ def form_builder_page(request): _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) messages.success(request, 'Feldtexte wurden gespeichert.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}") + elif action == 'save_field_rules': + field_ids = request.POST.getlist('field_rule_ids') + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + updated = 0 + for raw_id in field_ids: + cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + if cfg.field_name in locked_fields: + cfg.is_visible = True + cfg.is_required = None + else: + cfg.is_visible = request.POST.get(f'is_visible_{cfg.id}') == 'on' + required_mode = (request.POST.get(f'is_required_{cfg.id}') or '').strip() + cfg.is_required = True if required_mode == 'required' else False if required_mode == 'optional' else None + cfg.save(update_fields=['is_visible', 'is_required']) + updated += 1 + _audit(request, 'form_field_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, 'Feldregeln wurden gespeichert.') + + elif action == 'save_section_rules' and form_type in {'onboarding', 'offboarding'}: + section_configs = ensure_form_section_configs(form_type) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + updated = 0 + for section_key, cfg in section_configs.items(): + if section_key in locked_sections: + if not cfg.is_visible: + cfg.is_visible = True + cfg.save(update_fields=['is_visible']) + continue + cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on' + cfg.save(update_fields=['is_visible']) + updated += 1 + _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, 'Abschnittsregeln wurden gespeichert.') + + elif action == 'apply_preset': + preset_key = (request.POST.get('preset_key') or '').strip() + if apply_form_preset(form_type, preset_key): + active_panel = 'builder-preview' + _audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key}) + messages.success(request, 'Preset wurde angewendet.') + else: + messages.error(request, 'Preset konnte nicht angewendet werden.') + + if action in {'add_option', 'save_options', 'save_field_texts'}: + active_panel = 'builder-content' + if action in {'add_option', 'save_options'}: + active_subpanel = 'options' + elif action == 'save_field_texts': + active_subpanel = 'field-texts' + elif action in {'save_field_rules', 'save_section_rules'}: + active_panel = 'builder-rules' + redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" + if active_panel: + redirect_target += f"&panel={active_panel}" + if active_subpanel: + redirect_target += f"&subpanel={active_subpanel}" + if anchor == 'builder-content' or active_panel == 'builder-content': + redirect_target += "#builder-content" + return redirect(redirect_target) default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) existing_names = list( @@ -2100,12 +2216,17 @@ def form_builder_page(request): default_names.append(name) ensure_form_field_configs(form_type, default_names) + section_configs = ensure_form_section_configs(form_type) + section_order = get_section_order(form_type) + section_labels = get_section_labels(form_type) + default_page_map = get_default_page_map(form_type) configs = list( FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name') ) labels = _form_field_labels(form_type) locked = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) if form_type == 'onboarding': columns = [ @@ -2131,27 +2252,127 @@ def form_builder_page(request): 'is_visible': cfg.is_visible, 'is_required': cfg.is_required, 'locked': cfg.field_name in locked, + 'page_key': page_key, } ) else: columns = [ { - 'key': 'all', - 'title': 'Offboarding Felder', - 'items': [ - { - 'field_name': cfg.field_name, - 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), - 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), - 'label_en': cfg.label_override_en, - 'is_visible': cfg.is_visible, - 'is_required': cfg.is_required, - 'locked': cfg.field_name in locked, - } - for cfg in configs - ], + 'key': key, + 'title': section_labels.get(key, key), + 'items': [], } + for key in section_order ] + column_by_key = {c['key']: c for c in columns} + fallback = section_order[-1] if section_order else 'all' + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, fallback) + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': cfg.field_name, + 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), + 'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name), + 'label_en': cfg.label_override_en, + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + 'page_key': page_key, + } + ) + + section_rule_items = [] + if section_order: + fallback_section = section_order[-1] if section_order else '' + for key in section_order: + cfg = section_configs.get(key) + section_rule_items.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'is_visible': True if not cfg else cfg.is_visible, + 'locked': key in locked_sections, + 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]), + } + ) + + field_rule_items = [] + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '') + field_rule_items.append( + { + 'id': cfg.id, + 'field_name': cfg.field_name, + 'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name), + 'page_key': page_key, + 'page_label': section_labels.get(page_key, page_key) if section_order else '', + 'is_visible': cfg.is_visible, + 'is_required': cfg.is_required, + 'locked': cfg.field_name in locked, + } + ) + + field_rule_groups = [] + if section_order: + grouped_rules = {key: [] for key in section_order} + for item in field_rule_items: + grouped_rules.setdefault(item['page_key'], []).append(item) + for key in section_order: + field_rule_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_rules.get(key, []), + } + ) + + field_text_groups = [] + if section_order: + grouped_texts = {key: [] for key in section_order} + for cfg in configs: + page_key = cfg.page_key or default_page_map.get(cfg.field_name, section_order[-1] if section_order else '') + grouped_texts.setdefault(page_key, []).append(cfg) + for key in section_order: + field_text_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_texts.get(key, []), + } + ) + + preview_sections = [] + if section_order: + field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups} + for key in section_order: + section_cfg = section_configs.get(key) + section_locked = key in locked_sections + section_visible = True if section_locked or not section_cfg else section_cfg.is_visible + visible_items = [ + item for item in field_rule_group_map.get(key, []) + if item['locked'] or item['is_visible'] + ] + if section_visible: + preview_sections.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': visible_items, + } + ) + + locked_field_count = len([item for item in field_rule_items if item['locked']]) + hidden_field_count = len([item for item in field_rule_items if not item['is_visible']]) + configurable_field_count = len(field_rule_items) - locked_field_count + hidden_section_count = len([item for item in section_rule_items if not item['is_visible']]) if section_rule_items else 0 + builder_summary = { + 'locked_field_count': locked_field_count, + 'configurable_field_count': configurable_field_count, + 'hidden_field_count': hidden_field_count, + 'hidden_section_count': hidden_section_count, + } return render( request, @@ -2164,6 +2385,15 @@ def form_builder_page(request): 'selected_option_category': option_category, 'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'), 'field_text_items': configs, + 'field_rule_items': field_rule_items, + 'field_rule_groups': field_rule_groups, + 'field_text_groups': field_text_groups, + 'preview_sections': preview_sections, + 'section_rule_items': section_rule_items, + 'builder_summary': builder_summary, + 'active_panel': active_panel, + 'active_subpanel': active_subpanel, + 'available_presets': FORM_PRESETS.get(form_type, {}), }, ) From b9441f2503ffd38984447fb11be5b76156474ef1 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 12:41:32 +0100 Subject: [PATCH 18/45] snapshot: preserve dynamic pdf parity and quality pass --- .../media/templates/offboarding_template.html | 68 ++-- .../media/templates/onboarding_template.html | 279 ++--------------- backend/workflows/models.py | 5 + backend/workflows/pdf_sections.py | 290 ++++++++++++++++++ backend/workflows/tasks.py | 4 + .../workflows/tests/test_pdf_generation.py | 75 +++++ backend/workflows/tests/test_pdf_sections.py | 96 ++++++ 7 files changed, 525 insertions(+), 292 deletions(-) create mode 100644 backend/workflows/pdf_sections.py create mode 100644 backend/workflows/tests/test_pdf_generation.py create mode 100644 backend/workflows/tests/test_pdf_sections.py diff --git a/backend/media/templates/offboarding_template.html b/backend/media/templates/offboarding_template.html index 6b3ee37..39a1d1e 100644 --- a/backend/media/templates/offboarding_template.html +++ b/backend/media/templates/offboarding_template.html @@ -1,5 +1,5 @@ - + @@ -29,12 +29,6 @@ letter-spacing: 0.2px; } - .sub { - margin: 2px 0 0 0; - color: #6b7280; - font-size: 9.5px; - } - .section { margin-top: 9px; font-size: 11px; @@ -54,6 +48,8 @@ border: 1px solid #f0e1e1; padding: 4px 6px; vertical-align: top; + overflow-wrap: anywhere; + word-break: break-word; } th { @@ -141,17 +137,6 @@ font-size: 9.4px; } - .manual-title { - margin: 9px 0 5px; - font-size: 10px; - font-weight: bold; - color: #111827; - } - - .manual-grid td { - width: 50%; - } - @@ -159,25 +144,26 @@

    {{ T.offboarding_title }}

    -
    {{ T.employee_info }}
    - - - - - - - - - - - - - - - - - -
    {{ T.name }}{{ FULL_NAME }}{{ T.email }}{{ EMAIL }}
    {{ T.department }}{{ DEPARTMENT }}{{ T.job_title }}{{ JOB_TITLE }}
    {{ T.last_working_day }}{{ LAST_WORKING_DAY }}
    + {% for section in PDF_SECTIONS %} + {% if section.has_content %} +
    {{ section.title }}
    + + {% if section.scalar_rows %} + + {% for row in section.scalar_rows %} + + + {{ row[0].display_value }} + {% if row[1] %} + + + {% endif %} + + {% endfor %} +
    {{ row[0].label }}{{ row[1].label }}{{ row[1].display_value }}
    + {% endif %} + {% endif %} + {% endfor %}
    {{ T.offboarding_requester }}
    @@ -295,14 +281,6 @@
    {{ T.return_complete }}  {{ T.yes }}      {{ T.no }}
    -
    {{ T.notes }}
    -
    - - - - -
    {{ T.notes }}{{ NOTES }}
    -

    {{ T.offboarding_note }}

    diff --git a/backend/media/templates/onboarding_template.html b/backend/media/templates/onboarding_template.html index 5807195..8b9055b 100644 --- a/backend/media/templates/onboarding_template.html +++ b/backend/media/templates/onboarding_template.html @@ -29,12 +29,6 @@ letter-spacing: 0.2px; } - .sub { - margin: 2px 0 0 0; - color: #475569; - font-size: 9.5px; - } - .section { margin-top: 9px; font-size: 11px; @@ -101,11 +95,6 @@ word-break: break-word; } - .empty { - color: #94a3b8; - font-style: italic; - } - .signature { width: 150px; height: 70px; @@ -133,245 +122,41 @@

    {{ T.onboarding_title }}

    -
    {{ T.onboarding_staff_data }}
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ T.name }}{{ DISPLAY_NAME }}
    {{ T.department }}{{ ABTEILUNG }}{{ T.job_title }}{{ BERUFSBEZEICHNUNG }}
    {{ T.work_email }}{{ EMAIL }}{{ T.employment_type }}{{ BESCHAEFTIGUNG }}
    {{ T.contract_start }}{{ VERTRAGSBEGINN }}{{ T.contract_end }}{{ VERTRAGSENDE }}
    {{ T.handover_date }}{{ UEBERGABEDATUM }}
    + {% for section in PDF_SECTIONS %} + {% if section.has_content %} +
    {{ section.title }}
    -
    {{ T.equipment_access }}
    + {% if section.scalar_rows %} + + {% for row in section.scalar_rows %} + + + {{ row[0].display_value }} + {% if row[1] %} + + + {% endif %} + + {% endfor %} +
    {{ row[0].label }}{{ row[1].label }}{{ row[1].display_value }}
    + {% endif %} - {% if HAS_DEVICES %} -
    -
    {{ T.devices }}
    - - {% for row in ARBEITSGERÄTE_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - + {% for field in section.list_fields %} +
    +
    {{ field.label }}
    +
    • {{ cell }}
    + {% for row in field.display_value|batch(3, '') %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} +
    {% if cell %}• {{ cell }}{% endif %}
    +
    {% endfor %} - -
    - {% endif %} - - {% if HAS_GROUPS %} -
    -
    {{ T.workspace_groups }}
    - - {% for row in ZUGAENGE_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if HAS_SOFTWARE %} -
    -
    {{ T.software }}
    - - {% for row in SOFTWARE_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if HAS_ACCESSES %} -
    -
    {{ T.accesses }}
    - - {% for row in ACCOUNT_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if HAS_RESOURCES %} -
    -
    {{ T.resources }}
    - - {% for row in STANDARD_RESSOURCEN %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if GROUP_MAILBOXES_REQUIRED and HAS_GROUP_MAILBOXES %} -
    -
    {{ T.group_mailboxes_required }}
    - - {% for row in GROUP_MAILBOXES_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if ADDITIONAL_HARDWARE_NEEDED and HAS_ADDITIONAL_HARDWARE %} -
    -
    {{ T.additional_hardware_needed }}
    - - {% for row in ADDITIONAL_HARDWARE_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if ADDITIONAL_SOFTWARE_NEEDED and HAS_ADDITIONAL_SOFTWARE %} -
    -
    {{ T.additional_software_needed }}
    - - {% for row in ADDITIONAL_SOFTWARE_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if ADDITIONAL_ACCESS_NEEDED and HAS_ADDITIONAL_ACCESS %} -
    -
    {{ T.additional_access_needed }}
    - - {% for row in ADDITIONAL_ACCESS_LIST %} - - {% for cell in row %}{% endfor %} - {% if row|length < 3 %} - {% for _ in range(3 - row|length) %}{% endfor %} - {% endif %} - - {% endfor %} -
    • {{ cell }}
    -
    - {% endif %} - - {% if (VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN) or HAS_ADDITIONAL_HARDWARE_OTHER or HAS_SUCCESSOR_INFO or HAS_ADDITIONAL_NOTES %} -
    {{ T.additional_details }}
    - {% endif %} - - {% if VISITENKARTE_BESTELLT and HAS_VISITENKARTE_DATEN %} -
    -
    {{ T.business_cards }}
    - - - - - - - - - - - - - -
    {{ T.name }}{{ VISITENKARTE_NAME }}{{ T.job_title }}{{ VISITENKARTE_TITEL }}
    {{ T.email }}{{ VISITENKARTE_EMAIL }}{{ T.phone }}{{ VISITENKARTE_TELEFON }}
    -
    - {% endif %} - - {% if HAS_ADDITIONAL_HARDWARE_OTHER %} -
    -
    {{ T.additional_hardware_other }}
    - - - - -
    {{ ADDITIONAL_HARDWARE_OTHER }}
    -
    - {% endif %} - - {% if HAS_SUCCESSOR_INFO %} -
    -
    {{ T.successor_phone }}
    - - - - - - - - - - - -
    {{ T.successor_of }}{{ SUCCESSOR_NAME }}{{ T.inherit_phone_number }}{{ INHERIT_PHONE_NUMBER }}
    {{ T.direct_extension }}{{ PHONE_NUMBER }}
    -
    - {% endif %} - - {% if HAS_ADDITIONAL_NOTES %} -
    -
    {{ T.notes }}
    - - - - -
    {{ ADDITIONAL_NOTES }}
    -
    - {% endif %} + {% endif %} + {% endfor %}
    {{ T.confirmation }}
    diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 96c18a9..f524efa 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -477,6 +477,8 @@ class FormFieldConfig(models.Model): ('vertrag', _('Vertrag')), ('itsetup', _('IT-Setup')), ('abschluss', _('Abschluss')), + ('mitarbeitende', _('Mitarbeitende')), + ('austritt', _('Austritt')), ] FORM_CHOICES = [ ('onboarding', _('Onboarding')), @@ -519,12 +521,15 @@ class FormFieldConfig(models.Model): class FormSectionConfig(models.Model): FORM_CHOICES = [ ('onboarding', _('Onboarding')), + ('offboarding', _('Offboarding')), ] SECTION_CHOICES = [ ('stammdaten', _('Stammdaten')), ('vertrag', _('Vertrag')), ('itsetup', _('IT-Setup')), ('abschluss', _('Abschluss')), + ('mitarbeitende', _('Mitarbeitende')), + ('austritt', _('Austritt')), ] form_type = models.CharField(max_length=20, choices=FORM_CHOICES) diff --git a/backend/workflows/pdf_sections.py b/backend/workflows/pdf_sections.py new file mode 100644 index 0000000..948fc84 --- /dev/null +++ b/backend/workflows/pdf_sections.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from collections import OrderedDict +from datetime import date, datetime + +from django.utils import formats +from django.utils.translation import override + +from .form_builder import ( + LOCKED_FIELD_RULES, + LOCKED_SECTION_RULES, + ensure_form_field_configs, + ensure_form_section_configs, + get_default_page_map, + get_section_labels, + get_section_order, +) +from .forms import OffboardingRequestForm, OnboardingRequestForm + +PDF_SECTION_TITLES = { + "onboarding": { + "stammdaten": "Stammdaten", + "vertrag": "Vertrag", + "itsetup": "IT-Setup", + "abschluss": "Abschluss", + }, + "offboarding": { + "mitarbeitende": "Mitarbeitende", + "austritt": "Austritt", + "abschluss": "Abschluss", + }, +} + +PDF_FIELD_LABELS = { + "onboarding": { + "full_name": {"de": "Name", "en": "Name"}, + }, + "offboarding": { + "full_name": {"de": "Name", "en": "Name"}, + }, +} + +PDF_EXCLUDED_FIELDS = { + "onboarding": { + "first_name", + "last_name", + "onboarded_by_email", + "agreement_confirm", + "signature_url", + "signature_image", + }, + "offboarding": set(), +} + +PDF_BOOLEAN_CONTROL_FIELDS = { + "onboarding": { + "order_business_cards", + "group_mailboxes_required_choice", + "additional_hardware_needed_choice", + "additional_software_needed_choice", + "additional_access_needed_choice", + "successor_required_choice", + "inherit_phone_number_choice", + }, + "offboarding": set(), +} + + +def _normalized_lang(language_code: str | None) -> str: + return (language_code or "de").split("-")[0].lower() or "de" + + +def _yes_no_text(language_code: str | None) -> tuple[str, str]: + lang = _normalized_lang(language_code) + if lang == "en": + return "Yes", "No" + return "Ja", "Nein" + + +def _not_available_text(language_code: str | None) -> str: + return "Not provided" if _normalized_lang(language_code) == "en" else "Keine Angabe" + + +def _split_name(full_name: str) -> tuple[str, str]: + parts = (full_name or "").split() + if not parts: + return "", "" + return parts[0], " ".join(parts[1:]) + + +def _split_multiline(value: str) -> list[str]: + return [line.strip() for line in (value or "").splitlines() if line.strip()] + + +def _format_date(value, language_code: str | None) -> str: + if not value: + return "" + if isinstance(value, datetime): + with override(_normalized_lang(language_code)): + return formats.date_format(value, "DATETIME_FORMAT", use_l10n=True) + if isinstance(value, date): + with override(_normalized_lang(language_code)): + return formats.date_format(value, "DATE_FORMAT", use_l10n=True) + return str(value) + + +def _coerce_text(value) -> str: + if value is None: + return "" + if isinstance(value, (date, datetime)): + return str(value) + return str(value).strip() + + +def _field_value_from_request(form_type: str, request_obj, field_name: str, language_code: str | None): + yes_text, no_text = _yes_no_text(language_code) + first_name, last_name = _split_name(getattr(request_obj, "full_name", "")) + + onboarding_map = { + "first_name": first_name, + "last_name": last_name, + "full_name": getattr(request_obj, "full_name", ""), + "gender": getattr(request_obj, "get_gender_display", lambda: "")() or getattr(request_obj, "gender", ""), + "job_title": getattr(request_obj, "job_title", ""), + "department": getattr(request_obj, "department", ""), + "work_email": getattr(request_obj, "work_email", ""), + "order_business_cards": yes_text if getattr(request_obj, "order_business_cards", False) else no_text, + "business_card_name": getattr(request_obj, "business_card_name", ""), + "business_card_title": getattr(request_obj, "business_card_title", ""), + "business_card_email": getattr(request_obj, "business_card_email", ""), + "business_card_phone": getattr(request_obj, "business_card_phone", ""), + "contract_start": _format_date(getattr(request_obj, "contract_start", None), language_code), + "employment_type": getattr(request_obj, "get_employment_type_display", lambda: "")() or getattr(request_obj, "employment_type", ""), + "employment_end_date": _format_date(getattr(request_obj, "employment_end_date", None), language_code), + "handover_date": _format_date(getattr(request_obj, "handover_date", None), language_code), + "group_mailboxes_required_choice": yes_text if getattr(request_obj, "group_mailboxes_required", False) else no_text, + "group_mailboxes": _split_multiline(getattr(request_obj, "group_mailboxes", "")), + "needed_devices_multi": _split_multiline(getattr(request_obj, "needed_devices", "")), + "additional_hardware_needed_choice": yes_text if getattr(request_obj, "additional_hardware_needed", False) else no_text, + "additional_hardware_multi": _split_multiline(getattr(request_obj, "additional_hardware", "")), + "additional_hardware_other": getattr(request_obj, "additional_hardware_other", ""), + "needed_software_multi": _split_multiline(getattr(request_obj, "needed_software", "")), + "additional_software_needed_choice": yes_text if getattr(request_obj, "additional_software_needed", False) else no_text, + "additional_software_multi": _split_multiline(getattr(request_obj, "additional_software", "")), + "additional_software": getattr(request_obj, "additional_software", ""), + "needed_accesses_multi": _split_multiline(getattr(request_obj, "needed_accesses", "")), + "additional_access_needed_choice": yes_text if getattr(request_obj, "additional_access_needed", False) else no_text, + "additional_access_text": _split_multiline(getattr(request_obj, "additional_access_text", "")), + "needed_workspace_groups_multi": _split_multiline(getattr(request_obj, "needed_workspace_groups", "")), + "needed_resources_multi": _split_multiline(getattr(request_obj, "needed_resources", "")), + "successor_required_choice": yes_text if getattr(request_obj, "successor_required", False) else no_text, + "successor_name": getattr(request_obj, "successor_name", ""), + "inherit_phone_number_choice": yes_text if getattr(request_obj, "inherit_phone_number", False) else no_text, + "phone_number_choice": getattr(request_obj, "phone_number", ""), + "additional_notes": getattr(request_obj, "additional_notes", ""), + "signature_url": getattr(request_obj, "signature_url", ""), + "signature_image": getattr(getattr(request_obj, "signature_image", None), "name", "") or "", + "onboarded_by_email": getattr(request_obj, "onboarded_by_email", ""), + "agreement_confirm": yes_text if _coerce_text(getattr(request_obj, "agreement", "")) else no_text, + } + offboarding_map = { + "full_name": getattr(request_obj, "full_name", ""), + "work_email": getattr(request_obj, "work_email", ""), + "department": getattr(request_obj, "department", ""), + "job_title": getattr(request_obj, "job_title", ""), + "last_working_day": _format_date(getattr(request_obj, "last_working_day", None), language_code), + "notes": getattr(request_obj, "notes", ""), + } + value_map = onboarding_map if form_type == "onboarding" else offboarding_map + return value_map.get(field_name, "") + + +def _field_kind(value) -> str: + if isinstance(value, list): + return "list" + return "text" + + +def _is_empty_value(value) -> bool: + if isinstance(value, list): + return len(value) == 0 + return _coerce_text(value) == "" + + +def _field_meta(form_type: str, field_name: str, language_code: str | None) -> tuple[str, str]: + custom_label = PDF_FIELD_LABELS.get(form_type, {}).get(field_name, {}) + if custom_label: + return custom_label.get(_normalized_lang(language_code), field_name), "" + form_class = OnboardingRequestForm if form_type == "onboarding" else OffboardingRequestForm + base_field = form_class.base_fields.get(field_name) + if not base_field: + return field_name, "" + with override(_normalized_lang(language_code)): + label = str(base_field.label or field_name) + help_text = str(base_field.help_text or "").strip() + return label, help_text + + +def build_pdf_sections(form_type: str, request_obj, language_code: str | None = None) -> list[dict]: + language_code = _normalized_lang(language_code or getattr(request_obj, "preferred_language", None)) + default_page_map = get_default_page_map(form_type) + section_order = get_section_order(form_type) + section_labels = get_section_labels(form_type) + field_names = list(default_page_map.keys()) + configs = ensure_form_field_configs(form_type, field_names) + section_configs = ensure_form_section_configs(form_type) + locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) + locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + + sections: OrderedDict[str, dict] = OrderedDict() + for key in section_order: + section_cfg = section_configs.get(key) + is_visible = True + if key not in locked_sections and section_cfg is not None: + is_visible = bool(section_cfg.is_visible) + if not is_visible: + continue + sections[key] = { + "key": key, + "title": PDF_SECTION_TITLES.get(form_type, {}).get(key, section_labels.get(key, key)), + "fields": [], + } + + ordered_configs = sorted( + configs.values(), + key=lambda cfg: (cfg.sort_order, cfg.field_name), + ) + for cfg in ordered_configs: + field_name = cfg.field_name + section_key = cfg.page_key or default_page_map.get(field_name, "") + if section_key not in sections: + continue + if field_name in PDF_EXCLUDED_FIELDS.get(form_type, set()): + continue + if field_name not in locked_fields and not cfg.is_visible: + continue + + base_label, base_help_text = _field_meta(form_type, field_name, language_code) + label = cfg.translated_label_override(language_code) or base_label + help_text = cfg.translated_help_text_override(language_code) or base_help_text + raw_value = _field_value_from_request(form_type, request_obj, field_name, language_code) + sections[section_key]["fields"].append( + { + "name": field_name, + "label": label, + "help_text": help_text, + "kind": _field_kind(raw_value), + "value": raw_value, + "is_empty": _is_empty_value(raw_value), + "is_locked": field_name in locked_fields, + } + ) + + not_available = _not_available_text(language_code) + result = [] + for section in sections.values(): + visible_fields = [] + for field in section["fields"]: + if ( + field["name"] in PDF_BOOLEAN_CONTROL_FIELDS.get(form_type, set()) + and _coerce_text(field["value"]).lower() in {"nein", "no"} + ): + continue + display_value = field["value"] if field["kind"] == "list" else (_coerce_text(field["value"]) or not_available) + visible_fields.append( + { + **field, + "display_value": display_value, + } + ) + render_fields = [field for field in visible_fields if not field["is_empty"]] + scalar_fields = [field for field in render_fields if field["kind"] != "list"] + list_fields = [field for field in render_fields if field["kind"] == "list"] + scalar_rows = [scalar_fields[index:index + 2] for index in range(0, len(scalar_fields), 2)] + for row in scalar_rows: + if len(row) < 2: + row.append(None) + result.append( + { + "key": section["key"], + "title": section["title"], + "fields": visible_fields, + "render_fields": render_fields, + "scalar_fields": scalar_fields, + "list_fields": list_fields, + "scalar_rows": scalar_rows, + "has_content": bool(render_fields), + } + ) + return result diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 1b9aa58..4ece6b1 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -29,6 +29,7 @@ from .forms import ( WORKSPACE_GROUP_CHOICES, ) from .notifications import notify_user_by_email +from .pdf_sections import build_pdf_sections # These templates are the product-level defaults for fresh deployments. # Runtime branding and company config can override the company-facing identity @@ -965,6 +966,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: context = { 'T': t, 'PDF_LANG': lang, + 'PDF_SECTIONS': build_pdf_sections('onboarding', request_obj, lang), 'VORNAME': first_name, 'NACHNAME': last_name, 'DISPLAY_NAME': display_name or request_obj.full_name, @@ -1209,6 +1211,8 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: context = { 'T': t, + 'PDF_LANG': lang, + 'PDF_SECTIONS': build_pdf_sections('offboarding', request_obj, lang), 'FULL_NAME': request_obj.full_name, 'EMAIL': request_obj.work_email, 'DEPARTMENT': request_obj.department or t['not_available_short'], diff --git a/backend/workflows/tests/test_pdf_generation.py b/backend/workflows/tests/test_pdf_generation.py new file mode 100644 index 0000000..83d0ef3 --- /dev/null +++ b/backend/workflows/tests/test_pdf_generation.py @@ -0,0 +1,75 @@ +from datetime import date +from pathlib import Path + +from django.test import TestCase, override_settings +from pypdf import PdfReader + +from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.tasks import _generate_offboarding_pdf, _generate_onboarding_pdf + + +@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs')) +class PDFGenerationTests(TestCase): + def _extract_pdf_text(self, pdf_path: Path) -> str: + reader = PdfReader(str(pdf_path)) + return "\n".join((page.extract_text() or "") for page in reader.pages) + + def test_onboarding_pdf_respects_hidden_section_and_field(self): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='job_title', + defaults={'is_visible': False}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start=date(2026, 11, 1), + employment_type='unbefristet', + needed_devices='Laptop\nMonitor', + onboarded_by_email='requester@workdock.de', + onboarded_by_name='Mia Beispiel', + agreement='accepted', + ) + + pdf_path = _generate_onboarding_pdf(request_obj) + text = self._extract_pdf_text(pdf_path) + + self.assertIn('Max Mustermann', text) + self.assertIn('IT-Service', text) + self.assertIn('Stammdaten', text) + self.assertNotIn('Consultant', text) + self.assertNotIn('Laptop', text) + self.assertNotIn('IT-Setup', text) + self.assertNotIn('1. Stammdaten', text) + self.assertNotIn('Vorname', text) + self.assertNotIn('Nachname', text) + self.assertNotIn('onboarded_by_email', text) + + def test_offboarding_pdf_uses_dynamic_sections(self): + request_obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day=date(2026, 12, 31), + notes='Bitte Accounts sperren.', + requested_by_email='admin@workdock.de', + requested_by_name='Nina Admin', + ) + + pdf_path = _generate_offboarding_pdf(request_obj) + text = self._extract_pdf_text(pdf_path) + + self.assertIn('Lara Beispiel', text) + self.assertIn('Engineer', text) + self.assertIn('31. Dezember 2026', text) + self.assertIn('Bitte Accounts sperren.', text) + self.assertNotIn('1. Mitarbeitende', text) diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py new file mode 100644 index 0000000..9d4321e --- /dev/null +++ b/backend/workflows/tests/test_pdf_sections.py @@ -0,0 +1,96 @@ +from django.test import TestCase + +from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.pdf_sections import build_pdf_sections + + +class PDFSectionBuilderTests(TestCase): + def test_onboarding_builder_respects_hidden_section_and_hidden_field(self): + FormSectionConfig.objects.update_or_create( + form_type='onboarding', + section_key='itsetup', + defaults={'is_visible': False}, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='job_title', + defaults={'is_visible': False}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + + self.assertEqual([section['key'] for section in sections], ['stammdaten', 'vertrag', 'abschluss']) + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + self.assertNotIn('job_title', [field['name'] for field in stammdaten['fields']]) + + def test_onboarding_builder_uses_field_order_and_overrides(self): + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='department', + defaults={ + 'sort_order': 1, + 'label_override': 'Team', + 'help_text_override': 'Interne Organisationseinheit', + }, + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='gender', + defaults={'sort_order': 5}, + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + visible_names = [field['name'] for field in stammdaten['fields']] + department_field = next(field for field in stammdaten['fields'] if field['name'] == 'department') + + self.assertLess(visible_names.index('department'), visible_names.index('gender')) + self.assertEqual(department_field['label'], 'Team') + self.assertEqual(department_field['help_text'], 'Interne Organisationseinheit') + self.assertEqual(department_field['display_value'], 'IT-Service') + + def test_offboarding_builder_has_section_parity_and_formats_values(self): + FormSectionConfig.objects.update_or_create( + form_type='offboarding', + section_key='abschluss', + defaults={'is_visible': False}, + ) + request_obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day='2026-12-31', + notes='Bitte Accounts sperren.', + requested_by_email='admin@workdock.de', + ) + + sections = build_pdf_sections('offboarding', request_obj, 'de') + + self.assertEqual([section['key'] for section in sections], ['mitarbeitende', 'austritt']) + mitarbeitende = next(section for section in sections if section['key'] == 'mitarbeitende') + austritt = next(section for section in sections if section['key'] == 'austritt') + self.assertIn('full_name', [field['name'] for field in mitarbeitende['fields']]) + self.assertIn('last_working_day', [field['name'] for field in austritt['fields']]) + date_field = next(field for field in austritt['fields'] if field['name'] == 'last_working_day') + self.assertTrue(date_field['display_value']) From eb0fb811e49616c828ecee6f69dcef7b6881bdfc Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 12:44:53 +0100 Subject: [PATCH 19/45] snapshot: preserve upload hardening phase --- backend/workflows/forms.py | 59 ++----- .../workflows/tests/test_upload_validation.py | 143 +++++++++++++++++ backend/workflows/upload_validation.py | 147 ++++++++++++++++++ 3 files changed, 302 insertions(+), 47 deletions(-) create mode 100644 backend/workflows/tests/test_upload_validation.py create mode 100644 backend/workflows/upload_validation.py diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index 4bf0d1f..cd5a126 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -1,5 +1,4 @@ from django import forms -from pathlib import Path from datetime import timedelta from django.contrib.auth import authenticate, get_user_model, password_validation from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm @@ -12,6 +11,13 @@ from .form_builder import apply_form_field_config from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token +from .upload_validation import ( + validate_avatar_upload, + validate_favicon_upload, + validate_logo_upload, + validate_pdf_upload, + validate_signature_upload, +) YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')] @@ -233,10 +239,7 @@ class AccountAvatarForm(forms.ModelForm): def clean_avatar_image(self): avatar = self.cleaned_data.get('avatar_image') - if not avatar: - return avatar - if getattr(avatar, 'size', 0) > 5 * 1024 * 1024: - raise forms.ValidationError(_('Das Profilbild darf maximal 5 MB groß sein.')) + validate_avatar_upload(avatar) return avatar @@ -565,26 +568,17 @@ class PortalBrandingForm(forms.ModelForm): def clean_logo_image(self): logo = self.cleaned_data.get('logo_image') - if not logo: - return logo - if getattr(logo, 'size', 0) > 5 * 1024 * 1024: - raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.')) + validate_logo_upload(logo) return logo def clean_pdf_letterhead(self): letterhead = self.cleaned_data.get('pdf_letterhead') - if not letterhead: - return letterhead - if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024: - raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.')) + validate_pdf_upload(letterhead) return letterhead def clean_favicon_image(self): favicon = self.cleaned_data.get('favicon_image') - if not favicon: - return favicon - if getattr(favicon, 'size', 0) > 2 * 1024 * 1024: - raise forms.ValidationError(_('Das Favicon darf maximal 2 MB groß sein.')) + validate_favicon_upload(favicon) return favicon @@ -832,36 +826,7 @@ class OnboardingRequestForm(forms.ModelForm): def clean_signature_image(self): image = self.cleaned_data.get('signature_image') - if not image: - return image - max_size = 4 * 1024 * 1024 # 4 MB - if image.size > max_size: - raise forms.ValidationError('Die Signatur-Datei ist zu groß (max. 4 MB).') - content_type = (getattr(image, 'content_type', '') or '').lower().strip() - extension = Path(getattr(image, 'name', '')).suffix.lower() - allowed_content_types = { - 'image/png', - 'image/x-png', - 'image/jpeg', - 'image/jpg', - 'image/pjpeg', - } - allowed_extensions = {'.png', '.jpg', '.jpeg'} - if content_type and not content_type.startswith('image/'): - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - if content_type and content_type not in allowed_content_types and extension not in allowed_extensions: - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - if not content_type and extension not in allowed_extensions: - raise forms.ValidationError('Bitte eine PNG- oder JPG-Datei hochladen.') - try: - header = image.read(16) - image.seek(0) - except Exception: - raise forms.ValidationError('Die Signatur-Datei konnte nicht gelesen werden.') - is_png = header.startswith(b'\x89PNG\r\n\x1a\n') - is_jpeg = header.startswith(b'\xff\xd8\xff') - if not (is_png or is_jpeg): - raise forms.ValidationError('Die Signatur-Datei ist kein gültiges PNG/JPG-Bild.') + validate_signature_upload(image) return image def clean(self): diff --git a/backend/workflows/tests/test_upload_validation.py b/backend/workflows/tests/test_upload_validation.py new file mode 100644 index 0000000..7d41117 --- /dev/null +++ b/backend/workflows/tests/test_upload_validation.py @@ -0,0 +1,143 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from io import BytesIO +from PIL import Image + +from workflows.forms import AccountAvatarForm, OnboardingRequestForm, PortalBrandingForm + + +class UploadValidationTests(TestCase): + def test_avatar_rejects_mismatched_extension_and_signature(self): + form = AccountAvatarForm( + data={}, + files={ + 'avatar_image': SimpleUploadedFile( + 'avatar.png', + b'not-a-real-png', + content_type='image/png', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('avatar_image', form.errors) + + def test_logo_accepts_valid_svg(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'logo_image': SimpleUploadedFile( + 'logo.svg', + b'', + content_type='image/svg+xml', + ) + }, + ) + + self.assertTrue(form.is_valid(), form.errors) + + def test_favicon_rejects_wrong_signature(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'favicon_image': SimpleUploadedFile( + 'favicon.ico', + b'not-an-ico', + content_type='image/x-icon', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('favicon_image', form.errors) + + def test_pdf_letterhead_rejects_non_pdf_content(self): + form = PortalBrandingForm( + data={ + 'portal_title': 'Workdock', + 'company_name': 'Workdock', + 'company_domain': 'workdock.de', + 'support_email': 'info@workdock.de', + 'sender_display_name': 'Workdock', + 'login_subtitle': 'Login', + 'footer_text': 'Footer', + 'footer_text_en': 'Footer', + 'legal_notice': '', + 'legal_notice_en': '', + 'default_language': 'de', + 'primary_color': '#000078', + 'secondary_color': '#c0002b', + }, + files={ + 'pdf_letterhead': SimpleUploadedFile( + 'letterhead.pdf', + b'not-a-pdf', + content_type='application/pdf', + ) + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('pdf_letterhead', form.errors) + + def test_signature_accepts_valid_png(self): + buffer = BytesIO() + Image.new('RGBA', (2, 2), (0, 0, 0, 255)).save(buffer, format='PNG') + png_bytes = buffer.getvalue() + form = OnboardingRequestForm( + data={ + 'first_name': 'Max', + 'last_name': 'Mustermann', + 'gender': 'herr', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': 'max.mustermann@workdock.de', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'agreement_confirm': 'on', + }, + files={ + 'signature_image': SimpleUploadedFile( + 'signature.png', + png_bytes, + content_type='image/png', + ) + }, + requester_email='requester@workdock.de', + ) + + self.assertTrue(form.is_valid(), form.errors) diff --git a/backend/workflows/upload_validation.py b/backend/workflows/upload_validation.py new file mode 100644 index 0000000..9e17a86 --- /dev/null +++ b/backend/workflows/upload_validation.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from pathlib import Path + +from django import forms +from django.utils.translation import gettext as _ + + +def _header_matches(extension: str, header: bytes) -> bool: + extension = extension.lower().lstrip(".") + if extension == "png": + return header.startswith(b"\x89PNG\r\n\x1a\n") + if extension in {"jpg", "jpeg"}: + return header.startswith(b"\xff\xd8\xff") + if extension == "webp": + return header.startswith(b"RIFF") and header[8:12] == b"WEBP" + if extension == "pdf": + return header.startswith(b"%PDF") + if extension == "ico": + return header.startswith(b"\x00\x00\x01\x00") + if extension == "svg": + text = header.decode("utf-8", errors="ignore").lower() + return " None: + if not uploaded_file: + return + + if getattr(uploaded_file, "size", 0) > max_size_bytes: + raise forms.ValidationError(size_message) + + extension = Path(getattr(uploaded_file, "name", "")).suffix.lower().lstrip(".") + if extension not in allowed_extensions: + raise forms.ValidationError(invalid_type_message) + + content_type = (getattr(uploaded_file, "content_type", "") or "").lower().strip() + if allowed_content_types and content_type and content_type not in allowed_content_types: + raise forms.ValidationError(invalid_type_message) + + try: + header = uploaded_file.read(512) + uploaded_file.seek(0) + except Exception as exc: + raise forms.ValidationError(unreadable_message) from exc + + if not _header_matches(extension, header): + raise forms.ValidationError(invalid_type_message) + + +def validate_avatar_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg", "webp", "svg"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein PNG-, JPG-, WEBP- oder SVG-Bild hochladen."), + size_message=_("Das Profilbild darf maximal 5 MB groß sein."), + unreadable_message=_("Die Bilddatei konnte nicht gelesen werden."), + ) + + +def validate_logo_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"svg", "png", "jpg", "jpeg", "webp"}, + max_size_bytes=5 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte ein SVG-, PNG-, JPG- oder WEBP-Bild hochladen."), + size_message=_("Das Logo darf maximal 5 MB groß sein."), + unreadable_message=_("Die Logo-Datei konnte nicht gelesen werden."), + ) + + +def validate_favicon_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"ico", "png", "svg", "webp"}, + max_size_bytes=2 * 1024 * 1024, + allowed_content_types={ + "image/x-icon", + "image/vnd.microsoft.icon", + "image/png", + "image/x-png", + "image/webp", + "image/svg+xml", + }, + invalid_type_message=_("Bitte eine ICO-, PNG-, SVG- oder WEBP-Datei hochladen."), + size_message=_("Das Favicon darf maximal 2 MB groß sein."), + unreadable_message=_("Die Favicon-Datei konnte nicht gelesen werden."), + ) + + +def validate_pdf_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"pdf"}, + max_size_bytes=10 * 1024 * 1024, + allowed_content_types={"application/pdf"}, + invalid_type_message=_("Bitte eine gültige PDF-Datei hochladen."), + size_message=_("Der PDF-Briefkopf darf maximal 10 MB groß sein."), + unreadable_message=_("Die PDF-Datei konnte nicht gelesen werden."), + ) + + +def validate_signature_upload(uploaded_file) -> None: + validate_uploaded_file( + uploaded_file, + allowed_extensions={"png", "jpg", "jpeg"}, + max_size_bytes=4 * 1024 * 1024, + allowed_content_types={ + "image/png", + "image/x-png", + "image/jpeg", + "image/jpg", + "image/pjpeg", + }, + invalid_type_message=_("Bitte eine PNG- oder JPG-Datei hochladen."), + size_message=_("Die Signatur-Datei ist zu groß (max. 4 MB)."), + unreadable_message=_("Die Signatur-Datei konnte nicht gelesen werden."), + ) From 2e5e941d418ae0699bee3faed22529968bb6e0c0 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 12:54:47 +0100 Subject: [PATCH 20/45] snapshot: preserve configurable onboarding conditional logic --- backend/workflows/form_builder.py | 67 ++++++++++- .../0054_formconditionalruleconfig.py | 67 +++++++++++ backend/workflows/models.py | 20 ++++ .../static/workflows/js/onboarding_form.js | 75 +++++++----- .../templates/workflows/form_builder.html | 64 ++++++++++ .../templates/workflows/onboarding_form.html | 7 +- .../tests/test_form_builder_admin.py | 32 ++++- .../workflows/tests/test_onboarding_flow.py | 30 ++++- backend/workflows/views.py | 110 ++++++++++++++++-- 9 files changed, 431 insertions(+), 41 deletions(-) create mode 100644 backend/workflows/migrations/0054_formconditionalruleconfig.py diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index a366a38..fa2094a 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -1,7 +1,7 @@ from collections import OrderedDict from django.utils.translation import get_language -from .models import FormFieldConfig, FormSectionConfig +from .models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig DEFAULT_FIELD_ORDER = { @@ -132,6 +132,38 @@ OFFBOARDING_DEFAULT_PAGE = { 'notes': 'abschluss', } +DEFAULT_CONDITIONAL_RULES = { + 'onboarding': { + 'business-card-box': { + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + 'employment-end-box': { + 'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}], + }, + 'group-mailboxes-box': { + 'clauses': [{'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-hardware-box': { + 'clauses': [{'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-software-box': { + 'clauses': [{'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'extra-access-box': { + 'clauses': [{'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'successor-box': { + 'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}], + }, + 'phone-box': { + 'clauses': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, + ], + }, + }, +} + def get_section_order(form_type: str) -> list[str]: if form_type == 'onboarding': @@ -157,6 +189,10 @@ def get_default_page_map(form_type: str) -> dict[str, str]: return {} +def get_default_conditional_rules(form_type: str) -> dict[str, dict]: + return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) + + def _default_sort(form_type: str, field_name: str) -> int: ordered = DEFAULT_FIELD_ORDER.get(form_type, []) if field_name in ordered: @@ -215,6 +251,35 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: return existing +def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: + defaults = get_default_conditional_rules(form_type) + if not defaults: + return {} + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + missing = [key for key in defaults.keys() if key not in existing] + if missing: + FormConditionalRuleConfig.objects.bulk_create( + [ + FormConditionalRuleConfig( + form_type=form_type, + target_key=key, + clauses=defaults[key].get('clauses', []), + is_active=True, + ) + for key in missing + ], + ignore_conflicts=True, + ) + existing = { + cfg.target_key: cfg + for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) + } + return existing + + def apply_form_field_config(form_type: str, form) -> None: field_names = list(form.fields.keys()) configs = _ensure_configs(form_type, field_names) diff --git a/backend/workflows/migrations/0054_formconditionalruleconfig.py b/backend/workflows/migrations/0054_formconditionalruleconfig.py new file mode 100644 index 0000000..f1e705a --- /dev/null +++ b/backend/workflows/migrations/0054_formconditionalruleconfig.py @@ -0,0 +1,67 @@ +from django.db import migrations, models + + +DEFAULT_RULES = { + 'business-card-box': [ + {'field': 'order_business_cards', 'operator': 'checked', 'value': True}, + ], + 'employment-end-box': [ + {'field': 'employment_type', 'operator': 'equals', 'value': 'befristet'}, + ], + 'group-mailboxes-box': [ + {'field': 'group_mailboxes_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-hardware-box': [ + {'field': 'additional_hardware_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-software-box': [ + {'field': 'additional_software_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'extra-access-box': [ + {'field': 'additional_access_needed_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'successor-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + ], + 'phone-box': [ + {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, + {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, + ], +} + + +def seed_conditional_rules(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + for target_key, clauses in DEFAULT_RULES.items(): + FormConditionalRuleConfig.objects.get_or_create( + form_type='onboarding', + target_key=target_key, + defaults={'clauses': clauses, 'is_active': True}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0053_formsectionconfig'), + ] + + operations = [ + migrations.CreateModel( + name='FormConditionalRuleConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('target_key', models.CharField(max_length=80)), + ('clauses', models.JSONField(blank=True, default=list)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Formular-Bedingungsregel', + 'verbose_name_plural': 'Formular-Bedingungsregeln', + 'ordering': ['form_type', 'target_key'], + 'unique_together': {('form_type', 'target_key')}, + }, + ), + migrations.RunPython(seed_conditional_rules, migrations.RunPython.noop), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index f524efa..57ceb76 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -546,6 +546,26 @@ class FormSectionConfig(models.Model): return f'{self.form_type}: {self.section_key}' +class FormConditionalRuleConfig(models.Model): + FORM_CHOICES = [ + ('onboarding', _('Onboarding')), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + target_key = models.CharField(max_length=80) + clauses = models.JSONField(default=list, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'target_key'] + unique_together = ('form_type', 'target_key') + verbose_name = 'Formular-Bedingungsregel' + verbose_name_plural = 'Formular-Bedingungsregeln' + + def __str__(self) -> str: + return f'{self.form_type}: {self.target_key}' + + class NotificationTemplate(models.Model): TEMPLATE_CHOICES = [ ('onboarding_it', _('Onboarding: IT')), diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index b33ac38..534355a 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -6,6 +6,8 @@ const btnSubmit = document.getElementById('btn-submit'); const form = document.getElementById('onboarding-form'); const emailDomain = ((form && form.dataset.emailDomain) || 'workdock.de').replace(/^@+/, '').trim(); + const conditionalRulesNode = document.getElementById('onboarding-conditional-rules'); + const conditionalRules = conditionalRulesNode ? JSON.parse(conditionalRulesNode.textContent || '{}') : {}; let current = 0; form.setAttribute('novalidate', 'novalidate'); @@ -14,34 +16,39 @@ const el = document.getElementById(id); if (!el) return; el.classList.toggle('hidden', !state); + el.setAttribute('aria-hidden', state ? 'false' : 'true'); + } + + function fieldState(name) { + const field = byName(name); + if (!field) return { exists: false, value: '', checked: false }; + return { + exists: true, + value: (field.value || '').trim(), + checked: !!field.checked, + }; + } + + function evaluateClause(clause) { + const state = fieldState(clause.field); + if (!state.exists) return false; + if (clause.operator === 'checked') return state.checked === !!clause.value; + if (clause.operator === 'equals') return state.value === String(clause.value); + if (clause.operator === 'not_equals') return state.value !== String(clause.value); + return false; + } + + function evaluateRule(rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + return all.every(evaluateClause); } function syncConditionals() { - const orderCards = byName('order_business_cards'); - toggle('business-card-box', orderCards && orderCards.checked); - - const employmentType = byName('employment_type'); - toggle('employment-end-box', employmentType && employmentType.value === 'befristet'); - - const groupMailbox = byName('group_mailboxes_required_choice'); - toggle('group-mailboxes-box', groupMailbox && groupMailbox.value === 'ja'); - - const extraHardware = byName('additional_hardware_needed_choice'); - toggle('extra-hardware-box', extraHardware && extraHardware.value === 'ja'); - - const extraSoftware = byName('additional_software_needed_choice'); - toggle('extra-software-box', extraSoftware && extraSoftware.value === 'ja'); - - const extraAccess = byName('additional_access_needed_choice'); - toggle('extra-access-box', extraAccess && extraAccess.value === 'ja'); - - const successor = byName('successor_required_choice'); - const showSuccessor = successor && successor.value === 'ja'; - toggle('successor-box', showSuccessor); - - const inheritPhone = byName('inherit_phone_number_choice'); - const hidePhone = showSuccessor && inheritPhone && inheritPhone.value === 'ja'; - toggle('phone-box', !hidePhone); + Object.entries(conditionalRules).forEach(function (entry) { + const targetId = entry[0]; + const rule = entry[1] || {}; + toggle(targetId, evaluateRule(rule)); + }); // Hidden conditional groups must not block submit with invisible required fields. document.querySelectorAll('.field-group').forEach(function (group) { @@ -60,6 +67,22 @@ }); } + function setupConditionalBindings() { + const watched = new Set(); + Object.values(conditionalRules).forEach(function (rule) { + const all = Array.isArray(rule.all) ? rule.all : []; + all.forEach(function (clause) { + if (clause.field) watched.add(clause.field); + }); + }); + watched.forEach(function (name) { + const field = byName(name); + if (!field) return; + field.addEventListener('change', syncConditionals); + field.addEventListener('input', syncConditionals); + }); + } + function slugifyForEmail(value) { const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' }; const lower = (value || '').toLowerCase(); @@ -224,7 +247,6 @@ current = Math.max(0, step); } - document.addEventListener('change', syncConditionals); navItems.forEach((n, idx) => { n.addEventListener('click', function () { current = idx; updateStep(); }); n.addEventListener('keydown', function (e) { @@ -245,6 +267,7 @@ }); syncConditionals(); + setupConditionalBindings(); setupWorkEmailAutofill(); setupBusinessCardAutofill(); setupChecklistToggles(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 2e1cef1..f14536a 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -246,6 +246,70 @@ + + {% if form_type == 'onboarding' %} +
    +
    +

    {% trans "Bedingte Logik" %}

    +
    +
    + {% csrf_token %} +
    + {% for item in conditional_rule_items %} +
    +
    +

    {{ item.title }}

    + {{ item.target_fields|join:", " }} +
    +
    +
    +
    + {{ item.title }} +
    {{ item.description }}
    +
    + +
    + {% for clause in item.clauses %} +
    +
    + {% blocktrans trimmed with number=forloop.counter %}Bedingung {{ number }}{% endblocktrans %} +
    + + + +
    + {% endfor %} +
    +
    + {% endfor %} +
    +
    + +
    + +
    + {% endif %} diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 9a2953a..121cc05 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -44,6 +44,7 @@
    {% csrf_token %} + {{ onboarding_conditional_rules|json_script:"onboarding-conditional-rules" }} {% for section in onboarding_sections %}
    @@ -92,7 +93,11 @@ {% endif %} {% endwith %} {% else %} -
    +
    {% for field in block.fields %} {% if field.is_hidden %} diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index ab75efd..69ee28e 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,7 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormFieldConfig, FormOption, FormSectionConfig +from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig class FormBuilderAdminTests(TestCase): @@ -172,3 +172,33 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(notes.is_visible, True) self.assertEqual(notes.is_required, True) self.assertEqual(department.is_required, True) + + def test_staff_can_save_onboarding_conditional_rules(self): + self.client.force_login(self.staff) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_conditional_rules', + 'conditional_active_employment-end-box': 'on', + 'conditional_field_employment-end-box_0': 'employment_type', + 'conditional_operator_employment-end-box_0': 'equals', + 'conditional_value_employment-end-box_0': 'befristet', + 'conditional_active_phone-box': 'on', + 'conditional_field_phone-box_0': 'successor_required_choice', + 'conditional_operator_phone-box_0': 'equals', + 'conditional_value_phone-box_0': 'ja', + 'conditional_field_phone-box_1': 'inherit_phone_number_choice', + 'conditional_operator_phone-box_1': 'not_equals', + 'conditional_value_phone-box_1': 'ja', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='phone-box') + self.assertEqual(rule.is_active, True) + self.assertEqual(len(rule.clauses), 2) + self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice') + self.assertEqual(rule.clauses[1]['operator'], 'not_equals') diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index a91a714..c43bad6 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import FormFieldConfig, FormSectionConfig, OnboardingRequest +from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): @@ -143,3 +143,31 @@ class OnboardingFlowTests(TestCase): self.assertEqual(submit_response.status_code, 302) self.assertTrue(OnboardingRequest.objects.filter(work_email=f'nora.section@{self.company_domain}').exists()) mock_delay.assert_called_once() + + def test_onboarding_page_renders_conditional_rules_payload(self): + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('id="onboarding-conditional-rules"', html) + self.assertIn('business-card-box', html) + self.assertIn('employment-end-box', html) + self.assertIn('data-conditional-target="business-card-box"', html) + self.assertIn('data-conditional-target="phone-box"', html) + + def test_onboarding_page_uses_stored_conditional_rule_config(self): + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='employment-end-box', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}], + }, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('employment-end-box', html) + self.assertIn('"value": "unbefristet"', html) diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 0b0233c..e44bbc7 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -37,6 +37,7 @@ from .branding import get_branding_email_copy, get_company_email_domain, get_def from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm from .form_builder import ( DEFAULT_FIELD_ORDER, + DEFAULT_CONDITIONAL_RULES, FORM_PRESETS, LOCKED_FIELD_RULES, LOCKED_SECTION_RULES, @@ -46,13 +47,14 @@ from .form_builder import ( ONBOARDING_PAGE_LABELS, ONBOARDING_PAGE_ORDER, ensure_form_field_configs, + ensure_form_conditional_rule_configs, ensure_form_section_configs, get_default_page_map, get_section_labels, get_section_order, apply_form_preset, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email from .notifications import notify_user from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability @@ -103,15 +105,7 @@ ONBOARDING_GROUPS = { 'phone-box': ['phone_number_choice'], } -ONBOARDING_HIDDEN_BY_DEFAULT = { - 'business-card-box', - 'employment-end-box', - 'group-mailboxes-box', - 'extra-hardware-box', - 'extra-software-box', - 'extra-access-box', - 'successor-box', -} +ONBOARDING_HIDDEN_BY_DEFAULT = set(DEFAULT_CONDITIONAL_RULES.get('onboarding', {}).keys()) ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} ONBOARDING_CHECKBOX_LISTS = { @@ -131,6 +125,24 @@ ONBOARDING_SECTION_META = { 'abschluss': {'title': gettext_lazy('Abschluss'), 'subtitle': gettext_lazy('Notizen und Freigabe')}, } +CONDITIONAL_RULE_OPERATOR_CHOICES = [ + ('checked', _('ist aktiviert')), + ('equals', _('ist gleich')), + ('not_equals', _('ist nicht gleich')), +] + + +def _normalized_conditional_rule_payload(form_type: str) -> dict[str, dict]: + configs = ensure_form_conditional_rule_configs(form_type) + payload = {} + for target_key, cfg in configs.items(): + if not cfg.is_active: + continue + clauses = [clause for clause in (cfg.clauses or []) if clause.get('field') and clause.get('operator')] + if clauses: + payload[target_key] = {'all': clauses} + return payload + def healthz(request): db_ok = True @@ -1825,6 +1837,7 @@ def onboarding_create(request): if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible } onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) + onboarding_conditional_rules = _normalized_conditional_rule_payload('onboarding') return render( request, @@ -1835,6 +1848,7 @@ def onboarding_create(request): 'onboarding_sections': onboarding_sections, 'onboarding_inline_checks': ONBOARDING_INLINE_CHECKS, 'onboarding_checkbox_lists': ONBOARDING_CHECKBOX_LISTS, + 'onboarding_conditional_rules': onboarding_conditional_rules, 'legal_text': legal_text, 'saved': request.GET.get('saved') == '1', 'saved_request_id': request.GET.get('id', ''), @@ -2179,6 +2193,27 @@ def form_builder_page(request): _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) messages.success(request, 'Abschnittsregeln wurden gespeichert.') + elif action == 'save_conditional_rules' and form_type == 'onboarding': + rule_configs = ensure_form_conditional_rule_configs(form_type) + updated = 0 + for target_key, cfg in rule_configs.items(): + cfg.is_active = request.POST.get(f'conditional_active_{target_key}') == 'on' + clauses = [] + clause_total = 2 + for index in range(clause_total): + field_name = (request.POST.get(f'conditional_field_{target_key}_{index}') or '').strip() + operator = (request.POST.get(f'conditional_operator_{target_key}_{index}') or '').strip() + value = (request.POST.get(f'conditional_value_{target_key}_{index}') or '').strip() + if not field_name or not operator: + continue + parsed_value = True if operator == 'checked' else value + clauses.append({'field': field_name, 'operator': operator, 'value': parsed_value}) + cfg.clauses = clauses + cfg.save(update_fields=['is_active', 'clauses']) + updated += 1 + _audit(request, 'form_conditional_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) + messages.success(request, 'Bedingte Logik wurde gespeichert.') + elif action == 'apply_preset': preset_key = (request.POST.get('preset_key') or '').strip() if apply_form_preset(form_type, preset_key): @@ -2194,7 +2229,7 @@ def form_builder_page(request): active_subpanel = 'options' elif action == 'save_field_texts': active_subpanel = 'field-texts' - elif action in {'save_field_rules', 'save_section_rules'}: + elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: active_panel = 'builder-rules' redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" if active_panel: @@ -2217,6 +2252,7 @@ def form_builder_page(request): ensure_form_field_configs(form_type, default_names) section_configs = ensure_form_section_configs(form_type) + conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {} section_order = get_section_order(form_type) section_labels = get_section_labels(form_type) default_page_map = get_default_page_map(form_type) @@ -2343,6 +2379,57 @@ def form_builder_page(request): } ) + conditional_rule_items = [] + if form_type == 'onboarding': + conditional_field_choices = [] + for field_name in [ + 'order_business_cards', + 'employment_type', + 'group_mailboxes_required_choice', + 'additional_hardware_needed_choice', + 'additional_software_needed_choice', + 'additional_access_needed_choice', + 'successor_required_choice', + 'inherit_phone_number_choice', + ]: + conditional_field_choices.append((field_name, labels.get(field_name, field_name))) + conditional_target_titles = { + 'business-card-box': _('Visitenkarten-Details'), + 'employment-end-box': _('Vertragsende'), + 'group-mailboxes-box': _('Gruppenpostfächer'), + 'extra-hardware-box': _('Zusätzliche Hardware'), + 'extra-software-box': _('Zusätzliche Software'), + 'extra-access-box': _('Zusätzliche Zugänge'), + 'successor-box': _('Nachfolge'), + 'phone-box': _('Direktwahl'), + } + conditional_target_descriptions = { + 'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'), + 'employment-end-box': _('Steuert das Enddatum bei befristeter Beschäftigung.'), + 'group-mailboxes-box': _('Steuert das Freitextfeld für Gruppenpostfächer.'), + 'extra-hardware-box': _('Steuert zusätzliche Hardware-Felder.'), + 'extra-software-box': _('Steuert zusätzliche Software-Felder.'), + 'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'), + 'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'), + 'phone-box': _('Steuert die manuelle Direktwahl.'), + } + for target_key, cfg in conditional_rule_configs.items(): + clauses = list(cfg.clauses or []) + while len(clauses) < 2: + clauses.append({'field': '', 'operator': 'equals', 'value': ''}) + conditional_rule_items.append( + { + 'target_key': target_key, + 'title': conditional_target_titles.get(target_key, target_key), + 'description': conditional_target_descriptions.get(target_key, ''), + 'is_active': cfg.is_active, + 'clauses': clauses[:2], + 'field_choices': conditional_field_choices, + 'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES, + 'target_fields': [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])], + } + ) + preview_sections = [] if section_order: field_rule_group_map = {group['key']: group['items'] for group in field_rule_groups} @@ -2391,6 +2478,7 @@ def form_builder_page(request): 'preview_sections': preview_sections, 'section_rule_items': section_rule_items, 'builder_summary': builder_summary, + 'conditional_rule_items': conditional_rule_items, 'active_panel': active_panel, 'active_subpanel': active_subpanel, 'available_presets': FORM_PRESETS.get(form_type, {}), From fdc27f2123b2cd14d21ac2f7b7d262f9084a12c7 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 13:21:25 +0100 Subject: [PATCH 21/45] snapshot: preserve custom field parity across forms timeline and pdf --- backend/locale/en/LC_MESSAGES/django.po | 1309 ++++++++++------- backend/workflows/form_builder.py | 142 +- backend/workflows/forms.py | 27 +- ...ingrequest_custom_field_values_and_more.py | 63 + backend/workflows/models.py | 78 + backend/workflows/pdf_sections.py | 21 + .../workflows/static/workflows/css/home.css | 109 ++ .../templates/workflows/form_builder.html | 141 +- .../workflows/templates/workflows/home.html | 51 + .../templates/workflows/job_monitor.html | 39 + .../templates/workflows/offboarding_form.html | 8 + .../templates/workflows/onboarding_form.html | 4 +- .../templates/workflows/request_timeline.html | 22 +- .../tests/test_form_builder_admin.py | 58 +- .../workflows/tests/test_observability_ui.py | 123 ++ .../workflows/tests/test_offboarding_flow.py | 36 +- .../workflows/tests/test_onboarding_flow.py | 167 ++- backend/workflows/tests/test_pdf_sections.py | 31 +- .../workflows/tests/test_request_timeline.py | 73 + backend/workflows/views.py | 337 ++++- 20 files changed, 2294 insertions(+), 545 deletions(-) create mode 100644 backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py create mode 100644 backend/workflows/tests/test_observability_ui.py create mode 100644 backend/workflows/tests/test_request_timeline.py diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index ca8e780..28ef53a 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,15 +2,15 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 11:06+0000\n" +"POT-Creation-Date: 2026-03-27 12:07+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/app_registry.py:35 workflows/models.py:482 workflows/models.py:521 -#: workflows/models.py:588 +#: workflows/app_registry.py:35 workflows/models.py:485 workflows/models.py:524 +#: workflows/models.py:552 workflows/models.py:582 workflows/models.py:690 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 @@ -37,7 +37,8 @@ msgstr "Multi-step form" msgid "E-Mail Routing" msgstr "Email routing" -#: workflows/app_registry.py:46 workflows/models.py:483 workflows/models.py:589 +#: workflows/app_registry.py:46 workflows/models.py:486 workflows/models.py:525 +#: workflows/models.py:583 workflows/models.py:691 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 msgid "Offboarding" @@ -93,8 +94,8 @@ msgstr "Search" #: workflows/app_registry.py:62 #: workflows/templates/workflows/app_registry.html:32 #: workflows/templates/workflows/backup_recovery.html:72 -#: workflows/templates/workflows/job_monitor.html:29 -#: workflows/templates/workflows/job_monitor.html:50 +#: workflows/templates/workflows/job_monitor.html:68 +#: workflows/templates/workflows/job_monitor.html:89 #: workflows/templates/workflows/onboarding_intro_session.html:37 #: workflows/templates/workflows/request_timeline.html:70 #: workflows/templates/workflows/requests_dashboard.html:136 @@ -127,11 +128,12 @@ msgstr "" #: workflows/app_registry.py:142 workflows/app_registry.py:151 #: workflows/app_registry.py:160 workflows/app_registry.py:169 #: workflows/app_registry.py:178 workflows/app_registry.py:187 -#: workflows/templates/workflows/form_builder.html:82 -#: workflows/templates/workflows/form_builder.html:151 -#: workflows/templates/workflows/form_builder.html:259 -#: workflows/templates/workflows/form_builder.html:269 -#: workflows/templates/workflows/form_builder.html:344 +#: workflows/templates/workflows/form_builder.html:86 +#: workflows/templates/workflows/form_builder.html:155 +#: workflows/templates/workflows/form_builder.html:327 +#: workflows/templates/workflows/form_builder.html:337 +#: workflows/templates/workflows/form_builder.html:412 +#: workflows/templates/workflows/form_builder.html:463 #: workflows/templates/workflows/includes/app_header.html:57 msgid "Öffnen" msgstr "Open" @@ -390,222 +392,218 @@ msgstr "" msgid "Remote Backup in Nextcloud konnte nicht gelöscht werden." msgstr "" -#: workflows/forms.py:106 workflows/forms.py:473 +#: workflows/forms.py:112 workflows/forms.py:476 #: workflows/templates/workflows/user_management.html:72 #: workflows/templates/workflows/user_management.html:170 msgid "Benutzername" msgstr "" -#: workflows/forms.py:108 +#: workflows/forms.py:114 msgid "Passwort" msgstr "Password" -#: workflows/forms.py:114 +#: workflows/forms.py:120 msgid "Benutzername oder Passwort sind nicht korrekt." msgstr "" -#: workflows/forms.py:115 +#: workflows/forms.py:121 #, fuzzy #| msgid "Deaktivieren" msgid "Dieses Konto ist deaktiviert." msgstr "Disabled" -#: workflows/forms.py:141 workflows/forms.py:304 workflows/forms.py:356 +#: workflows/forms.py:147 workflows/forms.py:307 workflows/forms.py:359 msgid "TOTP-Code" msgstr "" -#: workflows/forms.py:147 workflows/forms.py:362 +#: workflows/forms.py:153 workflows/forms.py:365 msgid "Recovery-Code" msgstr "" -#: workflows/forms.py:154 workflows/forms.py:325 workflows/forms.py:382 +#: workflows/forms.py:160 workflows/forms.py:328 workflows/forms.py:385 msgid "Der TOTP-Code ist ungültig." msgstr "" -#: workflows/forms.py:155 +#: workflows/forms.py:161 msgid "Bitte geben Sie Ihren TOTP-Code ein." msgstr "" -#: workflows/forms.py:182 workflows/forms.py:246 workflows/forms.py:474 +#: workflows/forms.py:188 workflows/forms.py:249 workflows/forms.py:477 #, fuzzy #| msgid "E-Mail" msgid "E-Mail-Adresse" msgstr "Email" -#: workflows/forms.py:187 workflows/forms.py:206 +#: workflows/forms.py:193 workflows/forms.py:212 #: workflows/templates/workflows/user_management.html:77 #: workflows/templates/workflows/user_management.html:108 msgid "Neues Passwort" msgstr "New password" -#: workflows/forms.py:193 workflows/forms.py:212 +#: workflows/forms.py:199 workflows/forms.py:218 msgid "Neues Passwort bestätigen" msgstr "Confirm new password" -#: workflows/forms.py:201 workflows/forms.py:299 workflows/forms.py:331 +#: workflows/forms.py:207 workflows/forms.py:302 workflows/forms.py:334 #, fuzzy #| msgid "Neues Passwort" msgid "Aktuelles Passwort" msgstr "New password" -#: workflows/forms.py:223 workflows/templates/workflows/account_profile.html:36 +#: workflows/forms.py:229 workflows/templates/workflows/account_profile.html:36 #: workflows/templates/workflows/includes/app_header.html:79 msgid "Profilbild" msgstr "" -#: workflows/forms.py:239 -msgid "Das Profilbild darf maximal 5 MB groß sein." -msgstr "" - -#: workflows/forms.py:244 workflows/forms.py:471 +#: workflows/forms.py:247 workflows/forms.py:474 #: workflows/templates/workflows/account_profile.html:116 msgid "Vorname" msgstr "" -#: workflows/forms.py:245 workflows/forms.py:472 +#: workflows/forms.py:248 workflows/forms.py:475 #: workflows/templates/workflows/account_profile.html:120 msgid "Nachname" msgstr "" -#: workflows/forms.py:247 +#: workflows/forms.py:250 #: workflows/templates/workflows/account_profile.html:124 msgid "Telefon" msgstr "" -#: workflows/forms.py:248 +#: workflows/forms.py:251 #: workflows/templates/workflows/account_profile.html:128 msgid "Mobil" msgstr "" -#: workflows/forms.py:249 +#: workflows/forms.py:252 #: workflows/templates/workflows/account_profile.html:132 #, fuzzy #| msgid "Produktion" msgid "Position" msgstr "Production" -#: workflows/forms.py:250 workflows/models.py:443 +#: workflows/forms.py:253 workflows/models.py:444 #: workflows/templates/workflows/account_profile.html:136 #: workflows/templates/workflows/onboarding_intro_session.html:28 #: workflows/templates/workflows/requests_dashboard.html:145 msgid "Abteilung" msgstr "Department" -#: workflows/forms.py:251 +#: workflows/forms.py:254 #: workflows/templates/workflows/account_profile.html:140 msgid "Standort" msgstr "" -#: workflows/forms.py:253 +#: workflows/forms.py:256 #: workflows/templates/workflows/account_profile.html:144 #, fuzzy #| msgid "Einweisung" msgid "Hinweise" msgstr "Introduction" -#: workflows/forms.py:317 workflows/forms.py:344 +#: workflows/forms.py:320 workflows/forms.py:347 msgid "Das aktuelle Passwort ist nicht korrekt." msgstr "" -#: workflows/forms.py:323 +#: workflows/forms.py:326 msgid "Bitte geben Sie einen gültigen TOTP-Code ein." msgstr "" -#: workflows/forms.py:350 +#: workflows/forms.py:353 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP ist für dieses Konto nicht aktiv." msgstr "Disabled" -#: workflows/forms.py:378 +#: workflows/forms.py:381 msgid "Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein." msgstr "" -#: workflows/forms.py:385 +#: workflows/forms.py:388 msgid "Der Recovery-Code ist ungültig." msgstr "" -#: workflows/forms.py:390 +#: workflows/forms.py:393 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding erfolgreich" msgstr "Save offboarding request" -#: workflows/forms.py:391 +#: workflows/forms.py:394 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Onboarding fehlgeschlagen" msgstr "Failed" -#: workflows/forms.py:392 +#: workflows/forms.py:395 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding erfolgreich" msgstr "Save offboarding request" -#: workflows/forms.py:393 +#: workflows/forms.py:396 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding fehlgeschlagen" msgstr "Save offboarding request" -#: workflows/forms.py:394 +#: workflows/forms.py:397 #, fuzzy #| msgid "Eingereicht" msgid "Backup erfolgreich" msgstr "Submitted" -#: workflows/forms.py:395 workflows/views.py:1387 +#: workflows/forms.py:398 workflows/views.py:1448 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Backup fehlgeschlagen" msgstr "Failed" -#: workflows/forms.py:396 +#: workflows/forms.py:399 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail erfolgreich" msgstr "Welcome Emails" -#: workflows/forms.py:397 +#: workflows/forms.py:400 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fehlgeschlagen" msgstr "Welcome Emails" -#: workflows/forms.py:398 +#: workflows/forms.py:401 #, fuzzy #| msgid "Einweisung" msgid "Trial-Hinweise" msgstr "Introduction" -#: workflows/forms.py:399 +#: workflows/forms.py:402 #, fuzzy #| msgid "Einweisung" msgid "System-Hinweise" msgstr "Introduction" -#: workflows/forms.py:415 +#: workflows/forms.py:418 #, fuzzy #| msgid "Workflow-Regeln" msgid "Workflow" msgstr "Workflow rules" -#: workflows/forms.py:416 workflows/views.py:1544 +#: workflows/forms.py:419 workflows/views.py:1605 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/forms.py:417 workflows/templates/workflows/handbook.html:21 +#: workflows/forms.py:420 workflows/templates/workflows/handbook.html:21 msgid "Operations" msgstr "Operations" -#: workflows/forms.py:418 +#: workflows/forms.py:421 msgid "Platform" msgstr "" -#: workflows/forms.py:475 workflows/templates/workflows/user_management.html:74 +#: workflows/forms.py:478 workflows/templates/workflows/user_management.html:74 #: workflows/templates/workflows/user_management.html:93 #: workflows/templates/workflows/user_management.html:171 #, fuzzy @@ -613,207 +611,195 @@ msgstr "" msgid "Rolle" msgstr "Role:" -#: workflows/forms.py:489 +#: workflows/forms.py:492 msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:498 workflows/views.py:1196 +#: workflows/forms.py:501 workflows/views.py:1257 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:500 workflows/views.py:1199 +#: workflows/forms.py:503 workflows/views.py:1260 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" -#: workflows/forms.py:539 +#: workflows/forms.py:542 msgid "Portal-Titel" msgstr "Portal title" -#: workflows/forms.py:540 +#: workflows/forms.py:543 msgid "Firmenname" msgstr "Company name" -#: workflows/forms.py:541 +#: workflows/forms.py:544 #, fuzzy #| msgid "Firmenname" msgid "Firmen-Domain" msgstr "Company name" -#: workflows/forms.py:542 +#: workflows/forms.py:545 msgid "Support-E-Mail" msgstr "Support email" -#: workflows/forms.py:543 +#: workflows/forms.py:546 msgid "Absender-Anzeigename" msgstr "" -#: workflows/forms.py:544 +#: workflows/forms.py:547 msgid "Login-Untertitel" msgstr "" -#: workflows/forms.py:545 +#: workflows/forms.py:548 msgid "Footer-Text DE" msgstr "" -#: workflows/forms.py:546 +#: workflows/forms.py:549 msgid "Footer-Text EN" msgstr "" -#: workflows/forms.py:547 +#: workflows/forms.py:550 msgid "Rechtlicher Hinweis DE" msgstr "" -#: workflows/forms.py:548 +#: workflows/forms.py:551 msgid "Rechtlicher Hinweis EN" msgstr "" -#: workflows/forms.py:549 +#: workflows/forms.py:552 msgid "Standardsprache" msgstr "Default language" -#: workflows/forms.py:550 +#: workflows/forms.py:553 msgid "Logo" msgstr "Logo" -#: workflows/forms.py:551 +#: workflows/forms.py:554 msgid "PDF-Briefkopf" msgstr "PDF letterhead" -#: workflows/forms.py:552 +#: workflows/forms.py:555 msgid "Favicon" msgstr "" -#: workflows/forms.py:553 +#: workflows/forms.py:556 #: workflows/templates/workflows/branding_settings.html:89 #: workflows/templates/workflows/branding_settings.html:162 msgid "Primärfarbe" msgstr "Primary color" -#: workflows/forms.py:554 +#: workflows/forms.py:557 #: workflows/templates/workflows/branding_settings.html:90 #: workflows/templates/workflows/branding_settings.html:163 msgid "Sekundärfarbe" msgstr "Secondary color" -#: workflows/forms.py:571 -msgid "Das Logo darf maximal 5 MB groß sein." -msgstr "" - -#: workflows/forms.py:579 -msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." -msgstr "" - -#: workflows/forms.py:587 -msgid "Das Favicon darf maximal 2 MB groß sein." -msgstr "" - -#: workflows/forms.py:611 +#: workflows/forms.py:605 #, fuzzy #| msgid "Firmenname" msgid "Rechtlicher Firmenname" msgstr "Company name" -#: workflows/forms.py:612 +#: workflows/forms.py:606 msgid "Straße und Hausnummer" msgstr "" -#: workflows/forms.py:613 +#: workflows/forms.py:607 msgid "Postleitzahl" msgstr "" -#: workflows/forms.py:614 +#: workflows/forms.py:608 msgid "Stadt" msgstr "" -#: workflows/forms.py:615 +#: workflows/forms.py:609 msgid "Land" msgstr "" -#: workflows/forms.py:616 workflows/templates/workflows/base_shell.html:64 +#: workflows/forms.py:610 workflows/templates/workflows/base_shell.html:64 msgid "Website" msgstr "" -#: workflows/forms.py:617 +#: workflows/forms.py:611 msgid "Impressum-URL" msgstr "" -#: workflows/forms.py:618 +#: workflows/forms.py:612 msgid "Datenschutz-URL" msgstr "" -#: workflows/forms.py:619 +#: workflows/forms.py:613 msgid "HR-Kontakt" msgstr "" -#: workflows/forms.py:620 +#: workflows/forms.py:614 msgid "IT-Kontakt" msgstr "" -#: workflows/forms.py:621 +#: workflows/forms.py:615 #, fuzzy #| msgid "Operations" msgid "Operations-Kontakt" msgstr "Operations" -#: workflows/forms.py:622 +#: workflows/forms.py:616 msgid "Zentrale Telefonnummer" msgstr "" -#: workflows/forms.py:623 +#: workflows/forms.py:617 msgid "USt-IdNr." msgstr "" -#: workflows/forms.py:624 +#: workflows/forms.py:618 msgid "Register- oder Handelsnummer" msgstr "" -#: workflows/forms.py:641 +#: workflows/forms.py:635 msgid "Trial-Modus aktiv" msgstr "" -#: workflows/forms.py:642 +#: workflows/forms.py:636 msgid "Trial-Beginn" msgstr "" -#: workflows/forms.py:643 +#: workflows/forms.py:637 msgid "Trial-Ende" msgstr "" -#: workflows/forms.py:644 +#: workflows/forms.py:638 msgid "Produktive Integrationen begrenzen" msgstr "" -#: workflows/forms.py:645 +#: workflows/forms.py:639 msgid "Cleanup nach Ablauf zulassen" msgstr "" -#: workflows/forms.py:646 +#: workflows/forms.py:640 msgid "Banner-Text DE" msgstr "" -#: workflows/forms.py:647 +#: workflows/forms.py:641 msgid "Banner-Text EN" msgstr "" -#: workflows/forms.py:667 +#: workflows/forms.py:661 msgid "Bitte ein Trial-Ende festlegen." msgstr "" -#: workflows/forms.py:669 +#: workflows/forms.py:663 msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:808 workflows/forms.py:993 +#: workflows/forms.py:802 workflows/forms.py:964 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:830 workflows/forms.py:1007 +#: workflows/forms.py:825 workflows/forms.py:979 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" -#: workflows/forms.py:915 +#: workflows/forms.py:881 #, python-format msgid "" "Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen " @@ -879,39 +865,40 @@ msgstr "Submitted" msgid "Warnung" msgstr "" -#: workflows/models.py:132 workflows/templates/workflows/job_monitor.html:53 +#: workflows/models.py:132 workflows/templates/workflows/job_monitor.html:39 +#: workflows/templates/workflows/job_monitor.html:92 msgid "Fehler" msgstr "" -#: workflows/models.py:308 workflows/views.py:640 +#: workflows/models.py:308 workflows/views.py:695 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:309 workflows/views.py:640 +#: workflows/models.py:309 workflows/views.py:695 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:642 +#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:744 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:436 -#: workflows/views.py:640 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:451 +#: workflows/views.py:695 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:360 workflows/views.py:433 +#: workflows/models.py:360 workflows/views.py:448 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:361 workflows/views.py:434 +#: workflows/models.py:361 workflows/views.py:449 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:362 workflows/models.py:702 workflows/views.py:435 +#: workflows/models.py:362 workflows/models.py:804 workflows/views.py:450 msgid "Abgeschlossen" msgstr "Completed" @@ -935,200 +922,231 @@ msgstr "" msgid "unbefristet" msgstr "" -#: workflows/models.py:444 +#: workflows/models.py:445 msgid "Geräte" msgstr "" -#: workflows/models.py:445 +#: workflows/models.py:446 msgid "Software" msgstr "" -#: workflows/models.py:446 +#: workflows/models.py:447 #, fuzzy #| msgid "Vorgänge" msgid "Zugänge" msgstr "Requests" -#: workflows/models.py:447 +#: workflows/models.py:448 msgid "Workspace-Gruppen" msgstr "" -#: workflows/models.py:448 +#: workflows/models.py:449 msgid "Ressourcen" msgstr "" -#: workflows/models.py:449 +#: workflows/models.py:450 msgid "Telefonnummern" msgstr "" -#: workflows/models.py:475 +#: workflows/models.py:476 msgid "Automatisch" msgstr "" -#: workflows/models.py:476 workflows/models.py:524 workflows/views.py:128 +#: workflows/models.py:477 workflows/models.py:528 workflows/models.py:586 +#: workflows/views.py:125 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:477 workflows/models.py:525 workflows/views.py:129 +#: workflows/models.py:478 workflows/models.py:529 workflows/models.py:587 +#: workflows/views.py:126 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:478 workflows/models.py:526 workflows/views.py:130 +#: workflows/models.py:479 workflows/models.py:530 workflows/models.py:588 +#: workflows/views.py:127 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:479 workflows/models.py:527 workflows/views.py:131 -#: workflows/views.py:566 +#: workflows/models.py:480 workflows/models.py:531 workflows/models.py:589 +#: workflows/views.py:128 workflows/views.py:581 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:546 +#: workflows/models.py:481 workflows/models.py:532 workflows/models.py:590 +#: workflows/views.py:579 +#, fuzzy +#| msgid "Mitarbeiter" +msgid "Mitarbeitende" +msgstr "Staff" + +#: workflows/models.py:482 workflows/models.py:533 workflows/models.py:591 +#: workflows/views.py:580 +msgid "Austritt" +msgstr "" + +#: workflows/models.py:576 +msgid "Text" +msgstr "" + +#: workflows/models.py:577 +msgid "Mehrzeilig" +msgstr "" + +#: workflows/models.py:578 workflows/templates/workflows/welcome_emails.html:80 +msgid "Auswahl" +msgstr "Select" + +#: workflows/models.py:579 +msgid "Checkbox" +msgstr "" + +#: workflows/models.py:648 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:547 +#: workflows/models.py:649 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:548 +#: workflows/models.py:650 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:549 +#: workflows/models.py:651 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:550 +#: workflows/models.py:652 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:551 +#: workflows/models.py:653 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:552 +#: workflows/models.py:654 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:553 +#: workflows/models.py:655 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:554 +#: workflows/models.py:656 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:555 +#: workflows/models.py:657 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:556 +#: workflows/models.py:658 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:592 +#: workflows/models.py:694 msgid "Immer" msgstr "" -#: workflows/models.py:593 workflows/models.py:671 +#: workflows/models.py:695 workflows/models.py:773 msgid "Enthält" msgstr "" -#: workflows/models.py:594 workflows/models.py:672 +#: workflows/models.py:696 workflows/models.py:774 msgid "Ist gleich" msgstr "" -#: workflows/models.py:595 +#: workflows/models.py:697 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:596 +#: workflows/models.py:698 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:638 +#: workflows/models.py:740 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:639 +#: workflows/models.py:741 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:640 +#: workflows/models.py:742 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:641 +#: workflows/models.py:743 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:664 workflows/tasks.py:627 +#: workflows/models.py:766 workflows/tasks.py:628 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:665 workflows/tasks.py:628 +#: workflows/models.py:767 workflows/tasks.py:629 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:666 workflows/tasks.py:629 +#: workflows/models.py:768 workflows/tasks.py:630 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:667 workflows/tasks.py:630 +#: workflows/models.py:769 workflows/tasks.py:631 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:670 +#: workflows/models.py:772 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:673 +#: workflows/models.py:775 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:674 +#: workflows/models.py:776 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:701 +#: workflows/models.py:803 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:721 +#: workflows/models.py:823 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:722 +#: workflows/models.py:824 msgid "S3" msgstr "" -#: workflows/models.py:723 +#: workflows/models.py:825 msgid "NFS" msgstr "" @@ -1155,137 +1173,137 @@ msgstr "IT Staff" msgid "Mitarbeiter" msgstr "Staff" -#: workflows/tasks.py:270 +#: workflows/tasks.py:271 #, fuzzy, python-format #| msgid "Welcome E-Mails" msgid "Welcome E-Mail gesendet: %(name)s" msgstr "Welcome Emails" -#: workflows/tasks.py:272 +#: workflows/tasks.py:273 #, fuzzy, python-format #| msgid "Fehlgeschlagen" msgid "Welcome E-Mail fehlgeschlagen: %(name)s" msgstr "Failed" -#: workflows/tasks.py:643 +#: workflows/tasks.py:644 #, python-format msgid "%(item)s übergeben und Grundfunktionen erklärt" msgstr "%(item)s handed over and basic functions explained" -#: workflows/tasks.py:645 +#: workflows/tasks.py:646 #, python-format msgid "%(item)s gezeigt bzw. Nutzung erklärt" msgstr "%(item)s shown or usage explained" -#: workflows/tasks.py:647 +#: workflows/tasks.py:648 #, python-format msgid "Telefonnummer / Direktwahl erklärt: %(value)s" msgstr "Phone number / direct extension explained: %(value)s" -#: workflows/tasks.py:649 +#: workflows/tasks.py:650 msgid "Arbeitsplatz, Geräte und allgemeine Nutzung besprochen" msgstr "Workplace, devices, and general usage reviewed" -#: workflows/tasks.py:651 +#: workflows/tasks.py:652 #, python-format msgid "%(item)s Zugang erklärt" msgstr "%(item)s access explained" -#: workflows/tasks.py:652 +#: workflows/tasks.py:653 #, python-format msgid "%(item)s Gruppe / Berechtigung erläutert" msgstr "%(item)s group / permission explained" -#: workflows/tasks.py:654 +#: workflows/tasks.py:655 #, python-format msgid "Dienstliche E-Mail-Adresse erläutert: %(value)s" msgstr "Work email address explained: %(value)s" -#: workflows/tasks.py:656 +#: workflows/tasks.py:657 #, python-format msgid "Gruppenpostfach erklärt: %(item)s" msgstr "Group mailbox explained: %(item)s" -#: workflows/tasks.py:658 +#: workflows/tasks.py:659 msgid "Zugänge, Konten und Anmeldelogik besprochen" msgstr "Accesses, accounts, and login logic reviewed" -#: workflows/tasks.py:660 +#: workflows/tasks.py:661 #, python-format msgid "%(item)s Einführung durchgeführt" msgstr "%(item)s introduction completed" -#: workflows/tasks.py:661 +#: workflows/tasks.py:662 #, python-format msgid "%(item)s zusätzlich besprochen" msgstr "%(item)s discussed additionally" -#: workflows/tasks.py:663 +#: workflows/tasks.py:664 msgid "Benötigte Standardsoftware und tägliche Nutzung erklärt" msgstr "Required standard software and daily usage explained" -#: workflows/tasks.py:666 +#: workflows/tasks.py:667 msgid "Passwortregeln und sicherer Umgang besprochen" msgstr "Password rules and secure handling reviewed" -#: workflows/tasks.py:667 +#: workflows/tasks.py:668 msgid "Dateiablage, Nextcloud und Freigaben erklärt" msgstr "File storage, Nextcloud, and sharing explained" -#: workflows/tasks.py:668 +#: workflows/tasks.py:669 msgid "Kommunikationswege und Support-Prozess erklärt" msgstr "Communication channels and support process explained" -#: workflows/tasks.py:671 +#: workflows/tasks.py:672 #, python-format msgid "%(item)s als zusätzliche Ausstattung besprochen" msgstr "%(item)s discussed as additional equipment" -#: workflows/tasks.py:673 +#: workflows/tasks.py:674 #, python-format msgid "Zusätzlicher Zugang besprochen: %(item)s" msgstr "Additional access discussed: %(item)s" -#: workflows/tasks.py:675 +#: workflows/tasks.py:676 #, python-format msgid "Übergabe-/Nachfolgekontext besprochen: %(value)s" msgstr "Handover / successor context reviewed: %(value)s" -#: workflows/tasks.py:1363 +#: workflows/tasks.py:1367 #, fuzzy, python-format #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Onboarding abgeschlossen: %(name)s" msgstr "Introduction was saved as completed." -#: workflows/tasks.py:1364 +#: workflows/tasks.py:1368 msgid "Die Onboarding-Anfrage wurde erfolgreich verarbeitet." msgstr "" -#: workflows/tasks.py:1375 +#: workflows/tasks.py:1379 #, fuzzy, python-format #| msgid "Fehlgeschlagen" msgid "Onboarding fehlgeschlagen: %(name)s" msgstr "Failed" -#: workflows/tasks.py:1464 +#: workflows/tasks.py:1468 #, fuzzy, python-format #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding abgeschlossen: %(name)s" msgstr "Save offboarding request" -#: workflows/tasks.py:1465 +#: workflows/tasks.py:1469 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Die Offboarding-Anfrage wurde erfolgreich verarbeitet." msgstr "Save offboarding request" -#: workflows/tasks.py:1476 +#: workflows/tasks.py:1480 #, fuzzy, python-format #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding fehlgeschlagen: %(name)s" msgstr "Save offboarding request" -#: workflows/tasks.py:1551 +#: workflows/tasks.py:1555 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Die geplante Welcome E-Mail wurde erfolgreich versendet." @@ -1525,7 +1543,9 @@ msgstr "" #: workflows/templates/workflows/account_profile.html:262 #: workflows/templates/workflows/app_registry.html:35 #: workflows/templates/workflows/app_registry.html:84 -#: workflows/templates/workflows/form_builder.html:308 +#: workflows/templates/workflows/form_builder.html:275 +#: workflows/templates/workflows/form_builder.html:376 +#: workflows/templates/workflows/form_builder.html:507 #: workflows/templates/workflows/integrations_setup.html:263 #: workflows/templates/workflows/intro_builder.html:65 #: workflows/templates/workflows/trial_management.html:28 @@ -1714,7 +1734,9 @@ msgstr "Last updated" #: workflows/templates/workflows/app_registry.html:4 #: workflows/templates/workflows/app_registry.html:103 -#: workflows/templates/workflows/form_builder.html:304 +#: workflows/templates/workflows/form_builder.html:372 +#: workflows/templates/workflows/form_builder.html:482 +#: workflows/templates/workflows/form_builder.html:503 #: workflows/templates/workflows/intro_builder.html:58 msgid "Sortierung" msgstr "Sort order" @@ -1746,8 +1768,8 @@ msgstr "Search by name or email" #: workflows/templates/workflows/app_registry.html:34 #: workflows/templates/workflows/app_registry.html:43 #: workflows/templates/workflows/audit_log.html:25 -#: workflows/templates/workflows/job_monitor.html:22 -#: workflows/templates/workflows/job_monitor.html:31 +#: workflows/templates/workflows/job_monitor.html:61 +#: workflows/templates/workflows/job_monitor.html:70 #: workflows/templates/workflows/requests_dashboard.html:130 #: workflows/templates/workflows/requests_dashboard.html:138 #: workflows/templates/workflows/requests_dashboard.html:147 @@ -1952,7 +1974,7 @@ msgid "Bis Datum" msgstr "" #: workflows/templates/workflows/audit_log.html:44 -#: workflows/templates/workflows/job_monitor.html:38 +#: workflows/templates/workflows/job_monitor.html:77 msgid "Filtern" msgstr "" @@ -1967,6 +1989,7 @@ msgid "Zeit" msgstr "" #: workflows/templates/workflows/audit_log.html:55 +#: workflows/templates/workflows/form_builder.html:502 #: workflows/templates/workflows/request_timeline.html:62 #: workflows/templates/workflows/requests_dashboard.html:128 #: workflows/templates/workflows/requests_dashboard.html:188 @@ -1974,7 +1997,8 @@ msgid "Typ" msgstr "Type" #: workflows/templates/workflows/audit_log.html:56 -#: workflows/templates/workflows/job_monitor.html:51 +#: workflows/templates/workflows/job_monitor.html:38 +#: workflows/templates/workflows/job_monitor.html:90 msgid "Ziel" msgstr "" @@ -2080,6 +2104,7 @@ msgid "" msgstr "Create database and media backups and verify existing bundles safely." #: workflows/templates/workflows/backup_recovery.html:20 +#: workflows/templates/workflows/home.html:65 #, fuzzy #| msgid "Trial-Status" msgid "Backup-Status" @@ -2201,8 +2226,10 @@ msgid "Backup-Bundle wirklich löschen?" msgstr "Delete this backup bundle?" #: workflows/templates/workflows/backup_recovery.html:133 -#: workflows/templates/workflows/form_builder.html:309 -#: workflows/templates/workflows/form_builder.html:324 +#: workflows/templates/workflows/form_builder.html:377 +#: workflows/templates/workflows/form_builder.html:392 +#: workflows/templates/workflows/form_builder.html:508 +#: workflows/templates/workflows/form_builder.html:550 #: workflows/templates/workflows/integrations_setup.html:265 #: workflows/templates/workflows/intro_builder.html:66 #: workflows/templates/workflows/intro_builder.html:102 @@ -2352,7 +2379,7 @@ msgid "Regeln" msgstr "Rule name" #: workflows/templates/workflows/form_builder.html:38 -#: workflows/templates/workflows/form_builder.html:257 +#: workflows/templates/workflows/form_builder.html:325 #, fuzzy #| msgid "Optionen verwalten" msgid "Optionen & Texte" @@ -2372,212 +2399,333 @@ msgstr "" msgid "Aktuell ausgeblendet" msgstr "Hidden" -#: workflows/templates/workflows/form_builder.html:56 +#: workflows/templates/workflows/form_builder.html:55 +#: workflows/templates/workflows/form_builder.html:462 +msgid "Eigene Felder" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:60 #, fuzzy #| msgid "Abschnitt" msgid "Versteckte Abschnitte" msgstr "Section" -#: workflows/templates/workflows/form_builder.html:63 -msgid "Presets" +#: workflows/templates/workflows/form_builder.html:70 +msgid "Vorlage anwenden" msgstr "" -#: workflows/templates/workflows/form_builder.html:80 +#: workflows/templates/workflows/form_builder.html:76 +msgid "Anwenden" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:84 msgid "Live-Vorschau" msgstr "" -#: workflows/templates/workflows/form_builder.html:91 -#: workflows/templates/workflows/form_builder.html:122 -#: workflows/templates/workflows/form_builder.html:203 +#: workflows/templates/workflows/form_builder.html:95 +#: workflows/templates/workflows/form_builder.html:126 +#: workflows/templates/workflows/form_builder.html:207 #, fuzzy, python-format #| msgid "Keine konfigurierten Felder in diesem Schritt." msgid "%(count)s Feld/Felder" msgstr "No configured fields in this step." -#: workflows/templates/workflows/form_builder.html:97 +#: workflows/templates/workflows/form_builder.html:101 msgid "Keine sichtbaren Felder." msgstr "" -#: workflows/templates/workflows/form_builder.html:110 +#: workflows/templates/workflows/form_builder.html:114 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Struktur & Reihenfolge" msgstr "Save order" -#: workflows/templates/workflows/form_builder.html:112 +#: workflows/templates/workflows/form_builder.html:116 #, fuzzy #| msgid "öffnen" msgid "Geöffnet" msgstr "open" -#: workflows/templates/workflows/form_builder.html:132 -#: workflows/templates/workflows/form_builder.html:178 -#: workflows/templates/workflows/form_builder.html:227 +#: workflows/templates/workflows/form_builder.html:136 +#: workflows/templates/workflows/form_builder.html:182 +#: workflows/templates/workflows/form_builder.html:231 msgid "Fix" msgstr "Fixed" -#: workflows/templates/workflows/form_builder.html:133 -#: workflows/templates/workflows/form_builder.html:180 -#: workflows/templates/workflows/form_builder.html:229 +#: workflows/templates/workflows/form_builder.html:137 +#: workflows/templates/workflows/form_builder.html:184 +#: workflows/templates/workflows/form_builder.html:233 msgid "Ausgeblendet" msgstr "Hidden" -#: workflows/templates/workflows/form_builder.html:134 -#: workflows/templates/workflows/form_builder.html:218 -#: workflows/templates/workflows/form_builder.html:221 -#: workflows/templates/workflows/form_builder.html:231 +#: workflows/templates/workflows/form_builder.html:138 +#: workflows/templates/workflows/form_builder.html:222 +#: workflows/templates/workflows/form_builder.html:225 +#: workflows/templates/workflows/form_builder.html:235 +#: workflows/templates/workflows/form_builder.html:484 +#: workflows/templates/workflows/form_builder.html:506 msgid "Pflicht" msgstr "Required" -#: workflows/templates/workflows/form_builder.html:149 +#: workflows/templates/workflows/form_builder.html:153 #, fuzzy #| msgid "Sicherheitsregeln" msgid "Sichtbarkeit & Regeln" msgstr "Safety rules" -#: workflows/templates/workflows/form_builder.html:159 +#: workflows/templates/workflows/form_builder.html:163 #, fuzzy #| msgid "Abschnitt" msgid "Abschnitte steuern" msgstr "Section" -#: workflows/templates/workflows/form_builder.html:168 +#: workflows/templates/workflows/form_builder.html:172 #, fuzzy, python-format #| msgid "Keine konfigurierten Felder in diesem Schritt." msgid "%(count)s Feld/Felder in diesem Abschnitt." msgstr "No configured fields in this step." -#: workflows/templates/workflows/form_builder.html:179 -#: workflows/templates/workflows/form_builder.html:214 +#: workflows/templates/workflows/form_builder.html:183 +#: workflows/templates/workflows/form_builder.html:218 msgid "Sichtbar" msgstr "" -#: workflows/templates/workflows/form_builder.html:187 +#: workflows/templates/workflows/form_builder.html:191 #, fuzzy #| msgid "Regeln speichern" msgid "Abschnittsregeln speichern" msgstr "Save rules" -#: workflows/templates/workflows/form_builder.html:194 +#: workflows/templates/workflows/form_builder.html:198 #, fuzzy #| msgid "Feldtexte verwalten" msgid "Feldregeln verwalten" msgstr "Manage field text" -#: workflows/templates/workflows/form_builder.html:220 +#: workflows/templates/workflows/form_builder.html:224 #, fuzzy #| msgid "Standardsprache" msgid "Standard" msgstr "Default language" -#: workflows/templates/workflows/form_builder.html:222 +#: workflows/templates/workflows/form_builder.html:226 #: workflows/templates/workflows/user_management.html:109 msgid "Optional" msgstr "Optional" -#: workflows/templates/workflows/form_builder.html:233 +#: workflows/templates/workflows/form_builder.html:237 msgid "Flexibel" msgstr "" -#: workflows/templates/workflows/form_builder.html:238 +#: workflows/templates/workflows/form_builder.html:242 #, fuzzy #| msgid "Keine Feldkonfigurationen verfügbar." msgid "Keine Feldregeln verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:245 +#: workflows/templates/workflows/form_builder.html:249 #, fuzzy #| msgid "Regeln speichern" msgid "Feldregeln speichern" msgstr "Save rules" -#: workflows/templates/workflows/form_builder.html:268 -msgid "Optionen verwalten" -msgstr "Manage options" +#: workflows/templates/workflows/form_builder.html:257 +msgid "Bedingte Logik" +msgstr "" -#: workflows/templates/workflows/form_builder.html:279 -msgid "Kategorie" -msgstr "Category" +#: workflows/templates/workflows/form_builder.html:282 +#, python-format +msgid "Bedingung %(number)s" +msgstr "" -#: workflows/templates/workflows/form_builder.html:292 -#: workflows/templates/workflows/form_builder.html:305 -#: workflows/templates/workflows/form_builder.html:355 -msgid "Label (DE)" -msgstr "Label (DE)" - -#: workflows/templates/workflows/form_builder.html:293 -msgid "Label (EN, optional)" -msgstr "Label (EN, optional)" - -#: workflows/templates/workflows/form_builder.html:294 -msgid "Technischer Wert (optional)" -msgstr "Technical value (optional)" - -#: workflows/templates/workflows/form_builder.html:295 -msgid "Option hinzufügen" -msgstr "Add option" - -#: workflows/templates/workflows/form_builder.html:306 -#: workflows/templates/workflows/form_builder.html:356 -msgid "Label (EN)" -msgstr "Label (EN)" - -#: workflows/templates/workflows/form_builder.html:317 -msgid "Ziehen zum Sortieren" -msgstr "Drag to reorder" - -#: workflows/templates/workflows/form_builder.html:324 -msgid "Option wirklich löschen?" -msgstr "Delete this option?" - -#: workflows/templates/workflows/form_builder.html:328 -msgid "Keine Optionen in dieser Kategorie." -msgstr "No options in this category." - -#: workflows/templates/workflows/form_builder.html:334 -msgid "Optionen speichern" -msgstr "Save options" - -#: workflows/templates/workflows/form_builder.html:343 -msgid "Feldtexte verwalten" -msgstr "Manage field text" - -#: workflows/templates/workflows/form_builder.html:354 +#: workflows/templates/workflows/form_builder.html:285 +#: workflows/templates/workflows/form_builder.html:422 msgid "Feld" msgstr "Field" -#: workflows/templates/workflows/form_builder.html:357 +#: workflows/templates/workflows/form_builder.html:287 +msgid "Keine" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:294 +#: 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/form_builder.html:302 +#: workflows/templates/workflows/intro_builder.html:64 +msgid "Wert" +msgstr "Value" + +#: workflows/templates/workflows/form_builder.html:303 +msgid "wird ignoriert" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:312 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Logik speichern" +msgstr "Save branding" + +#: workflows/templates/workflows/form_builder.html:336 +msgid "Optionen verwalten" +msgstr "Manage options" + +#: workflows/templates/workflows/form_builder.html:347 +msgid "Kategorie" +msgstr "Category" + +#: workflows/templates/workflows/form_builder.html:360 +#: workflows/templates/workflows/form_builder.html:373 +#: workflows/templates/workflows/form_builder.html:423 +#: workflows/templates/workflows/form_builder.html:470 +#: workflows/templates/workflows/form_builder.html:504 +msgid "Label (DE)" +msgstr "Label (DE)" + +#: workflows/templates/workflows/form_builder.html:361 +#: workflows/templates/workflows/form_builder.html:471 +msgid "Label (EN, optional)" +msgstr "Label (EN, optional)" + +#: workflows/templates/workflows/form_builder.html:362 +msgid "Technischer Wert (optional)" +msgstr "Technical value (optional)" + +#: workflows/templates/workflows/form_builder.html:363 +msgid "Option hinzufügen" +msgstr "Add option" + +#: workflows/templates/workflows/form_builder.html:374 +#: workflows/templates/workflows/form_builder.html:424 +#: workflows/templates/workflows/form_builder.html:505 +msgid "Label (EN)" +msgstr "Label (EN)" + +#: workflows/templates/workflows/form_builder.html:385 +msgid "Ziehen zum Sortieren" +msgstr "Drag to reorder" + +#: workflows/templates/workflows/form_builder.html:392 +msgid "Option wirklich löschen?" +msgstr "Delete this option?" + +#: workflows/templates/workflows/form_builder.html:396 +msgid "Keine Optionen in dieser Kategorie." +msgstr "No options in this category." + +#: workflows/templates/workflows/form_builder.html:402 +msgid "Optionen speichern" +msgstr "Save options" + +#: workflows/templates/workflows/form_builder.html:411 +msgid "Feldtexte verwalten" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:425 +#: workflows/templates/workflows/form_builder.html:539 msgid "Hilfetext (DE)" msgstr "Help text (DE)" -#: workflows/templates/workflows/form_builder.html:358 +#: workflows/templates/workflows/form_builder.html:426 +#: workflows/templates/workflows/form_builder.html:544 msgid "Hilfetext (EN)" msgstr "Help text (EN)" -#: workflows/templates/workflows/form_builder.html:372 +#: workflows/templates/workflows/form_builder.html:440 msgid "Fallback: Standardlabel" msgstr "Fallback: default label" -#: workflows/templates/workflows/form_builder.html:373 +#: workflows/templates/workflows/form_builder.html:441 msgid "English label" msgstr "English label" -#: workflows/templates/workflows/form_builder.html:374 +#: workflows/templates/workflows/form_builder.html:442 msgid "Optionaler Hilfetext" msgstr "Optional help text" -#: workflows/templates/workflows/form_builder.html:375 +#: workflows/templates/workflows/form_builder.html:443 msgid "Optional English help text" msgstr "Optional English help text" -#: workflows/templates/workflows/form_builder.html:378 +#: workflows/templates/workflows/form_builder.html:446 msgid "Keine Feldkonfigurationen verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:385 +#: workflows/templates/workflows/form_builder.html:453 msgid "Feldtexte speichern" msgstr "Save field text" +#: workflows/templates/workflows/form_builder.html:487 +#, fuzzy +#| msgid "Hilfetext (DE)" +msgid "Hilfetext (DE, optional)" +msgstr "Help text (DE)" + +#: workflows/templates/workflows/form_builder.html:488 +#, fuzzy +#| msgid "Hilfetext (EN)" +msgid "Hilfetext (EN, optional)" +msgstr "Help text (EN)" + +#: workflows/templates/workflows/form_builder.html:489 +msgid "Optionen (eine pro Zeile, optional: wert|Label)" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:490 +msgid "Optionen EN (eine pro Zeile, optional: value|Label)" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:491 +#, fuzzy +#| msgid "Neue Regel hinzufügen" +msgid "Eigenes Feld hinzufügen" +msgstr "Add new rule" + +#: workflows/templates/workflows/form_builder.html:500 +#, fuzzy +#| msgid "Onboarding starten" +msgid "Schlüssel" +msgstr "Start onboarding" + +#: workflows/templates/workflows/form_builder.html:501 +#: workflows/templates/workflows/intro_builder.html:29 +#: workflows/templates/workflows/intro_builder.html:59 +msgid "Abschnitt" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:540 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen (DE)" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:545 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen (EN)" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:550 +#, fuzzy +#| msgid "Option wirklich löschen?" +msgid "Eigenes Feld wirklich löschen?" +msgstr "Delete this option?" + +#: workflows/templates/workflows/form_builder.html:554 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine eigenen Felder vorhanden." +msgstr "No scheduled welcome emails available." + +#: workflows/templates/workflows/form_builder.html:561 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Eigene Felder speichern" +msgstr "Save field text" + #: workflows/templates/workflows/handbook.html:17 msgid "" "Single documentation entry point for both operational knowledge and long-" @@ -2745,7 +2893,48 @@ msgstr "Production" msgid "PDF + E-Mail Workflow bereit" msgstr "PDF + Email Workflow Ready" -#: workflows/templates/workflows/home.html:80 +#: workflows/templates/workflows/home.html:44 +#, fuzzy +#| msgid "Operations" +msgid "Operations Overview" +msgstr "Operations" + +#: workflows/templates/workflows/home.html:45 +msgid "Letzte Laufzeit- und Backup-Signale auf einen Blick." +msgstr "" + +#: workflows/templates/workflows/home.html:51 +#: workflows/templates/workflows/job_monitor.html:20 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Fehlgeschlagene Jobs (24h)" +msgstr "Failed" + +#: workflows/templates/workflows/home.html:55 +#: workflows/templates/workflows/job_monitor.html:24 +#, fuzzy +#| msgid "Eingereicht" +msgid "Erfolgreiche Jobs (24h)" +msgstr "Submitted" + +#: workflows/templates/workflows/home.html:59 +#: workflows/templates/workflows/job_monitor.html:28 +msgid "Offene Starts (24h)" +msgstr "" + +#: workflows/templates/workflows/home.html:74 +#, fuzzy +#| msgid "Letzte Anmeldung" +msgid "Letzte Fehler" +msgstr "Last login" + +#: workflows/templates/workflows/home.html:75 +#, fuzzy +#| msgid "Dashboard öffnen" +msgid "Job Monitor öffnen" +msgstr "Open dashboard" + +#: workflows/templates/workflows/home.html:131 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." @@ -2947,12 +3136,6 @@ msgstr "Event" msgid "Feldname" msgstr "Field name" -#: 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:229 #: workflows/templates/workflows/integrations_setup.html:300 msgid "Vergleichswert" @@ -3075,11 +3258,6 @@ msgstr "" 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:29 -#: workflows/templates/workflows/intro_builder.html:59 -msgid "Abschnitt" -msgstr "Section" - #: workflows/templates/workflows/intro_builder.html:37 #: workflows/templates/workflows/intro_builder.html:60 msgid "Checklistenpunkt (DE)" @@ -3115,10 +3293,6 @@ msgstr "Checklist item (EN)" msgid "Feld-Bedingung" msgstr "Field condition" -#: workflows/templates/workflows/intro_builder.html:64 -msgid "Wert" -msgstr "Value" - #: workflows/templates/workflows/intro_builder.html:99 msgid "z. B. HR Works" msgstr "e.g. HR Works" @@ -3147,20 +3321,26 @@ msgstr "Save checklist" msgid "Asynchrone Aufgaben, Fehler und letzte Worker-Läufe zentral prüfen." msgstr "" -#: workflows/templates/workflows/job_monitor.html:20 -#: workflows/templates/workflows/job_monitor.html:49 +#: workflows/templates/workflows/job_monitor.html:37 +#, fuzzy +#| msgid "Fehlgeschlagen" +msgid "Zuletzt fehlgeschlagen" +msgstr "Failed" + +#: workflows/templates/workflows/job_monitor.html:59 +#: workflows/templates/workflows/job_monitor.html:88 msgid "Task" msgstr "" -#: workflows/templates/workflows/job_monitor.html:48 +#: workflows/templates/workflows/job_monitor.html:87 msgid "Start" msgstr "" -#: workflows/templates/workflows/job_monitor.html:52 +#: workflows/templates/workflows/job_monitor.html:91 msgid "Task ID" msgstr "" -#: workflows/templates/workflows/job_monitor.html:67 +#: workflows/templates/workflows/job_monitor.html:106 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." msgid "Noch keine Task-Läufe vorhanden." @@ -3209,7 +3389,7 @@ msgstr "Search" msgid "Vorbefüllt aus:" msgstr "Prefilled from:" -#: workflows/templates/workflows/offboarding_form.html:74 +#: workflows/templates/workflows/offboarding_form.html:82 msgid "Offboarding-Anfrage speichern" msgstr "Save offboarding request" @@ -3286,42 +3466,42 @@ msgid "" "Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt." msgstr "Please check the highlighted fields. Invalid input was detected." -#: workflows/templates/workflows/onboarding_form.html:75 -#: workflows/templates/workflows/onboarding_form.html:77 -#: workflows/templates/workflows/onboarding_form.html:114 -#: workflows/templates/workflows/onboarding_form.html:116 +#: workflows/templates/workflows/onboarding_form.html:76 +#: workflows/templates/workflows/onboarding_form.html:78 +#: workflows/templates/workflows/onboarding_form.html:119 +#: workflows/templates/workflows/onboarding_form.html:121 #: workflows/templates/workflows/welcome_emails.html:65 msgid "Alle auswählen" msgstr "Select all" -#: workflows/templates/workflows/onboarding_form.html:76 -#: workflows/templates/workflows/onboarding_form.html:115 +#: workflows/templates/workflows/onboarding_form.html:77 +#: workflows/templates/workflows/onboarding_form.html:120 #, fuzzy #| msgid "Auswahl löschen" msgid "Auswahl aufheben" msgstr "Delete selection" -#: workflows/templates/workflows/onboarding_form.html:138 +#: workflows/templates/workflows/onboarding_form.html:143 msgid "Keine konfigurierten Felder in diesem Schritt." msgstr "No configured fields in this step." -#: workflows/templates/workflows/onboarding_form.html:143 +#: workflows/templates/workflows/onboarding_form.html:148 msgid "Fast geschafft. Bitte Abschlussdaten prüfen und die Anfrage absenden." msgstr "Almost done. Please review the final details and submit the request." -#: workflows/templates/workflows/onboarding_form.html:155 +#: workflows/templates/workflows/onboarding_form.html:160 msgid "Zurück" msgstr "Back" -#: workflows/templates/workflows/onboarding_form.html:156 +#: workflows/templates/workflows/onboarding_form.html:161 msgid "Weiter" msgstr "Next" -#: workflows/templates/workflows/onboarding_form.html:157 +#: workflows/templates/workflows/onboarding_form.html:162 msgid "Wird gesendet..." msgstr "" -#: workflows/templates/workflows/onboarding_form.html:157 +#: workflows/templates/workflows/onboarding_form.html:162 msgid "Onboarding-Anfrage absenden" msgstr "Submit onboarding request" @@ -3351,7 +3531,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1483 +#: workflows/views.py:1544 msgid "Vertragsbeginn" msgstr "Contract start" @@ -4131,10 +4311,6 @@ msgstr "Send now" msgid "Bulk ausführen" msgstr "Run bulk action" -#: workflows/templates/workflows/welcome_emails.html:80 -msgid "Auswahl" -msgstr "Select" - #: workflows/templates/workflows/welcome_emails.html:84 msgid "Geplant für" msgstr "Scheduled for" @@ -4151,350 +4327,424 @@ msgstr "Resume" msgid "Keine geplanten Welcome E-Mails vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/views.py:128 +#: workflows/upload_validation.py:75 +msgid "Bitte ein PNG-, JPG-, WEBP- oder SVG-Bild hochladen." +msgstr "" + +#: workflows/upload_validation.py:76 +msgid "Das Profilbild darf maximal 5 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:77 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Bilddatei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:95 +msgid "Bitte ein SVG-, PNG-, JPG- oder WEBP-Bild hochladen." +msgstr "" + +#: workflows/upload_validation.py:96 +msgid "Das Logo darf maximal 5 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:97 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Logo-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:114 +msgid "Bitte eine ICO-, PNG-, SVG- oder WEBP-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:115 +msgid "Das Favicon darf maximal 2 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:116 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Favicon-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:126 +msgid "Bitte eine gültige PDF-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:127 +msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein." +msgstr "" + +#: workflows/upload_validation.py:128 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die PDF-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/upload_validation.py:144 +msgid "Bitte eine PNG- oder JPG-Datei hochladen." +msgstr "" + +#: workflows/upload_validation.py:145 +msgid "Die Signatur-Datei ist zu groß (max. 4 MB)." +msgstr "" + +#: workflows/upload_validation.py:146 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Die Signatur-Datei konnte nicht gelesen werden." +msgstr "Password could not be saved" + +#: workflows/views.py:125 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:129 +#: workflows/views.py:126 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:130 +#: workflows/views.py:127 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:131 +#: workflows/views.py:128 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:264 +#: workflows/views.py:132 +#, fuzzy +#| msgid "Deaktivieren" +msgid "ist aktiviert" +msgstr "Disabled" + +#: workflows/views.py:133 +msgid "ist gleich" +msgstr "" + +#: workflows/views.py:134 +msgid "ist nicht gleich" +msgstr "" + +#: workflows/views.py:279 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:266 +#: workflows/views.py:281 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:272 +#: workflows/views.py:287 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:274 +#: workflows/views.py:289 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:280 +#: workflows/views.py:295 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungseinstellungen gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:282 +#: workflows/views.py:297 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Benachrichtigungseinstellungen konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:291 +#: workflows/views.py:306 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP wurde aktiviert." msgstr "Disabled" -#: workflows/views.py:293 +#: workflows/views.py:308 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht aktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:300 +#: workflows/views.py:315 msgid "TOTP wurde deaktiviert." msgstr "" -#: workflows/views.py:302 +#: workflows/views.py:317 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht deaktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:311 +#: workflows/views.py:326 msgid "Recovery-Codes wurden neu erzeugt." msgstr "" -#: workflows/views.py:313 +#: workflows/views.py:328 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Recovery-Codes konnten nicht neu erzeugt werden." msgstr "Password could not be saved" -#: workflows/views.py:362 workflows/views.py:1569 workflows/views.py:1574 +#: workflows/views.py:377 workflows/views.py:1630 workflows/views.py:1635 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:443 +#: workflows/views.py:458 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:444 +#: workflows/views.py:459 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:445 +#: workflows/views.py:460 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:446 +#: workflows/views.py:461 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:447 +#: workflows/views.py:462 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:448 +#: workflows/views.py:463 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:449 +#: workflows/views.py:464 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:450 +#: workflows/views.py:465 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:451 +#: workflows/views.py:466 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:452 +#: workflows/views.py:467 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:453 +#: workflows/views.py:468 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:454 +#: workflows/views.py:469 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:455 +#: workflows/views.py:470 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:456 +#: workflows/views.py:471 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:457 +#: workflows/views.py:472 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:458 +#: workflows/views.py:473 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:459 +#: workflows/views.py:474 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:460 +#: workflows/views.py:475 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:461 +#: workflows/views.py:476 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:462 +#: workflows/views.py:477 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:463 +#: workflows/views.py:478 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:464 +#: workflows/views.py:479 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:465 +#: workflows/views.py:480 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:466 +#: workflows/views.py:481 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:467 +#: workflows/views.py:482 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:468 +#: workflows/views.py:483 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:469 +#: workflows/views.py:484 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:470 +#: workflows/views.py:485 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:471 +#: workflows/views.py:486 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:472 +#: workflows/views.py:487 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:473 +#: workflows/views.py:488 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:474 +#: workflows/views.py:489 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:475 +#: workflows/views.py:490 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:476 +#: workflows/views.py:491 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:477 +#: workflows/views.py:492 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:478 +#: workflows/views.py:493 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:479 +#: workflows/views.py:494 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:480 +#: workflows/views.py:495 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:481 +#: workflows/views.py:496 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:564 -#, fuzzy -#| msgid "Mitarbeiter" -msgid "Mitarbeitende" -msgstr "Staff" - -#: workflows/views.py:564 +#: workflows/views.py:579 #, fuzzy #| msgid "Person, Rolle, Abteilung" msgid "Person, Rolle und Bereich" msgstr "Person, role, department" -#: workflows/views.py:565 -msgid "Austritt" -msgstr "" - -#: workflows/views.py:565 +#: workflows/views.py:580 msgid "Letzter Arbeitstag" msgstr "" -#: workflows/views.py:566 +#: workflows/views.py:581 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Hinweise und Abschlussnotizen" msgstr "Introduction was saved as completed." -#: workflows/views.py:683 +#: workflows/views.py:744 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:782 +#: workflows/views.py:843 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:791 +#: workflows/views.py:852 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:793 +#: workflows/views.py:854 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4507,12 +4757,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:804 +#: workflows/views.py:865 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:806 +#: workflows/views.py:867 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4525,7 +4775,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:857 +#: workflows/views.py:918 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4533,69 +4783,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:885 +#: workflows/views.py:946 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:902 +#: workflows/views.py:963 msgid "Identität" msgstr "" -#: workflows/views.py:903 +#: workflows/views.py:964 msgid "Titel, Firmenname und zentrale Spracheinstellungen." msgstr "" -#: workflows/views.py:907 +#: workflows/views.py:968 msgid "" "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " "B. workdock.de." msgstr "" -#: workflows/views.py:912 +#: workflows/views.py:973 msgid "Farben & Erscheinungsbild" msgstr "" -#: workflows/views.py:913 +#: workflows/views.py:974 msgid "Zentrale visuelle Markenwerte und Browser-Icon." msgstr "" -#: workflows/views.py:917 +#: workflows/views.py:978 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/views.py:918 +#: workflows/views.py:979 msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." msgstr "" -#: workflows/views.py:923 +#: workflows/views.py:984 #, fuzzy #| msgid "Produktion" msgid "Kommunikation" msgstr "Production" -#: workflows/views.py:924 +#: workflows/views.py:985 msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." msgstr "" -#: workflows/views.py:928 +#: workflows/views.py:989 msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." msgstr "" -#: workflows/views.py:929 +#: workflows/views.py:990 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/views.py:934 +#: workflows/views.py:995 msgid "Footer & Rechtliches" msgstr "" -#: workflows/views.py:935 +#: workflows/views.py:996 msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." msgstr "" -#: workflows/views.py:989 +#: workflows/views.py:1050 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4604,53 +4854,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:1018 +#: workflows/views.py:1079 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:1035 +#: workflows/views.py:1096 #, fuzzy #| msgid "Firmenname" msgid "Firmenprofil" msgstr "Company name" -#: workflows/views.py:1036 +#: workflows/views.py:1097 msgid "Rechtlicher Name und zentrale Stammdaten der Firma." msgstr "" -#: workflows/views.py:1041 +#: workflows/views.py:1102 msgid "Adresse & Register" msgstr "" -#: workflows/views.py:1042 +#: workflows/views.py:1103 msgid "Anschrift sowie optionale Register- und Steuerangaben." msgstr "" -#: workflows/views.py:1047 +#: workflows/views.py:1108 msgid "Kontaktpunkte" msgstr "" -#: workflows/views.py:1048 +#: workflows/views.py:1109 msgid "Zentrale Ansprechpartner für HR, IT und Operations." msgstr "" -#: workflows/views.py:1053 +#: workflows/views.py:1114 msgid "Recht & Öffentlichkeit" msgstr "" -#: workflows/views.py:1054 +#: workflows/views.py:1115 msgid "Öffentliche Links für Website, Impressum und Datenschutz." msgstr "" -#: workflows/views.py:1056 +#: workflows/views.py:1117 msgid "" "Diese Links können später im Portal-Footer oder in öffentlichen Seiten " "verwendet werden." msgstr "" -#: workflows/views.py:1096 +#: workflows/views.py:1157 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4659,54 +4909,54 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:1128 +#: workflows/views.py:1189 #, fuzzy #| msgid "Trial abgelaufen" msgid "Trial ist abgelaufen" msgstr "Trial expired" -#: workflows/views.py:1129 +#: workflows/views.py:1190 msgid "" "Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt " "blockiert." msgstr "" -#: workflows/views.py:1137 +#: workflows/views.py:1198 msgid "Trial läuft bald ab" msgstr "" -#: workflows/views.py:1138 +#: workflows/views.py:1199 #, python-format msgid "Der Trial endet am %(date)s." msgstr "" -#: workflows/views.py:1146 +#: workflows/views.py:1207 #, fuzzy #| msgid "Trial-Modus" msgid "Trial-Modus deaktiviert" msgstr "Trial mode" -#: workflows/views.py:1147 +#: workflows/views.py:1208 #, fuzzy #| msgid "Nextcloud schalten" msgid "Der Trial-Modus wurde ausgeschaltet." msgstr "Toggle Nextcloud" -#: workflows/views.py:1152 +#: workflows/views.py:1213 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:1169 +#: workflows/views.py:1230 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:1182 +#: workflows/views.py:1243 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1204 +#: workflows/views.py:1265 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4717,14 +4967,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1207 +#: workflows/views.py:1268 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1210 +#: workflows/views.py:1271 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4735,7 +4985,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1213 +#: workflows/views.py:1274 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4746,18 +4996,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1230 +#: workflows/views.py:1291 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:1252 +#: workflows/views.py:1313 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1264 +#: workflows/views.py:1325 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4767,7 +5017,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1267 +#: workflows/views.py:1328 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4777,7 +5027,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1270 +#: workflows/views.py:1331 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4786,7 +5036,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1273 +#: workflows/views.py:1334 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4795,189 +5045,261 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1286 +#: workflows/views.py:1347 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1377 +#: workflows/views.py:1438 #, fuzzy, python-format #| msgid "Anfrage gespeichert" msgid "Backup erstellt: %(name)s" msgstr "Request saved" -#: workflows/views.py:1378 +#: workflows/views.py:1439 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Das Backup-Bundle wurde erfolgreich erstellt." msgstr "Save offboarding request" -#: workflows/views.py:1383 +#: workflows/views.py:1444 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:1393 +#: workflows/views.py:1454 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1411 +#: workflows/views.py:1472 #, fuzzy, python-format #| msgid "Backup wird verifiziert" msgid "Backup verifiziert: %(name)s" msgstr "Backup is being verified" -#: workflows/views.py:1412 +#: workflows/views.py:1473 #, fuzzy #| msgid "Backup wird verifiziert" msgid "Das Backup wurde erfolgreich verifiziert." msgstr "Backup is being verified" -#: workflows/views.py:1417 +#: workflows/views.py:1478 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1421 +#: workflows/views.py:1482 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Backup-Verifikation fehlgeschlagen" msgstr "Failed" -#: workflows/views.py:1427 +#: workflows/views.py:1488 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1443 +#: workflows/views.py:1504 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1445 +#: workflows/views.py:1506 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1471 +#: workflows/views.py:1532 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1473 +#: workflows/views.py:1534 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1485 +#: workflows/views.py:1546 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1495 +#: workflows/views.py:1556 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1497 +#: workflows/views.py:1558 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1506 +#: workflows/views.py:1567 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1532 +#: workflows/views.py:1593 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1583 +#: workflows/views.py:1644 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1626 +#: workflows/views.py:1687 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1628 +#: workflows/views.py:1689 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1630 +#: workflows/views.py:1691 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1863 +#: workflows/views.py:1926 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1880 +#: workflows/views.py:1943 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1909 +#: workflows/views.py:1972 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1923 +#: workflows/views.py:1986 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1936 +#: workflows/views.py:1999 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." -#: workflows/views.py:2907 +#: workflows/views.py:2560 +msgid "Visitenkarten-Details" +msgstr "" + +#: workflows/views.py:2561 +#, fuzzy +#| msgid "Vertragsbeginn" +msgid "Vertragsende" +msgstr "Contract start" + +#: workflows/views.py:2562 +#, fuzzy +#| msgid "Gruppenpostfach erklärt: %(item)s" +msgid "Gruppenpostfächer" +msgstr "Group mailbox explained: %(item)s" + +#: workflows/views.py:2563 +msgid "Zusätzliche Hardware" +msgstr "" + +#: workflows/views.py:2564 +msgid "Zusätzliche Software" +msgstr "" + +#: workflows/views.py:2565 +#, fuzzy +#| msgid "Zusätzlicher Zugang besprochen: %(item)s" +msgid "Zusätzliche Zugänge" +msgstr "Additional access discussed: %(item)s" + +#: workflows/views.py:2566 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Nachfolge" +msgstr "Save order" + +#: workflows/views.py:2567 +msgid "Direktwahl" +msgstr "" + +#: workflows/views.py:2570 +msgid "Steuert die Detailfelder für Visitenkarten." +msgstr "" + +#: workflows/views.py:2571 +msgid "Steuert das Enddatum bei befristeter Beschäftigung." +msgstr "" + +#: workflows/views.py:2572 +msgid "Steuert das Freitextfeld für Gruppenpostfächer." +msgstr "" + +#: workflows/views.py:2573 +msgid "Steuert zusätzliche Hardware-Felder." +msgstr "" + +#: workflows/views.py:2574 +msgid "Steuert zusätzliche Software-Felder." +msgstr "" + +#: workflows/views.py:2575 +msgid "Steuert zusätzliche Zugangsangaben." +msgstr "" + +#: workflows/views.py:2576 +msgid "Steuert Nachfolge- und Übernahmefelder." +msgstr "" + +#: workflows/views.py:2577 +msgid "Steuert die manuelle Direktwahl." +msgstr "" + +#: workflows/views.py:3171 #, fuzzy #| msgid "SMTP-Test starten" msgid "SMTP-Test erfolgreich" msgstr "Run SMTP test" -#: workflows/views.py:2908 +#: workflows/views.py:3172 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Die SMTP-Testmail wurde erfolgreich gesendet." msgstr "Save offboarding request" -#: workflows/views.py:2917 +#: workflows/views.py:3181 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test fehlgeschlagen" msgstr "SMTP test" -#: workflows/views.py:2923 +#: workflows/views.py:3187 #, fuzzy, python-format #| msgid "Passwort konnte nicht gespeichert werden" msgid "SMTP-Testmail konnte nicht gesendet werden: %(error)s" msgstr "Password could not be saved" -#: workflows/views.py:2948 +#: workflows/views.py:3212 #, fuzzy #| msgid "Nextcloud-Test starten" msgid "Nextcloud-Test erfolgreich" msgstr "Run Nextcloud test" -#: workflows/views.py:2949 +#: workflows/views.py:3213 msgid "Der Testupload nach Nextcloud war erfolgreich." msgstr "" -#: workflows/views.py:2959 workflows/views.py:2969 +#: workflows/views.py:3223 workflows/views.py:3233 #, fuzzy #| msgid "Nextcloud-Test starten" msgid "Nextcloud-Test fehlgeschlagen" msgstr "Run Nextcloud test" -#: workflows/views.py:2960 +#: workflows/views.py:3224 msgid "Der Testupload nach Nextcloud ist fehlgeschlagen." msgstr "" @@ -4998,24 +5320,11 @@ msgstr "" #~ msgid "Setup-Link" #~ msgstr "Setup Mail" -#~ msgid "Branding speichern" -#~ msgstr "Save branding" - #, fuzzy #~| msgid "Optionen speichern" #~ msgid "Firmenkonfiguration speichern" #~ msgstr "Save options" -#, fuzzy -#~| msgid "Aktion" -#~ msgid "Aktion DE" -#~ msgstr "Action" - -#, fuzzy -#~| msgid "Aktion" -#~ msgid "Aktion EN" -#~ msgstr "Action" - #, fuzzy #~| msgid "Produktion" #~ msgid "Product Owner" diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index fa2094a..43bb7a0 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -1,7 +1,9 @@ from collections import OrderedDict +from django import forms +from django.utils.text import slugify from django.utils.translation import get_language -from .models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig +from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig DEFAULT_FIELD_ORDER = { @@ -164,6 +166,8 @@ DEFAULT_CONDITIONAL_RULES = { }, } +CUSTOM_FIELD_PREFIX = 'custom__' + def get_section_order(form_type: str) -> list[str]: if form_type == 'onboarding': @@ -193,6 +197,94 @@ def get_default_conditional_rules(form_type: str) -> dict[str, dict]: return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) +def custom_field_target_key(field_key: str) -> str: + return f'custom__{field_key}' + + +def is_custom_field_target_key(target_key: str) -> bool: + return target_key.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_form_name(field_key: str) -> str: + return f'{CUSTOM_FIELD_PREFIX}{field_key}' + + +def is_custom_field_name(field_name: str) -> bool: + return field_name.startswith(CUSTOM_FIELD_PREFIX) + + +def custom_field_key_from_name(field_name: str) -> str: + return field_name[len(CUSTOM_FIELD_PREFIX):] if is_custom_field_name(field_name) else field_name + + +def build_custom_field_key(label: str) -> str: + return slugify(label).replace('-', '_')[:60] or 'custom_field' + + +def get_custom_field_configs(form_type: str, include_inactive: bool = False): + qs = FormCustomFieldConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'field_key')) + + +def add_custom_form_fields(form_type: str, form, initial_values: dict | None = None) -> None: + language_code = get_language() + initial_values = initial_values or {} + field_page_keys = getattr(form, '_field_page_keys', {}) + sort_map = {} + for cfg in get_custom_field_configs(form_type): + field_name = custom_field_form_name(cfg.field_key) + initial = initial_values.get(cfg.field_key) + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_TEXTAREA: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + widget=forms.Textarea(attrs={'rows': 3}), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT: + field = forms.ChoiceField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial or '', + choices=[('', '--')] + cfg.translated_select_options(language_code), + ) + elif cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: + field = forms.BooleanField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=False, + initial=bool(initial), + ) + else: + field = forms.CharField( + label=cfg.translated_label(language_code), + help_text=cfg.translated_help_text(language_code), + required=cfg.is_required, + initial=initial, + ) + form.fields[field_name] = field + field_page_keys[field_name] = cfg.section_key + sort_map[field_name] = cfg.sort_order + + if form.fields: + core_configs = ensure_form_field_configs(form_type, [name for name in form.fields.keys() if not is_custom_field_name(name)]) + for name in form.fields.keys(): + if is_custom_field_name(name): + continue + cfg = core_configs.get(name) + if cfg: + sort_map[name] = cfg.sort_order + form.fields = OrderedDict( + (name, form.fields[name]) + for name in sorted(form.fields.keys(), key=lambda name: (sort_map.get(name, 9999), name)) + ) + form._field_page_keys = field_page_keys + + def _default_sort(form_type: str, field_name: str) -> int: ordered = DEFAULT_FIELD_ORDER.get(form_type, []) if field_name in ordered: @@ -253,21 +345,28 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormConditionalRuleConfig]: defaults = get_default_conditional_rules(form_type) - if not defaults: + if form_type != 'onboarding' and not defaults: return {} + custom_targets = { + custom_field_target_key(cfg.field_key): {'clauses': []} + for cfg in get_custom_field_configs(form_type) + if form_type == 'onboarding' + } + target_defaults = dict(defaults) + target_defaults.update(custom_targets) existing = { cfg.target_key: cfg for cfg in FormConditionalRuleConfig.objects.filter(form_type=form_type) } - missing = [key for key in defaults.keys() if key not in existing] + missing = [key for key in target_defaults.keys() if key not in existing] if missing: FormConditionalRuleConfig.objects.bulk_create( [ FormConditionalRuleConfig( form_type=form_type, target_key=key, - clauses=defaults[key].get('clauses', []), - is_active=True, + clauses=target_defaults[key].get('clauses', []), + is_active=bool(target_defaults[key].get('clauses')), ) for key in missing ], @@ -280,6 +379,39 @@ def ensure_form_conditional_rule_configs(form_type: str) -> dict[str, FormCondit return existing +def evaluate_conditional_clauses(cleaned_data: dict, clauses: list[dict]) -> bool: + def clause_result(clause: dict) -> bool: + field_name = (clause.get('field') or '').strip() + operator = (clause.get('operator') or '').strip() + if not field_name or not operator: + return False + value = cleaned_data.get(field_name) + if operator == 'checked': + return bool(value) is bool(clause.get('value')) + normalized = '' if value is None else str(value).strip() + expected = '' if clause.get('value') is None else str(clause.get('value')).strip() + if operator == 'equals': + return normalized == expected + if operator == 'not_equals': + return normalized != expected + return False + + active_clauses = [clause for clause in (clauses or []) if clause.get('field') and clause.get('operator')] + return bool(active_clauses) and all(clause_result(clause) for clause in active_clauses) + + +def hidden_custom_field_names(form_type: str, cleaned_data: dict) -> set[str]: + if form_type != 'onboarding': + return set() + hidden = set() + for target_key, cfg in ensure_form_conditional_rule_configs(form_type).items(): + if not cfg.is_active or not is_custom_field_target_key(target_key): + continue + if not evaluate_conditional_clauses(cleaned_data, list(cfg.clauses or [])): + hidden.add(target_key) + return hidden + + def apply_form_field_config(form_type: str, form) -> None: field_names = list(form.fields.keys()) configs = _ensure_configs(form_type, field_names) diff --git a/backend/workflows/forms.py b/backend/workflows/forms.py index cd5a126..837ef1c 100644 --- a/backend/workflows/forms.py +++ b/backend/workflows/forms.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.utils.translation import get_language, gettext as _, gettext_lazy from .branding import get_company_email_domain -from .form_builder import apply_form_field_config +from .form_builder import add_custom_form_fields, apply_form_field_config, custom_field_key_from_name, hidden_custom_field_names, is_custom_field_name from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token @@ -814,6 +814,7 @@ class OnboardingRequestForm(forms.ModelForm): self.fields['needed_resources_multi'].choices = self._choices_from_options('resource', RESOURCE_CHOICES) self.fields['signature_image'].required = False apply_form_field_config('onboarding', self) + add_custom_form_fields('onboarding', self, getattr(self.instance, 'custom_field_values', None)) def clean_work_email(self): value = (self.cleaned_data.get('work_email') or '').strip().lower() @@ -883,6 +884,11 @@ class OnboardingRequestForm(forms.ModelForm): }, ) + hidden_custom = hidden_custom_field_names('onboarding', cleaned) + for field_name in hidden_custom: + cleaned[field_name] = False if self.fields.get(field_name) and self.fields[field_name].widget.input_type == 'checkbox' else '' + self._errors.pop(field_name, None) + return cleaned def save(self, commit=True): @@ -922,6 +928,11 @@ class OnboardingRequestForm(forms.ModelForm): instance.agreement = 'accepted' if self.cleaned_data.get('agreement_confirm') else '' instance.onboarded_by_email = self.requester_email + instance.custom_field_values = { + custom_field_key_from_name(name): self.cleaned_data.get(name) + for name in self.fields.keys() + if is_custom_field_name(name) + } if commit: instance.save() @@ -962,6 +973,7 @@ class OffboardingRequestForm(forms.ModelForm): self.fields['department'].initial = prefill_profile.department self.fields['job_title'].initial = prefill_profile.job_title apply_form_field_config('offboarding', self) + add_custom_form_fields('offboarding', self, getattr(self.instance, 'custom_field_values', None)) def clean_work_email(self): value = (self.cleaned_data.get('work_email') or '').strip().lower() @@ -971,3 +983,16 @@ class OffboardingRequestForm(forms.ModelForm): if self.email_domain and not value.endswith(expected_suffix): raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain}) return value + + def save(self, commit=True): + instance = super().save(commit=False) + if not (instance.preferred_language or '').strip(): + instance.preferred_language = (get_language() or 'de').split('-')[0] + instance.custom_field_values = { + custom_field_key_from_name(name): self.cleaned_data.get(name) + for name in self.fields.keys() + if is_custom_field_name(name) + } + if commit: + instance.save() + return instance diff --git a/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py b/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py new file mode 100644 index 0000000..c1cf8a1 --- /dev/null +++ b/backend/workflows/migrations/0055_offboardingrequest_custom_field_values_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 5.1.5 on 2026-03-27 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0054_formconditionalruleconfig'), + ] + + operations = [ + migrations.AddField( + model_name='offboardingrequest', + name='custom_field_values', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='onboardingrequest', + name='custom_field_values', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, choices=[('', 'Automatisch'), ('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], default='', max_length=20), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='form_type', + field=models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='section_key', + field=models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20), + ), + migrations.CreateModel( + name='FormCustomFieldConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')], max_length=20)), + ('field_key', models.SlugField(max_length=80)), + ('section_key', models.CharField(choices=[('stammdaten', 'Stammdaten'), ('vertrag', 'Vertrag'), ('itsetup', 'IT-Setup'), ('abschluss', 'Abschluss'), ('mitarbeitende', 'Mitarbeitende'), ('austritt', 'Austritt')], max_length=20)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('field_type', models.CharField(choices=[('text', 'Text'), ('textarea', 'Mehrzeilig'), ('select', 'Auswahl'), ('checkbox', 'Checkbox')], default='text', max_length=20)), + ('is_active', models.BooleanField(default=True)), + ('is_required', models.BooleanField(default=False)), + ('label', models.CharField(max_length=255)), + ('label_en', models.CharField(blank=True, max_length=255)), + ('help_text', models.TextField(blank=True)), + ('help_text_en', models.TextField(blank=True)), + ('select_options', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label')), + ('select_options_en', models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label')), + ], + options={ + 'verbose_name': 'Benutzerdefiniertes Formularfeld', + 'verbose_name_plural': 'Benutzerdefinierte Formularfelder', + 'ordering': ['form_type', 'section_key', 'sort_order', 'field_key'], + 'unique_together': {('form_type', 'field_key')}, + }, + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index 57ceb76..a406bab 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -422,6 +422,7 @@ class OnboardingRequest(models.Model): verbose_name='Personalisierter Text für PDF', help_text='Optionaler individueller Textblock im Onboarding PDF.', ) + custom_field_values = models.JSONField(default=dict, blank=True) generated_pdf_path = models.CharField(max_length=500, blank=True) intro_pdf_path = models.CharField(max_length=500, blank=True) @@ -566,6 +567,82 @@ class FormConditionalRuleConfig(models.Model): return f'{self.form_type}: {self.target_key}' +class FormCustomFieldConfig(models.Model): + FIELD_TYPE_TEXT = 'text' + FIELD_TYPE_TEXTAREA = 'textarea' + FIELD_TYPE_SELECT = 'select' + FIELD_TYPE_CHECKBOX = 'checkbox' + FIELD_TYPE_CHOICES = [ + (FIELD_TYPE_TEXT, _('Text')), + (FIELD_TYPE_TEXTAREA, _('Mehrzeilig')), + (FIELD_TYPE_SELECT, _('Auswahl')), + (FIELD_TYPE_CHECKBOX, _('Checkbox')), + ] + FORM_CHOICES = [ + ('onboarding', _('Onboarding')), + ('offboarding', _('Offboarding')), + ] + SECTION_CHOICES = [ + ('stammdaten', _('Stammdaten')), + ('vertrag', _('Vertrag')), + ('itsetup', _('IT-Setup')), + ('abschluss', _('Abschluss')), + ('mitarbeitende', _('Mitarbeitende')), + ('austritt', _('Austritt')), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + field_key = models.SlugField(max_length=80) + section_key = models.CharField(max_length=20, choices=SECTION_CHOICES) + sort_order = models.PositiveIntegerField(default=0) + field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES, default=FIELD_TYPE_TEXT) + is_active = models.BooleanField(default=True) + is_required = models.BooleanField(default=False) + label = models.CharField(max_length=255) + label_en = models.CharField(max_length=255, blank=True) + help_text = models.TextField(blank=True) + help_text_en = models.TextField(blank=True) + select_options = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: wert|Label') + select_options_en = models.TextField(blank=True, help_text='Eine Option pro Zeile. Optional: value|Label') + + class Meta: + ordering = ['form_type', 'section_key', 'sort_order', 'field_key'] + unique_together = ('form_type', 'field_key') + verbose_name = 'Benutzerdefiniertes Formularfeld' + verbose_name_plural = 'Benutzerdefinierte Formularfelder' + + def __str__(self) -> str: + return f'{self.form_type}: {self.label}' + + def translated_label(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.label_en.strip(): + return self.label_en.strip() + return self.label.strip() + + def translated_help_text(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.help_text_en.strip(): + return self.help_text_en.strip() + return self.help_text.strip() + + def translated_select_options(self, language_code: str | None = None) -> list[tuple[str, str]]: + lang = (language_code or get_language() or 'de').split('-')[0] + raw = self.select_options_en if lang == 'en' and self.select_options_en.strip() else self.select_options + options = [] + for line in (raw or '').splitlines(): + line = line.strip() + if not line: + continue + if '|' in line: + value, label = [part.strip() for part in line.split('|', 1)] + else: + value = label = line + if value: + options.append((value, label or value)) + return options + + class NotificationTemplate(models.Model): TEMPLATE_CHOICES = [ ('onboarding_it', _('Onboarding: IT')), @@ -844,6 +921,7 @@ class OffboardingRequest(models.Model): requested_by_name = models.CharField(max_length=255, blank=True, verbose_name='Name der anfordernden Person') preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de') generated_pdf_path = models.CharField(max_length=500, blank=True) + custom_field_values = models.JSONField(default=dict, blank=True) processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') last_error = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/workflows/pdf_sections.py b/backend/workflows/pdf_sections.py index 948fc84..cff2221 100644 --- a/backend/workflows/pdf_sections.py +++ b/backend/workflows/pdf_sections.py @@ -9,6 +9,7 @@ from django.utils.translation import override from .form_builder import ( LOCKED_FIELD_RULES, LOCKED_SECTION_RULES, + get_custom_field_configs, ensure_form_field_configs, ensure_form_section_configs, get_default_page_map, @@ -16,6 +17,7 @@ from .form_builder import ( get_section_order, ) from .forms import OffboardingRequestForm, OnboardingRequestForm +from .models import FormCustomFieldConfig PDF_SECTION_TITLES = { "onboarding": { @@ -251,6 +253,25 @@ def build_pdf_sections(form_type: str, request_obj, language_code: str | None = } ) + custom_values = getattr(request_obj, 'custom_field_values', {}) or {} + for cfg in get_custom_field_configs(form_type): + if cfg.section_key not in sections: + continue + raw_value = custom_values.get(cfg.field_key) + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_CHECKBOX: + raw_value = _yes_no_text(language_code)[0] if raw_value else '' + sections[cfg.section_key]["fields"].append( + { + "name": f"custom__{cfg.field_key}", + "label": cfg.translated_label(language_code), + "help_text": cfg.translated_help_text(language_code), + "kind": _field_kind(raw_value), + "value": raw_value, + "is_empty": _is_empty_value(raw_value), + "is_locked": False, + } + ) + not_available = _not_available_text(language_code) result = [] for section in sections.values(): diff --git a/backend/workflows/static/workflows/css/home.css b/backend/workflows/static/workflows/css/home.css index 11817de..af00405 100644 --- a/backend/workflows/static/workflows/css/home.css +++ b/backend/workflows/static/workflows/css/home.css @@ -149,6 +149,114 @@ line-height: 1.55; } + .ops-overview-card { + margin-bottom: 20px; + border: 1px solid var(--line); + border-radius: 18px; + background: + radial-gradient(120% 120% at 100% 0%, rgba(0, 0, 120, 0.08), rgba(0, 0, 120, 0)), + linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); + padding: 18px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); + } + + .ops-overview-head h2 { + margin: 0; + color: #17345e; + font-size: 20px; + } + + .ops-overview-head p { + margin: 4px 0 0; + color: var(--muted); + font-size: 14px; + } + + .ops-overview-grid { + display: grid; + grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: 12px; + margin-top: 14px; + } + + .ops-stat-card { + border: 1px solid #dce6f2; + border-radius: 16px; + background: rgba(255,255,255,0.86); + padding: 14px; + display: grid; + gap: 6px; + } + + .ops-stat-label { + color: #60738d; + font-size: 12px; + font-weight: 700; + } + + .ops-stat-card strong { + color: #17345e; + font-size: 22px; + line-height: 1.1; + } + + .ops-stat-card strong.is-error { + color: #a32020; + } + + .ops-failure-list { + margin-top: 16px; + display: grid; + gap: 10px; + } + + .ops-failure-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .ops-failure-head h3 { + margin: 0; + color: #17345e; + font-size: 16px; + } + + .ops-failure-items { + display: grid; + gap: 10px; + } + + .ops-failure-item { + border: 1px solid #ead9d9; + border-radius: 14px; + background: rgba(255,248,248,0.92); + padding: 12px; + display: grid; + gap: 4px; + } + + .ops-failure-item strong { + color: #7f1d1d; + font-size: 14px; + } + + .ops-failure-item span { + color: #6f5b5b; + font-size: 13px; + } + + .ops-failure-item code { + color: #6a1f1f; + background: rgba(255,255,255,0.6); + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + overflow-wrap: anywhere; + } + .status-row { margin-top: 16px; display: flex; @@ -605,6 +713,7 @@ @media (max-width: 1080px) { .hero-grid { grid-template-columns: 1fr; } + .ops-overview-grid { grid-template-columns: 1fr 1fr; } .apps-grid { grid-template-columns: 1fr 1fr; } .admin-grid { grid-template-columns: 1fr 1fr; } } diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index f14536a..b032696 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -51,6 +51,10 @@ {% trans "Aktuell ausgeblendet" %} {{ builder_summary.hidden_field_count }} +
    + {% trans "Eigene Felder" %} + {{ builder_summary.custom_field_count }} +
    {% if form_type == 'onboarding' %}
    {% trans "Versteckte Abschnitte" %} @@ -129,6 +133,7 @@
    {{ item.field_name }}
    + {% if item.is_custom %}{% trans "Eigen" %}{% endif %} {% if item.locked %}{% trans "Fix" %}{% endif %} {% if not item.is_visible %}{% endif %} {% if item.is_required %}{% trans "Pflicht" %}{% endif %} @@ -451,22 +456,134 @@
    + +
    + +
    +

    {% trans "Eigene Felder" %}

    + {% trans "Öffnen" %} +
    +
    +
    +
    + {% csrf_token %} + + + + + + + + + + + + + + +
    + {% csrf_token %} +
    +
    + + + + + + + + + + + + + + + {% for group in custom_field_groups %} + + + + {% for item in group.items %} + + + + + + + + + + + + {% empty %} + + {% endfor %} + {% endfor %} + +
    {% trans "Schlüssel" %}{% trans "Abschnitt" %}{% trans "Typ" %}{% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Pflicht" %}{% trans "Aktiv" %}{% trans "Löschen" %}
    {{ group.title }}
    + + {{ item.field_key }} + + + + + + + + + + + + + + +
    {% trans "Keine eigenen Felder vorhanden." %}
    +
+
+ +
+ + + - + + }); + })(); + {% endblock %} diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index 6329e7e..0aaa03b 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -37,6 +37,57 @@
{% include 'workflows/includes/messages.html' %} + {% if ops_summary.show %} +
+
+
+

{% trans "Operations Overview" %}

+

{% trans "Letzte Laufzeit- und Backup-Signale auf einen Blick." %}

+
+
+
+ {% if ops_summary.can_view_jobs %} +
+ {% trans "Fehlgeschlagene Jobs (24h)" %} + {{ ops_summary.failed_count_24h }} +
+
+ {% trans "Erfolgreiche Jobs (24h)" %} + {{ ops_summary.success_count_24h }} +
+
+ {% trans "Offene Starts (24h)" %} + {{ ops_summary.started_count_24h }} +
+ {% endif %} + {% if ops_summary.can_manage_backups and ops_summary.backup_health %} +
+ {% trans "Backup-Status" %} + {{ ops_summary.backup_health.label }} + {{ ops_summary.backup_health.summary }} +
+ {% endif %} +
+ {% if ops_summary.can_view_jobs and ops_summary.recent_failed_logs %} +
+
+

{% trans "Letzte Fehler" %}

+ {% trans "Job Monitor öffnen" %} +
+
+ {% for log in ops_summary.recent_failed_logs %} +
+ {{ log.task_name }} + {{ log.target_label|default:log.target_type }} + {{ log.error_message|truncatechars:120 }} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + {% for section in portal_app_sections %} {% if not forloop.first %} diff --git a/backend/workflows/templates/workflows/job_monitor.html b/backend/workflows/templates/workflows/job_monitor.html index 3828049..6e32a7b 100644 --- a/backend/workflows/templates/workflows/job_monitor.html +++ b/backend/workflows/templates/workflows/job_monitor.html @@ -14,6 +14,45 @@ {% include 'workflows/includes/messages.html' %} +
+
+
+ +
{{ job_summary.failed_count_24h }}
+
+
+ +
{{ job_summary.success_count_24h }}
+
+
+ +
{{ job_summary.started_count_24h }}
+
+
+ {% if job_summary.recent_failed %} +
+ + + + + + + + + + {% for log in job_summary.recent_failed %} + + + + + + {% endfor %} + +
{% trans "Zuletzt fehlgeschlagen" %}{% trans "Ziel" %}{% trans "Fehler" %}
{{ log.task_name }}{{ log.target_label|default:log.target_type }}{{ log.error_message|truncatechars:140 }}
+
+ {% endif %} +
+
diff --git a/backend/workflows/templates/workflows/offboarding_form.html b/backend/workflows/templates/workflows/offboarding_form.html index 489ea0d..300e96c 100644 --- a/backend/workflows/templates/workflows/offboarding_form.html +++ b/backend/workflows/templates/workflows/offboarding_form.html @@ -60,12 +60,20 @@
{% for field in section.fields %} + {% if field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
+ {% else %}
{{ field.label_tag }} {{ field }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} {{ field.errors }}
+ {% endif %} {% endfor %}
diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 121cc05..0fdfb48 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -59,7 +59,7 @@ {% with field=block.field %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks %} + {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
{{ field }} {{ field.label_tag }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} @@ -102,7 +102,7 @@ {% for field in block.fields %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks %} + {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %}
{{ field }} {{ field.label_tag }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} diff --git a/backend/workflows/templates/workflows/request_timeline.html b/backend/workflows/templates/workflows/request_timeline.html index a0c0dc0..c59c416 100644 --- a/backend/workflows/templates/workflows/request_timeline.html +++ b/backend/workflows/templates/workflows/request_timeline.html @@ -37,9 +37,15 @@ .timeline-detail-row { display:grid; grid-template-columns:160px 1fr; gap:12px; font-size:13px; } .timeline-detail-row strong { color:#566886; } .timeline-detail-list { margin:0; padding-left:18px; color:#4f617f; } +.timeline-custom-fields { margin: 0 0 20px; padding: 18px 20px; border: 1px solid #d9e3f8; border-radius: 20px; background: linear-gradient(180deg,#ffffff 0%,#f7faff 100%); box-shadow: 0 18px 40px rgba(23,39,90,.08); } +.timeline-custom-fields h2 { margin: 0 0 14px; font-size: 18px; color: #20345f; } +.timeline-custom-grid { display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 16px; } +.timeline-custom-item { padding: 12px 14px; border: 1px solid #d8e1f5; border-radius: 16px; background: #fff; } +.timeline-custom-item strong { display:block; margin-bottom: 4px; color:#566886; font-size:12px; letter-spacing:.05em; text-transform:uppercase; } +.timeline-custom-item span { color:#22324d; font-size:14px; line-height:1.45; } @media (max-width: 1160px) { .timeline-summary-grid { grid-template-columns:repeat(3, minmax(0,1fr)); } } @media (max-width: 820px) { .timeline-summary-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } } -@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } } +@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-custom-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } } {% endblock %} @@ -80,6 +86,20 @@
+ {% if custom_field_details %} +
+

{% trans "Benutzerdefinierte Felder" %}

+
+ {% for item in custom_field_details %} +
+ {{ item.label }} + {{ item.value }} +
+ {% endfor %} +
+
+ {% endif %} +
{% for row in timeline_rows %}
diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index 69ee28e..3620174 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,7 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig class FormBuilderAdminTests(TestCase): @@ -202,3 +202,59 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(len(rule.clauses), 2) self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice') self.assertEqual(rule.clauses[1]['operator'], 'not_equals') + + def test_staff_can_add_custom_field(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'add_custom_field', + 'custom_label': 'Laptop-Tag', + 'custom_label_en': 'Laptop tag', + 'custom_section_key': 'itsetup', + 'custom_field_type': 'text', + 'custom_sort_order': '3', + 'custom_is_required': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + field = FormCustomFieldConfig.objects.get(form_type='onboarding', field_key='laptop_tag') + self.assertEqual(field.section_key, 'itsetup') + self.assertEqual(field.field_type, 'text') + self.assertEqual(field.is_required, True) + + def test_save_order_updates_custom_field_section_and_sort_order(self): + self.client.force_login(self.staff) + custom_field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='laptop_tag', + section_key='itsetup', + sort_order=99, + field_type='text', + label='Laptop-Tag', + ) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department'], + 'vertrag': ['contract_start'], + 'itsetup': ['custom__laptop_tag'], + 'abschluss': [], + }, + } + + response = self.client.post( + '/admin-tools/form-builder/save-order/', + data=json.dumps(payload), + content_type='application/json', + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 200) + custom_field.refresh_from_db() + self.assertEqual(custom_field.section_key, 'itsetup') + self.assertEqual(custom_field.sort_order, 2) diff --git a/backend/workflows/tests/test_observability_ui.py b/backend/workflows/tests/test_observability_ui.py new file mode 100644 index 0000000..5c80698 --- /dev/null +++ b/backend/workflows/tests/test_observability_ui.py @@ -0,0 +1,123 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.utils import timezone + +from workflows.models import AsyncTaskLog +from workflows.roles import ROLE_ADMIN, ROLE_STAFF, assign_user_role + + +class ObservabilityUITests(TestCase): + def setUp(self): + user_model = get_user_model() + self.admin = user_model.objects.create_user( + username='ops_admin', + email='ops-admin@example.com', + password='secret123', + ) + assign_user_role(self.admin, ROLE_ADMIN) + + self.staff = user_model.objects.create_user( + username='ops_staff', + email='ops-staff@example.com', + password='secret123', + ) + assign_user_role(self.staff, ROLE_STAFF) + + def _create_log(self, *, status: str, task_name: str, target_label: str, error_message: str = '') -> AsyncTaskLog: + log = AsyncTaskLog.objects.create( + task_name=task_name, + status=status, + target_type='request', + target_id=1, + target_label=target_label, + error_message=error_message, + ) + AsyncTaskLog.objects.filter(id=log.id).update( + started_at=timezone.now() - timedelta(hours=2), + finished_at=timezone.now() - timedelta(hours=1, minutes=45), + ) + return AsyncTaskLog.objects.get(id=log.id) + + def test_home_shows_operations_overview_for_admin(self): + self._create_log( + status='failed', + task_name='send_scheduled_welcome_email', + target_label='Request A', + error_message='smtp failed hard', + ) + self._create_log( + status='succeeded', + task_name='process_onboarding_request', + target_label='Request B', + ) + self._create_log( + status='started', + task_name='process_offboarding_request', + target_label='Request C', + ) + + client = Client() + client.force_login(self.admin) + response = client.get('/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Operations Overview') + self.assertContains(response, 'Fehlgeschlagene Jobs (24h)') + self.assertContains(response, '1', html=True) + self.assertContains(response, 'send_scheduled_welcome_email') + self.assertContains(response, 'Backup-Status') + + def test_home_hides_operations_overview_for_staff(self): + self._create_log( + status='failed', + task_name='process_onboarding_request', + target_label='Request A', + error_message='pdf failed', + ) + + client = Client() + client.force_login(self.staff) + response = client.get('/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'Operations Overview') + self.assertNotContains(response, 'Job Monitor öffnen') + + def test_job_monitor_summary_shows_recent_counts(self): + self._create_log( + status='failed', + task_name='process_onboarding_request', + target_label='Request A', + error_message='pdf failed', + ) + self._create_log( + status='succeeded', + task_name='process_offboarding_request', + target_label='Request B', + ) + self._create_log( + status='started', + task_name='send_scheduled_welcome_email', + target_label='Request C', + ) + + client = Client() + client.force_login(self.admin) + response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Fehlgeschlagene Jobs (24h)') + self.assertContains(response, 'Erfolgreiche Jobs (24h)') + self.assertContains(response, 'Offene Starts (24h)') + self.assertContains(response, 'Zuletzt fehlgeschlagen') + self.assertContains(response, 'pdf failed') + + def test_job_monitor_requires_capability(self): + client = Client() + client.force_login(self.staff) + + response = client.get('/admin-tools/jobs/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 302) diff --git a/backend/workflows/tests/test_offboarding_flow.py b/backend/workflows/tests/test_offboarding_flow.py index 6306a0b..df83b5f 100644 --- a/backend/workflows/tests/test_offboarding_flow.py +++ b/backend/workflows/tests/test_offboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import EmployeeProfile, OffboardingRequest +from workflows.models import EmployeeProfile, FormCustomFieldConfig, OffboardingRequest class OffboardingFlowTests(TestCase): @@ -59,3 +59,37 @@ class OffboardingFlowTests(TestCase): self.assertEqual(obj.requested_by_email, f'operator@{self.company_domain}') self.assertEqual(obj.requested_by_name, 'Nina Admin') mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_offboarding_request.delay') + def test_offboarding_custom_field_is_saved(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='offboarding', + field_key='return_comment', + section_key='abschluss', + sort_order=0, + field_type='textarea', + is_active=True, + is_required=False, + label='Rückgabehinweis', + ) + + payload = { + 'full_name': self.profile.full_name, + 'work_email': self.profile.work_email, + 'department': self.profile.department, + 'job_title': self.profile.job_title, + 'last_working_day': '2026-12-31', + 'notes': 'Bitte Accounts sperren.', + 'custom__return_comment': 'Abholung durch IT am Freitag.', + } + + response = self.client.post( + f'/offboarding/new/?profile={self.profile.id}', + payload, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + obj = OffboardingRequest.objects.get(work_email=self.profile.work_email) + self.assertEqual(obj.custom_field_values, {'return_comment': 'Abholung durch IT am Freitag.'}) + mock_delay.assert_called_once_with(obj.id) diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index c43bad6..8cad82c 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import FormConditionalRuleConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): @@ -171,3 +171,168 @@ class OnboardingFlowTests(TestCase): self.assertEqual(response.status_code, 200) self.assertIn('employment-end-box', html) self.assertIn('"value": "unbefristet"', html) + + def test_onboarding_custom_field_uses_combined_order(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=1, + field_type='text', + is_active=True, + label='Bürostandort', + ) + FormFieldConfig.objects.update_or_create( + form_type='onboarding', + field_name='gender', + defaults={'sort_order': 2, 'page_key': 'stammdaten'}, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertLess(html.index('Bürostandort'), html.index('Anrede')) + + @patch('workflows.views.process_onboarding_request.delay') + def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Bürostandort', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + self.assertContains(response, 'Bürostandort') + + payload = { + 'first_name': 'Mara', + 'last_name': 'Muster', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'mara.muster@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'custom__office_location': 'Berlin Mitte', + 'agreement_confirm': 'on', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + obj = OnboardingRequest.objects.get(work_email=f'mara.muster@{self.company_domain}') + self.assertEqual(obj.custom_field_values, {'office_location': 'Berlin Mitte'}) + mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_hidden_required_custom_field_does_not_block_submission(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='visitor_badge_name', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Besucherausweis', + ) + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='custom__visitor_badge_name', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + self.assertIn('custom__visitor_badge_name', html) + self.assertIn('"custom__visitor_badge_name"', html) + + payload = { + 'first_name': 'Lea', + 'last_name': 'Leicht', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'lea.leicht@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'agreement_confirm': 'on', + } + + submit_response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(submit_response.status_code, 302) + obj = OnboardingRequest.objects.get(work_email=f'lea.leicht@{self.company_domain}') + self.assertEqual(obj.custom_field_values, {'visitor_badge_name': ''}) + mock_delay.assert_called_once_with(obj.id) + + @patch('workflows.views.process_onboarding_request.delay') + def test_visible_required_custom_field_blocks_submission(self, mock_delay): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='visitor_badge_name', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + is_required=True, + label='Besucherausweis', + ) + FormConditionalRuleConfig.objects.update_or_create( + form_type='onboarding', + target_key='custom__visitor_badge_name', + defaults={ + 'is_active': True, + 'clauses': [{'field': 'order_business_cards', 'operator': 'checked', 'value': True}], + }, + ) + + payload = { + 'first_name': 'Lia', + 'last_name': 'Laut', + 'gender': 'frau', + 'job_title': 'Consultant', + 'department': 'IT-Service', + 'work_email': f'lia.laut@{self.company_domain}', + 'contract_start': '2026-11-01', + 'employment_type': 'unbefristet', + 'order_business_cards': 'on', + 'business_card_name': 'Lia Laut', + 'business_card_title': 'Consultant', + 'business_card_email': f'lia.laut@{self.company_domain}', + 'business_card_phone': '030 123456', + 'group_mailboxes_required_choice': 'nein', + 'additional_hardware_needed_choice': 'nein', + 'additional_software_needed_choice': 'nein', + 'additional_access_needed_choice': 'nein', + 'successor_required_choice': 'nein', + 'inherit_phone_number_choice': 'nein', + 'agreement_confirm': 'on', + } + + response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Besucherausweis') + self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lia.laut@{self.company_domain}').exists()) + mock_delay.assert_not_called() diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py index 9d4321e..80ee418 100644 --- a/backend/workflows/tests/test_pdf_sections.py +++ b/backend/workflows/tests/test_pdf_sections.py @@ -1,6 +1,6 @@ from django.test import TestCase -from workflows.models import FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.models import FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest from workflows.pdf_sections import build_pdf_sections @@ -94,3 +94,32 @@ class PDFSectionBuilderTests(TestCase): self.assertIn('last_working_day', [field['name'] for field in austritt['fields']]) date_field = next(field for field in austritt['fields'] if field['name'] == 'last_working_day') self.assertTrue(date_field['display_value']) + + def test_custom_fields_are_included_in_pdf_sections(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + label='Bürostandort', + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'office_location': 'Berlin Mitte'}, + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + stammdaten = next(section for section in sections if section['key'] == 'stammdaten') + custom_field = next(field for field in stammdaten['fields'] if field['name'] == 'custom__office_location') + + self.assertEqual(custom_field['label'], 'Bürostandort') + self.assertEqual(custom_field['display_value'], 'Berlin Mitte') diff --git a/backend/workflows/tests/test_request_timeline.py b/backend/workflows/tests/test_request_timeline.py new file mode 100644 index 0000000..389e3be --- /dev/null +++ b/backend/workflows/tests/test_request_timeline.py @@ -0,0 +1,73 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from workflows.models import FormCustomFieldConfig, OffboardingRequest, OnboardingRequest +from workflows.roles import ROLE_ADMIN, assign_user_role + + +class RequestTimelineCustomFieldTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + username='timeline_admin', + email='timeline-admin@example.com', + password='secret123', + ) + assign_user_role(self.user, ROLE_ADMIN) + self.client.force_login(self.user) + + def test_onboarding_timeline_renders_custom_field_values(self): + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='office_location', + section_key='stammdaten', + sort_order=0, + field_type='text', + is_active=True, + label='Bürostandort', + ) + obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'office_location': 'Berlin Mitte'}, + ) + + response = self.client.get(f'/requests/timeline/onboarding/{obj.id}/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Benutzerdefinierte Felder') + self.assertContains(response, 'Bürostandort') + self.assertContains(response, 'Berlin Mitte') + + def test_offboarding_timeline_renders_custom_field_values(self): + FormCustomFieldConfig.objects.create( + form_type='offboarding', + field_key='return_comment', + section_key='abschluss', + sort_order=0, + field_type='textarea', + is_active=True, + label='Rückgabehinweis', + ) + obj = OffboardingRequest.objects.create( + full_name='Lara Beispiel', + work_email='lara.beispiel@workdock.de', + department='IT-Service', + job_title='Engineer', + last_working_day='2026-12-31', + requested_by_email='admin@workdock.de', + custom_field_values={'return_comment': 'Abholung durch IT am Freitag.'}, + ) + + response = self.client.get(f'/requests/timeline/offboarding/{obj.id}/', HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Benutzerdefinierte Felder') + self.assertContains(response, 'Rückgabehinweis') + self.assertContains(response, 'Abholung durch IT am Freitag.') diff --git a/backend/workflows/views.py b/backend/workflows/views.py index e44bbc7..ad2422d 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -10,6 +10,7 @@ from django.conf import settings from django.db import connection from django.db import IntegrityError from django.db.models import Q +from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.contrib import messages from django.contrib.auth import get_user_model, login as auth_login @@ -46,15 +47,18 @@ from .form_builder import ( ONBOARDING_DEFAULT_PAGE, ONBOARDING_PAGE_LABELS, ONBOARDING_PAGE_ORDER, + build_custom_field_key, + custom_field_target_key, ensure_form_field_configs, ensure_form_conditional_rule_configs, ensure_form_section_configs, + get_custom_field_configs, get_default_page_map, get_section_labels, get_section_order, apply_form_preset, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email from .notifications import notify_user from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability @@ -105,8 +109,6 @@ ONBOARDING_GROUPS = { 'phone-box': ['phone_number_choice'], } -ONBOARDING_HIDDEN_BY_DEFAULT = set(DEFAULT_CONDITIONAL_RULES.get('onboarding', {}).keys()) - ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} ONBOARDING_CHECKBOX_LISTS = { 'needed_devices_multi', @@ -144,6 +146,10 @@ def _normalized_conditional_rule_payload(form_type: str) -> dict[str, dict]: return payload +def _active_conditional_target_keys(form_type: str) -> set[str]: + return set(_normalized_conditional_rule_payload(form_type).keys()) + + def healthz(request): db_ok = True try: @@ -450,6 +456,36 @@ def _request_status_label(status_key: str, language_code: str | None = None) -> return labels.get(status_key, status_key) +def _request_custom_field_details(obj, kind: str, language_code: str | None = None) -> list[dict[str, str]]: + form_type = 'onboarding' if kind == 'onboarding' else 'offboarding' + language_code = ((language_code or getattr(obj, 'preferred_language', '') or get_language() or 'de').split('-')[0]).lower() + values = getattr(obj, 'custom_field_values', {}) or {} + rows = [] + yes_label = 'Ja' if language_code == 'de' else 'Yes' + for cfg in get_custom_field_configs(form_type, include_inactive=True): + raw_value = values.get(cfg.field_key) + if raw_value in (None, '', False, []): + continue + if isinstance(raw_value, bool): + display_value = str(yes_label) if raw_value else '' + elif isinstance(raw_value, list): + display_value = ', '.join(str(item).strip() for item in raw_value if str(item).strip()) + else: + display_value = str(raw_value).strip() + if not display_value: + continue + rows.append( + { + 'label': cfg.translated_label(language_code), + 'value': display_value, + 'section': cfg.section_key, + 'sort_order': cfg.sort_order, + } + ) + rows.sort(key=lambda item: (item['section'], item['sort_order'], item['label'])) + return rows + + def _audit_action_label(action: str) -> str: labels = { 'requests_deleted': _('Vorgänge gelöscht'), @@ -505,6 +541,7 @@ def _build_onboarding_layout(form) -> list[dict]: for group_id, group_fields in ONBOARDING_GROUPS.items(): for name in group_fields: group_by_field[name] = group_id + conditional_target_keys = _active_conditional_target_keys('onboarding') rendered_groups = set() consumed = set() @@ -529,7 +566,7 @@ def _build_onboarding_layout(form) -> list[dict]: { 'kind': 'group', 'id': group_id, - 'hidden_default': group_id in ONBOARDING_HIDDEN_BY_DEFAULT, + 'hidden_default': group_id in conditional_target_keys, 'fields': group_fields, } ) @@ -537,6 +574,18 @@ def _build_onboarding_layout(form) -> list[dict]: consumed.update([f.name for f in group_fields]) continue + if field_name.startswith('custom__') and field_name in conditional_target_keys: + blocks.append( + { + 'kind': 'group', + 'id': field_name, + 'hidden_default': True, + 'fields': [form[field_name]], + } + ) + consumed.add(field_name) + continue + blocks.append({'kind': 'field', 'field': form[field_name]}) consumed.add(field_name) @@ -600,10 +649,42 @@ def _build_offboarding_sections(form, visible_section_keys: set[str] | None = No ] +def _ops_summary_for_user(user) -> dict[str, object]: + can_view_jobs = user_has_capability(user, 'view_job_monitor') + can_manage_backups = user_has_capability(user, 'manage_backups') + summary: dict[str, object] = { + 'show': can_view_jobs or can_manage_backups, + 'can_view_jobs': can_view_jobs, + 'can_manage_backups': can_manage_backups, + 'failed_count_24h': 0, + 'started_count_24h': 0, + 'success_count_24h': 0, + 'recent_failed_logs': [], + 'backup_health': latest_backup_health_snapshot() if can_manage_backups else None, + } + if not can_view_jobs: + return summary + + since = timezone.now() - timedelta(hours=24) + logs = AsyncTaskLog.objects.filter(started_at__gte=since) + counts = { + row['status']: row['count'] + for row in logs.values('status').annotate(count=Count('id')) + } + summary['failed_count_24h'] = counts.get('failed', 0) + summary['started_count_24h'] = counts.get('started', 0) + summary['success_count_24h'] = counts.get('succeeded', 0) + summary['recent_failed_logs'] = list( + AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5] + ) + return summary + + @login_required def home(request): config, _ = WorkflowConfig.objects.get_or_create(name='Default') role_key = get_user_role_key(request.user) + ops_summary = _ops_summary_for_user(request.user) return render( request, 'workflows/home.html', @@ -614,6 +695,7 @@ def home(request): 'role_label': get_user_role_label(request.user), 'role_key': role_key, 'portal_app_sections': build_portal_app_sections(request.user), + 'ops_summary': ops_summary, }, ) @@ -641,6 +723,13 @@ def job_monitor_page(request): logs = logs.filter(task_name=task_filter) logs = logs.order_by('-started_at', '-id')[:200] task_names = list(AsyncTaskLog.objects.order_by('task_name').values_list('task_name', flat=True).distinct()) + since = timezone.now() - timedelta(hours=24) + recent_logs = AsyncTaskLog.objects.filter(started_at__gte=since) + counts = { + row['status']: row['count'] + for row in recent_logs.values('status').annotate(count=Count('id')) + } + recent_failed = list(AsyncTaskLog.objects.filter(status='failed').order_by('-started_at', '-id')[:5]) return render( request, 'workflows/job_monitor.html', @@ -650,6 +739,12 @@ def job_monitor_page(request): 'task_filter': task_filter, 'task_names': task_names, 'status_choices': [('started', _('Gestartet')), ('succeeded', _('Erfolgreich')), ('failed', _('Fehlgeschlagen'))], + 'job_summary': { + 'started_count_24h': counts.get('started', 0), + 'success_count_24h': counts.get('succeeded', 0), + 'failed_count_24h': counts.get('failed', 0), + 'recent_failed': recent_failed, + }, }, ) @@ -1469,6 +1564,7 @@ def request_timeline_page(request, kind: str, request_id: int): return redirect('requests_dashboard') request_label = _request_target_label(obj, kind) + custom_field_details = _request_custom_field_details(obj, kind, getattr(request, 'LANGUAGE_CODE', None)) audit_rows = list( AdminAuditLog.objects.select_related('actor') .filter(target_type__in=[kind, 'request']) @@ -1483,6 +1579,7 @@ def request_timeline_page(request, kind: str, request_id: int): 'title': _('Anfrage erstellt'), 'summary': request_label, 'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()}, + 'details': {item['label']: item['value'] for item in custom_field_details}, } ] @@ -1569,6 +1666,7 @@ def request_timeline_page(request, kind: str, request_id: int): 'request_obj': obj, 'request_label': request_label, 'timeline_rows': timeline_rows, + 'custom_field_details': custom_field_details, 'contract_start': getattr(obj, 'contract_start', None), 'handover_date': getattr(obj, 'handover_date', None), }, @@ -2076,6 +2174,7 @@ def form_builder_page(request): if request.method == 'POST': delete_option_id = request.POST.get('delete_option_id', '').strip() + delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip() if delete_option_id: option = FormOption.objects.filter(id=delete_option_id).first() if not option: @@ -2088,6 +2187,17 @@ def form_builder_page(request): _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Option wurde gelöscht.') return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=options#builder-content") + if delete_custom_field_id: + custom_field = FormCustomFieldConfig.objects.filter(id=delete_custom_field_id, form_type=form_type).first() + if not custom_field: + messages.error(request, 'Benutzerdefiniertes Feld nicht gefunden.') + else: + deleted_label = custom_field.label + deleted_id = custom_field.id + custom_field.delete() + _audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label) + messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") action = request.POST.get('builder_action', '') if action == 'add_option': @@ -2157,6 +2267,91 @@ def form_builder_page(request): _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) messages.success(request, 'Feldtexte wurden gespeichert.') + elif action == 'add_custom_field': + label = (request.POST.get('custom_label') or '').strip() + label_en = (request.POST.get('custom_label_en') or '').strip() + section_key = (request.POST.get('custom_section_key') or '').strip() + field_type = (request.POST.get('custom_field_type') or '').strip() + sort_order_raw = (request.POST.get('custom_sort_order') or '').strip() + help_text = (request.POST.get('custom_help_text') or '').strip() + help_text_en = (request.POST.get('custom_help_text_en') or '').strip() + select_options = (request.POST.get('custom_select_options') or '').strip() + select_options_en = (request.POST.get('custom_select_options_en') or '').strip() + section_choices = {key for key in get_section_order(form_type)} + field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES} + if not label: + messages.error(request, 'Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben.') + elif section_key not in section_choices: + messages.error(request, 'Ungültiger Abschnitt für das benutzerdefinierte Feld.') + elif field_type not in field_type_choices: + messages.error(request, 'Ungültiger Feldtyp.') + elif field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not select_options: + messages.error(request, 'Auswahlfelder benötigen mindestens eine Option.') + else: + field_key_base = build_custom_field_key(label) + field_key = field_key_base + suffix = 2 + while FormCustomFieldConfig.objects.filter(form_type=form_type, field_key=field_key).exists(): + field_key = f'{field_key_base}_{suffix}' + suffix += 1 + try: + sort_order = int(sort_order_raw or 0) + except ValueError: + sort_order = 0 + FormCustomFieldConfig.objects.create( + form_type=form_type, + field_key=field_key, + section_key=section_key, + sort_order=max(0, sort_order), + field_type=field_type, + is_active=True, + is_required=request.POST.get('custom_is_required') == 'on', + label=label, + label_en=label_en, + help_text=help_text, + help_text_en=help_text_en, + select_options=select_options, + select_options_en=select_options_en, + ) + _audit(request, 'form_custom_field_added', target_type='form_custom_field', target_label=label, details={'form_type': form_type, 'field_type': field_type, 'section_key': section_key}) + messages.success(request, 'Benutzerdefiniertes Feld wurde hinzugefügt.') + + elif action == 'save_custom_fields': + custom_ids = request.POST.getlist('custom_field_ids') + updated = 0 + section_choices = {key for key in get_section_order(form_type)} + field_type_choices = {key for key, _ in FormCustomFieldConfig.FIELD_TYPE_CHOICES} + for raw_id in custom_ids: + cfg = FormCustomFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + field_type = (request.POST.get(f'custom_field_type_{cfg.id}') or '').strip() + section_key = (request.POST.get(f'custom_section_key_{cfg.id}') or '').strip() + try: + sort_order = int((request.POST.get(f'custom_sort_order_{cfg.id}') or '').strip() or cfg.sort_order) + except ValueError: + sort_order = cfg.sort_order + cfg.label = (request.POST.get(f'custom_label_{cfg.id}') or '').strip() or cfg.label + cfg.label_en = (request.POST.get(f'custom_label_en_{cfg.id}') or '').strip() + cfg.help_text = (request.POST.get(f'custom_help_text_{cfg.id}') or '').strip() + cfg.help_text_en = (request.POST.get(f'custom_help_text_en_{cfg.id}') or '').strip() + cfg.is_required = request.POST.get(f'custom_is_required_{cfg.id}') == 'on' + cfg.is_active = request.POST.get(f'custom_is_active_{cfg.id}') == 'on' + if field_type in field_type_choices: + cfg.field_type = field_type + if section_key in section_choices: + cfg.section_key = section_key + cfg.sort_order = max(0, sort_order) + cfg.select_options = (request.POST.get(f'custom_select_options_{cfg.id}') or '').strip() + cfg.select_options_en = (request.POST.get(f'custom_select_options_en_{cfg.id}') or '').strip() + if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not cfg.select_options: + messages.error(request, f'Auswahlfeld "{cfg.label}" benötigt mindestens eine Option.') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") + cfg.save() + updated += 1 + _audit(request, 'form_custom_fields_saved', target_type='form_custom_field', target_label=form_type, details={'count': updated}) + messages.success(request, 'Benutzerdefinierte Felder wurden gespeichert.') + elif action == 'save_field_rules': field_ids = request.POST.getlist('field_rule_ids') locked_fields = LOCKED_FIELD_RULES.get(form_type, set()) @@ -2223,12 +2418,14 @@ def form_builder_page(request): else: messages.error(request, 'Preset konnte nicht angewendet werden.') - if action in {'add_option', 'save_options', 'save_field_texts'}: + if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields'}: active_panel = 'builder-content' if action in {'add_option', 'save_options'}: active_subpanel = 'options' elif action == 'save_field_texts': active_subpanel = 'field-texts' + elif action in {'add_custom_field', 'save_custom_fields'}: + active_subpanel = 'custom-fields' elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: active_panel = 'builder-rules' redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" @@ -2263,6 +2460,7 @@ def form_builder_page(request): labels = _form_field_labels(form_type) locked = LOCKED_FIELD_RULES.get(form_type, set()) locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key')) if form_type == 'onboarding': columns = [ @@ -2289,8 +2487,30 @@ def form_builder_page(request): 'is_required': cfg.is_required, 'locked': cfg.field_name in locked, 'page_key': page_key, + 'is_custom': False, + 'sort_order': cfg.sort_order, } ) + for cfg in custom_field_configs: + page_key = cfg.section_key or fallback + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': f'custom__{cfg.field_key}', + 'label': cfg.translated_label(language_code), + 'label_de': cfg.label, + 'label_en': cfg.label_en, + 'is_visible': cfg.is_active, + 'is_required': cfg.is_required, + 'locked': False, + 'page_key': page_key, + 'is_custom': True, + 'sort_order': cfg.sort_order, + } + ) + for column in columns: + column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name'])) else: columns = [ { @@ -2316,8 +2536,30 @@ def form_builder_page(request): 'is_required': cfg.is_required, 'locked': cfg.field_name in locked, 'page_key': page_key, + 'is_custom': False, + 'sort_order': cfg.sort_order, } ) + for cfg in custom_field_configs: + page_key = cfg.section_key or fallback + if page_key not in column_by_key: + page_key = fallback + column_by_key[page_key]['items'].append( + { + 'field_name': f'custom__{cfg.field_key}', + 'label': cfg.translated_label(language_code), + 'label_de': cfg.label, + 'label_en': cfg.label_en, + 'is_visible': cfg.is_active, + 'is_required': cfg.is_required, + 'locked': False, + 'page_key': page_key, + 'is_custom': True, + 'sort_order': cfg.sort_order, + } + ) + for column in columns: + column['items'].sort(key=lambda item: (item.get('sort_order', 9999), item['field_name'])) section_rule_items = [] if section_order: @@ -2350,6 +2592,20 @@ def form_builder_page(request): } ) + custom_field_groups = [] + if section_order: + grouped_custom = {key: [] for key in section_order} + for cfg in custom_field_configs: + grouped_custom.setdefault(cfg.section_key, []).append(cfg) + for key in section_order: + custom_field_groups.append( + { + 'key': key, + 'title': section_labels.get(key, key), + 'items': grouped_custom.get(key, []), + } + ) + field_rule_groups = [] if section_order: grouped_rules = {key: [] for key in section_order} @@ -2393,6 +2649,8 @@ def form_builder_page(request): 'inherit_phone_number_choice', ]: conditional_field_choices.append((field_name, labels.get(field_name, field_name))) + for cfg in custom_field_configs: + conditional_field_choices.append((f'custom__{cfg.field_key}', cfg.translated_label(language_code))) conditional_target_titles = { 'business-card-box': _('Visitenkarten-Details'), 'employment-end-box': _('Vertragsende'), @@ -2417,16 +2675,26 @@ def form_builder_page(request): clauses = list(cfg.clauses or []) while len(clauses) < 2: clauses.append({'field': '', 'operator': 'equals', 'value': ''}) + if target_key.startswith('custom__'): + custom_field_key = target_key.replace('custom__', '', 1) + custom_field = next((item for item in custom_field_configs if item.field_key == custom_field_key), None) + target_title = custom_field.translated_label(language_code) if custom_field else target_key + target_description = _('Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes.') + target_fields = [target_title] + else: + target_title = conditional_target_titles.get(target_key, target_key) + target_description = conditional_target_descriptions.get(target_key, '') + target_fields = [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])] conditional_rule_items.append( { 'target_key': target_key, - 'title': conditional_target_titles.get(target_key, target_key), - 'description': conditional_target_descriptions.get(target_key, ''), + 'title': target_title, + 'description': target_description, 'is_active': cfg.is_active, 'clauses': clauses[:2], 'field_choices': conditional_field_choices, 'operator_choices': CONDITIONAL_RULE_OPERATOR_CHOICES, - 'target_fields': [labels.get(name, name) for name in ONBOARDING_GROUPS.get(target_key, [])], + 'target_fields': target_fields, } ) @@ -2441,6 +2709,16 @@ def form_builder_page(request): item for item in field_rule_group_map.get(key, []) if item['locked'] or item['is_visible'] ] + visible_items.extend( + [ + { + 'label': cfg.translated_label(language_code), + 'locked': False, + } + for cfg in custom_field_configs + if cfg.section_key == key and cfg.is_active + ] + ) if section_visible: preview_sections.append( { @@ -2459,6 +2737,7 @@ def form_builder_page(request): 'configurable_field_count': configurable_field_count, 'hidden_field_count': hidden_field_count, 'hidden_section_count': hidden_section_count, + 'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]), } return render( @@ -2479,6 +2758,8 @@ def form_builder_page(request): 'section_rule_items': section_rule_items, 'builder_summary': builder_summary, 'conditional_rule_items': conditional_rule_items, + 'custom_field_groups': custom_field_groups, + 'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES), 'active_panel': active_panel, 'active_subpanel': active_subpanel, 'available_presets': FORM_PRESETS.get(form_type, {}), @@ -2921,22 +3202,24 @@ def form_builder_save_order(request): form_type = payload.get('form_type') if form_type not in DEFAULT_FIELD_ORDER: return JsonResponse({'ok': False, 'error': 'Ungültiger Formulartyp.'}, status=400) + default_page_map = get_default_page_map(form_type) columns = payload.get('columns') if not isinstance(columns, dict): return JsonResponse({'ok': False, 'error': 'Spalten-Daten fehlen.'}, status=400) configs = list(FormFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_name')) - allowed_names = {cfg.field_name for cfg in configs} + custom_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('sort_order', 'field_key')) + allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs} seen = set() - ordered_names = [] if form_type == 'onboarding': allowed_columns = ONBOARDING_PAGE_ORDER else: - allowed_columns = ['all'] + allowed_columns = OFFBOARDING_PAGE_ORDER name_to_cfg = {cfg.field_name: cfg for cfg in configs} + custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs} sort_order = 0 for column_key in allowed_columns: @@ -2950,14 +3233,15 @@ def form_builder_save_order(request): if name not in allowed_names or name in seen: continue seen.add(name) - ordered_names.append(name) - cfg = name_to_cfg[name] - cfg.sort_order = sort_order - sort_order += 1 - if form_type == 'onboarding': + if name in name_to_cfg: + cfg = name_to_cfg[name] + cfg.sort_order = sort_order cfg.page_key = column_key else: - cfg.page_key = '' + cfg = custom_name_to_cfg[name] + cfg.sort_order = sort_order + cfg.section_key = column_key + sort_order += 1 missing = [cfg.field_name for cfg in configs if cfg.field_name not in seen] for name in missing: @@ -2967,11 +3251,24 @@ def form_builder_save_order(request): if form_type == 'onboarding': cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss') else: - cfg.page_key = '' + cfg.page_key = cfg.page_key or default_page_map.get(name, OFFBOARDING_PAGE_ORDER[-1]) + + missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen] + for name in missing_custom: + cfg = custom_name_to_cfg[name] + cfg.sort_order = sort_order + sort_order += 1 + if form_type == 'onboarding': + cfg.section_key = cfg.section_key or 'abschluss' + else: + cfg.section_key = cfg.section_key or OFFBOARDING_PAGE_ORDER[-1] FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) - _audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': len(configs)}) - return JsonResponse({'ok': True, 'saved_count': len(configs)}) + if custom_configs: + FormCustomFieldConfig.objects.bulk_update(custom_configs, ['sort_order', 'section_key']) + saved_count = len(configs) + len(custom_configs) + _audit(request, 'form_layout_saved', target_type='form_config', target_label=form_type, details={'saved_count': saved_count}) + return JsonResponse({'ok': True, 'saved_count': saved_count}) @_require_capability('manage_integrations') From 30877ed8ee753556612b76812fa2b7bbc61b1a06 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 16:54:11 +0100 Subject: [PATCH 22/45] snapshot: preserve dynamic builder and section ordering work --- backend/workflows/form_builder.py | 72 ++- ...mcustomfieldconfig_section_key_and_more.py | 46 ++ .../0057_remove_phone_box_conditional_rule.py | 20 + ...lter_formsectionconfig_options_and_more.py | 34 ++ backend/workflows/models.py | 55 +- .../static/workflows/css/form_builder.css | 455 ++++++++++++-- .../static/workflows/js/form_builder.js | 91 +++ .../templates/workflows/form_builder.html | 573 +++++++++++------- .../templates/workflows/onboarding_form.html | 32 +- .../tests/test_form_builder_admin.py | 131 +++- .../workflows/tests/test_onboarding_flow.py | 37 +- backend/workflows/tests/test_pdf_sections.py | 37 +- backend/workflows/views.py | 173 ++++-- 13 files changed, 1391 insertions(+), 365 deletions(-) create mode 100644 backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py create mode 100644 backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py create mode 100644 backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py diff --git a/backend/workflows/form_builder.py b/backend/workflows/form_builder.py index 43bb7a0..8d6e06b 100644 --- a/backend/workflows/form_builder.py +++ b/backend/workflows/form_builder.py @@ -3,7 +3,7 @@ from django import forms from django.utils.text import slugify from django.utils.translation import get_language -from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig +from .models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig DEFAULT_FIELD_ORDER = { @@ -72,6 +72,10 @@ OFFBOARDING_PAGE_LABELS = { 'austritt': '2. Austritt', 'abschluss': '3. Abschluss', } +CORE_SECTION_LABELS = { + 'onboarding': ONBOARDING_PAGE_LABELS, + 'offboarding': OFFBOARDING_PAGE_LABELS, +} LOCKED_FIELD_RULES = { 'onboarding': {'full_name', 'work_email', 'contract_start', 'agreement_confirm'}, @@ -157,12 +161,6 @@ DEFAULT_CONDITIONAL_RULES = { 'successor-box': { 'clauses': [{'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}], }, - 'phone-box': { - 'clauses': [ - {'field': 'successor_required_choice', 'operator': 'equals', 'value': 'ja'}, - {'field': 'inherit_phone_number_choice', 'operator': 'not_equals', 'value': 'ja'}, - ], - }, }, } @@ -170,19 +168,11 @@ CUSTOM_FIELD_PREFIX = 'custom__' def get_section_order(form_type: str) -> list[str]: - if form_type == 'onboarding': - return ONBOARDING_PAGE_ORDER - if form_type == 'offboarding': - return OFFBOARDING_PAGE_ORDER - return [] + return [item['key'] for item in get_section_definitions(form_type)] def get_section_labels(form_type: str) -> dict[str, str]: - if form_type == 'onboarding': - return ONBOARDING_PAGE_LABELS - if form_type == 'offboarding': - return OFFBOARDING_PAGE_LABELS - return {} + return {item['key']: item['title'] for item in get_section_definitions(form_type)} def get_default_page_map(form_type: str) -> dict[str, str]: @@ -193,6 +183,42 @@ def get_default_page_map(form_type: str) -> dict[str, str]: return {} +def get_custom_section_configs(form_type: str, include_inactive: bool = False) -> list[FormCustomSectionConfig]: + qs = FormCustomSectionConfig.objects.filter(form_type=form_type) + if not include_inactive: + qs = qs.filter(is_active=True) + return list(qs.order_by('sort_order', 'section_key')) + + +def get_section_definitions(form_type: str, include_inactive_custom: bool = False) -> list[dict[str, object]]: + definitions: list[dict[str, object]] = [] + section_configs = ensure_form_section_configs(form_type) + for cfg in sorted(section_configs.values(), key=lambda item: (item.sort_order, item.section_key)): + label_map = CORE_SECTION_LABELS.get(form_type, {}) + definitions.append( + { + 'key': cfg.section_key, + 'title': label_map.get(cfg.section_key, cfg.section_key), + 'locked': cfg.section_key in LOCKED_SECTION_RULES.get(form_type, set()), + 'is_custom': False, + 'sort_order': cfg.sort_order, + } + ) + for cfg in get_custom_section_configs(form_type, include_inactive=include_inactive_custom): + definitions.append( + { + 'key': cfg.section_key, + 'title': cfg.translated_title(get_language()), + 'locked': False, + 'is_custom': True, + 'is_active': cfg.is_active, + 'sort_order': cfg.sort_order, + } + ) + definitions.sort(key=lambda item: (item.get('sort_order', 9999), item['key'])) + return definitions + + def get_default_conditional_rules(form_type: str) -> dict[str, dict]: return DEFAULT_CONDITIONAL_RULES.get(form_type, {}) @@ -323,7 +349,7 @@ def ensure_form_field_configs(form_type: str, field_names: list[str]) -> dict[st def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: - section_order = get_section_order(form_type) + section_order = list(CORE_SECTION_LABELS.get(form_type, {}).keys()) if not section_order: return {} existing = { @@ -333,7 +359,15 @@ def ensure_form_section_configs(form_type: str) -> dict[str, FormSectionConfig]: missing = [key for key in section_order if key not in existing] if missing: FormSectionConfig.objects.bulk_create( - [FormSectionConfig(form_type=form_type, section_key=key, is_visible=True) for key in missing], + [ + FormSectionConfig( + form_type=form_type, + section_key=key, + sort_order=section_order.index(key), + is_visible=True, + ) + for key in missing + ], ignore_conflicts=True, ) existing = { diff --git a/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py new file mode 100644 index 0000000..b981014 --- /dev/null +++ b/backend/workflows/migrations/0056_alter_formcustomfieldconfig_section_key_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.5 on 2026-03-27 12:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0055_offboardingrequest_custom_field_values_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='formcustomfieldconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.AlterField( + model_name='formfieldconfig', + name='page_key', + field=models.CharField(blank=True, default='', max_length=80), + ), + migrations.AlterField( + model_name='formsectionconfig', + name='section_key', + field=models.CharField(max_length=80), + ), + migrations.CreateModel( + name='FormCustomSectionConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_type', models.CharField(choices=[('onboarding', 'Onboarding')], max_length=20)), + ('section_key', models.SlugField(max_length=80)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('title', models.CharField(max_length=255)), + ('title_en', models.CharField(blank=True, max_length=255)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'verbose_name': 'Benutzerdefinierter Formularabschnitt', + 'verbose_name_plural': 'Benutzerdefinierte Formularabschnitte', + 'ordering': ['form_type', 'sort_order', 'section_key'], + 'unique_together': {('form_type', 'section_key')}, + }, + ), + ] diff --git a/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py new file mode 100644 index 0000000..d221eb2 --- /dev/null +++ b/backend/workflows/migrations/0057_remove_phone_box_conditional_rule.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +def remove_phone_box_rule(apps, schema_editor): + FormConditionalRuleConfig = apps.get_model('workflows', 'FormConditionalRuleConfig') + FormConditionalRuleConfig.objects.filter( + form_type='onboarding', + target_key='phone-box', + ).update(is_active=False, clauses=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0056_alter_formcustomfieldconfig_section_key_and_more'), + ] + + operations = [ + migrations.RunPython(remove_phone_box_rule, migrations.RunPython.noop), + ] diff --git a/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py new file mode 100644 index 0000000..d98d85f --- /dev/null +++ b/backend/workflows/migrations/0058_alter_formsectionconfig_options_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2026-03-27 15:45 + +from django.db import migrations, models + + +def seed_section_sort_order(apps, schema_editor): + FormSectionConfig = apps.get_model('workflows', 'FormSectionConfig') + defaults = { + 'onboarding': ['stammdaten', 'vertrag', 'itsetup', 'abschluss'], + 'offboarding': ['mitarbeitende', 'austritt', 'abschluss'], + } + for form_type, section_keys in defaults.items(): + for index, section_key in enumerate(section_keys): + FormSectionConfig.objects.filter(form_type=form_type, section_key=section_key).update(sort_order=index) + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0057_remove_phone_box_conditional_rule'), + ] + + operations = [ + migrations.AlterModelOptions( + name='formsectionconfig', + options={'ordering': ['form_type', 'sort_order', 'section_key'], 'verbose_name': 'Formularabschnitt-Konfiguration', 'verbose_name_plural': 'Formularabschnitt-Konfigurationen'}, + ), + migrations.AddField( + model_name='formsectionconfig', + name='sort_order', + field=models.PositiveIntegerField(default=0), + ), + migrations.RunPython(seed_section_sort_order, migrations.RunPython.noop), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index a406bab..8caf2a6 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -491,7 +491,7 @@ class FormFieldConfig(models.Model): sort_order = models.PositiveIntegerField(default=0) is_visible = models.BooleanField(default=True) is_required = models.BooleanField(null=True, blank=True, default=None) - page_key = models.CharField(max_length=20, blank=True, default='', choices=PAGE_CHOICES) + page_key = models.CharField(max_length=80, blank=True, default='') label_override = models.CharField(max_length=255, blank=True) label_override_en = models.CharField(max_length=255, blank=True) help_text_override = models.TextField(blank=True) @@ -524,21 +524,13 @@ class FormSectionConfig(models.Model): ('onboarding', _('Onboarding')), ('offboarding', _('Offboarding')), ] - SECTION_CHOICES = [ - ('stammdaten', _('Stammdaten')), - ('vertrag', _('Vertrag')), - ('itsetup', _('IT-Setup')), - ('abschluss', _('Abschluss')), - ('mitarbeitende', _('Mitarbeitende')), - ('austritt', _('Austritt')), - ] - form_type = models.CharField(max_length=20, choices=FORM_CHOICES) - section_key = models.CharField(max_length=20, choices=SECTION_CHOICES) + section_key = models.CharField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) is_visible = models.BooleanField(default=True) class Meta: - ordering = ['form_type', 'section_key'] + ordering = ['form_type', 'sort_order', 'section_key'] unique_together = ('form_type', 'section_key') verbose_name = 'Formularabschnitt-Konfiguration' verbose_name_plural = 'Formularabschnitt-Konfigurationen' @@ -567,6 +559,34 @@ class FormConditionalRuleConfig(models.Model): return f'{self.form_type}: {self.target_key}' +class FormCustomSectionConfig(models.Model): + FORM_CHOICES = [ + ('onboarding', _('Onboarding')), + ] + + form_type = models.CharField(max_length=20, choices=FORM_CHOICES) + section_key = models.SlugField(max_length=80) + sort_order = models.PositiveIntegerField(default=0) + title = models.CharField(max_length=255) + title_en = models.CharField(max_length=255, blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['form_type', 'sort_order', 'section_key'] + unique_together = ('form_type', 'section_key') + verbose_name = 'Benutzerdefinierter Formularabschnitt' + verbose_name_plural = 'Benutzerdefinierte Formularabschnitte' + + def __str__(self) -> str: + return f'{self.form_type}: {self.title}' + + def translated_title(self, language_code: str | None = None) -> str: + lang = (language_code or get_language() or 'de').split('-')[0] + if lang == 'en' and self.title_en.strip(): + return self.title_en.strip() + return self.title.strip() + + class FormCustomFieldConfig(models.Model): FIELD_TYPE_TEXT = 'text' FIELD_TYPE_TEXTAREA = 'textarea' @@ -582,18 +602,9 @@ class FormCustomFieldConfig(models.Model): ('onboarding', _('Onboarding')), ('offboarding', _('Offboarding')), ] - SECTION_CHOICES = [ - ('stammdaten', _('Stammdaten')), - ('vertrag', _('Vertrag')), - ('itsetup', _('IT-Setup')), - ('abschluss', _('Abschluss')), - ('mitarbeitende', _('Mitarbeitende')), - ('austritt', _('Austritt')), - ] - form_type = models.CharField(max_length=20, choices=FORM_CHOICES) field_key = models.SlugField(max_length=80) - section_key = models.CharField(max_length=20, choices=SECTION_CHOICES) + section_key = models.CharField(max_length=80) sort_order = models.PositiveIntegerField(default=0) field_type = models.CharField(max_length=20, choices=FIELD_TYPE_CHOICES, default=FIELD_TYPE_TEXT) is_active = models.BooleanField(default=True) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index fce3a2e..f6db87f 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -109,14 +109,31 @@ body { color: #166534; } -.builder-overview { +.builder-summary-strip { margin-top: 12px; - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.builder-summary-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 34px; + padding: 0 12px; + border: 1px solid #d5dfec; + border-radius: 999px; + background: #f8fbff; + color: #304159; + font-size: 13px; + font-weight: 700; +} + +.builder-summary-pill strong { + color: #101c30; } -.builder-stat-card, .builder-panel, .options-panel { border: 1px solid rgba(201, 212, 226, 0.95); @@ -125,34 +142,6 @@ body { box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); } -.builder-stat-card { - padding: 14px 16px; - transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; -} - -.builder-stat-card:hover { - transform: translateY(-2px); - border-color: rgba(146, 170, 199, 0.95); - box-shadow: 0 16px 28px rgba(15, 23, 42, 0.08); -} - -.builder-stat-label { - display: block; - color: #65758f; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.03em; - text-transform: uppercase; -} - -.builder-stat-card strong { - display: block; - margin-top: 6px; - font-size: 28px; - line-height: 1; - color: #101c30; -} - .builder-panel-head h2, .options-head h2 { margin: 0; @@ -371,6 +360,18 @@ body { gap: 12px; } +.preview-shell-compact .preview-section { + border-radius: 12px; +} + +.preview-shell-compact .preview-section-head { + padding: 10px 12px; +} + +.preview-shell-compact .preview-chip-list { + padding: 12px; +} + .preview-section { border: 1px solid #d7e0ec; border-radius: 14px; @@ -500,6 +501,169 @@ body { justify-content: flex-end; } +.conditional-rule-grid { + display: grid; + gap: 12px; +} + +.conditional-rule-card { + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + padding: 14px; + display: grid; + gap: 12px; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.conditional-rule-card:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 14px 26px rgba(15, 23, 42, 0.06); +} + +.conditional-rule-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px solid #e6edf6; +} + +.conditional-rule-head-main { + min-width: 0; +} + +.conditional-rule-head h3 { + margin: 2px 0 4px; + font-size: 16px; + color: #142033; +} + +.conditional-rule-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eef4ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.conditional-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + color: #5f7089; + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.conditional-toggle input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.conditional-targets { + display: grid; + gap: 8px; +} + +.conditional-target-label { + color: #5f7089; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-target-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.conditional-clause-list { + display: grid; + gap: 10px; +} + +.conditional-clause-row { + display: grid; + grid-template-columns: 56px minmax(220px, 1.35fr) minmax(180px, 0.8fr) minmax(180px, 0.85fr); + gap: 10px; + align-items: end; + padding: 12px; + border: 1px solid #e5ebf3; + border-radius: 14px; + background: #f8fbff; +} + +.conditional-clause-index { + display: inline-flex; + align-items: center; + min-height: 38px; + color: #33506f; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-clause-control { + display: grid; + gap: 6px; +} + +.conditional-clause-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.conditional-clause-control select, +.conditional-clause-control input[type='text'] { + width: 100%; + min-height: 40px; +} + +.conditional-extra-clause { + border: 1px dashed #d7e0ec; + border-radius: 14px; + background: #fbfdff; +} + +.conditional-extra-clause summary { + list-style: none; + cursor: pointer; + padding: 10px 12px; + color: #35506f; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.conditional-extra-clause summary::-webkit-details-marker { + display: none; +} + +.conditional-extra-clause[open] summary { + border-bottom: 1px solid #e6edf6; +} + +.conditional-extra-clause .conditional-clause-row { + border: 0; + border-radius: 0 0 14px 14px; + background: transparent; +} + .columns { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); @@ -728,35 +892,212 @@ body { justify-content: flex-end; } -.section-rule-grid { +.builder-entity-form { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 10px; + gap: 12px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); } -.section-rule-card { +.builder-entity-head h3, +.builder-group-head h3 { + margin: 0; + font-size: 16px; + color: #142033; +} + +.builder-entity-head .mini { + margin-top: 4px; +} + +.builder-entity-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.builder-entity-control { + display: grid; + gap: 6px; +} + +.builder-entity-control span { + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-entity-control-narrow { + max-width: 180px; +} + +.builder-entity-control-full { + grid-column: 1 / -1; +} + +.builder-card-list, +.builder-group-stack { + display: grid; + gap: 12px; +} + +.builder-group-card { + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + overflow: hidden; +} + +.builder-group-head { display: flex; align-items: center; justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #dfe7f1; + background: #f2f7ff; +} + +.builder-entity-card { + padding: 14px; + border: 1px solid #e5ebf3; + border-radius: 16px; + background: #ffffff; + display: grid; + gap: 12px; +} + +.builder-entity-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.builder-entity-card-head strong { + color: #142033; + font-size: 15px; +} + +.entity-meta { + margin-top: 4px; + color: #64748b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + overflow-wrap: anywhere; +} + +.builder-switch, +.builder-switch-inline { + display: inline-flex; + align-items: center; + gap: 8px; + color: #5f7089; + font-size: 12px; + font-weight: 800; +} + +.builder-switch-stack { + display: grid; + gap: 8px; +} + +.builder-switch input[type='checkbox'], +.builder-switch-inline input[type='checkbox'] { + width: 16px; + height: 16px; +} + +.builder-entity-card-actions { + display: flex; + justify-content: flex-end; +} + +.builder-empty-state { + padding: 14px; + border: 1px dashed #d7e0ec; + border-radius: 14px; + background: #fbfdff; + color: #64748b; + font-size: 13px; +} + +.section-rule-grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.section-rule-grid.drag-over { + outline: 1px dashed #9db4d2; + outline-offset: 6px; +} + +.section-rule-card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d6e0ec; border-radius: 14px; background: linear-gradient(180deg, #f9fbff, #ffffff); + cursor: move; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; + width: 100%; +} + +.section-rule-card:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); + border-color: #b8cae0; } .section-rule-card.is-locked { background: linear-gradient(180deg, #f4f7fb, #fafcff); } +.section-rule-card.dragging { + opacity: 0.58; +} + +.section-rule-card.manual-dragging { + opacity: 0.72; + border-color: #8fb1d8; + box-shadow: 0 16px 30px rgba(15, 23, 42, 0.10); +} + +.section-rule-drag { + color: #8aa0be; + font-size: 18px; + letter-spacing: -2px; + user-select: none; + align-self: stretch; + display: inline-flex; + align-items: center; + padding-right: 2px; + cursor: grab; +} + +body.builder-dragging, +body.builder-dragging * { + cursor: grabbing !important; + user-select: none !important; +} + .section-rule-copy { display: grid; gap: 4px; + min-width: 0; } .section-rule-copy strong { color: #0f172a; font-size: 14px; + overflow-wrap: anywhere; } .section-rule-copy span, @@ -772,6 +1113,11 @@ body { gap: 8px; } +.section-rule-checkbox { + display: inline-flex; + align-items: center; +} + @keyframes builderFadeIn { from { opacity: 0; @@ -821,10 +1167,6 @@ body { } @media (max-width: 1220px) { - .builder-overview { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .builder-rule-layout, .columns { grid-template-columns: repeat(2, minmax(220px, 1fr)); @@ -834,7 +1176,8 @@ body { @media (max-width: 900px) { .builder-hero, .builder-panel-head, - .options-head { + .options-head, + .conditional-rule-head { flex-direction: column; align-items: flex-start; } @@ -846,15 +1189,20 @@ body { .builder-rule-layout { grid-template-columns: 1fr; } + + .builder-entity-card-head { + flex-direction: column; + align-items: flex-start; + } } @media (max-width: 760px) { - .builder-overview, .columns { grid-template-columns: 1fr; } - .field-rule-row { + .field-rule-row, + .conditional-clause-row { grid-template-columns: 1fr; } @@ -862,7 +1210,26 @@ body { justify-content: flex-start; } + .conditional-clause-index { + min-height: auto; + } + .add-option-form { grid-template-columns: 1fr; } + + .builder-entity-grid { + grid-template-columns: 1fr; + } + + .builder-entity-control-narrow, + .builder-entity-control-full { + max-width: none; + grid-column: auto; + } + + .builder-group-head { + flex-direction: column; + align-items: flex-start; + } } diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js index bb3b7c4..1fceee8 100644 --- a/backend/workflows/static/workflows/js/form_builder.js +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -145,4 +145,95 @@ } }); } + + const sectionRuleGrid = document.getElementById('section-rule-grid'); + if (sectionRuleGrid) { + let draggingSectionCard = null; + let manualDraggingSectionCard = null; + + function getSectionInsertBeforeNode(mouseY) { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.dragging)')); + return cards.find((card) => { + const box = card.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + function getManualSectionInsertBeforeNode(mouseY) { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.manual-dragging)')); + return cards.find((card) => { + const box = card.getBoundingClientRect(); + return mouseY < box.top + box.height / 2; + }); + } + + function onManualSectionMove(event) { + if (!manualDraggingSectionCard) return; + event.preventDefault(); + sectionRuleGrid.classList.add('drag-over'); + const beforeNode = getManualSectionInsertBeforeNode(event.clientY); + if (beforeNode) { + sectionRuleGrid.insertBefore(manualDraggingSectionCard, beforeNode); + } else { + sectionRuleGrid.appendChild(manualDraggingSectionCard); + } + } + + function endManualSectionDrag() { + if (!manualDraggingSectionCard) return; + manualDraggingSectionCard.classList.remove('manual-dragging'); + manualDraggingSectionCard = null; + sectionRuleGrid.classList.remove('drag-over'); + document.body.classList.remove('builder-dragging'); + document.removeEventListener('mousemove', onManualSectionMove); + document.removeEventListener('mouseup', endManualSectionDrag); + } + + sectionRuleGrid.querySelectorAll('.section-rule-card').forEach((card) => { + card.addEventListener('dragstart', (event) => { + draggingSectionCard = card; + card.classList.add('dragging'); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', card.dataset.sectionKey || ''); + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + sectionRuleGrid.classList.remove('drag-over'); + draggingSectionCard = null; + }); + const handle = card.querySelector('.section-rule-drag'); + if (handle) { + handle.addEventListener('mousedown', (event) => { + event.preventDefault(); + manualDraggingSectionCard = card; + card.classList.add('manual-dragging'); + sectionRuleGrid.classList.add('drag-over'); + document.body.classList.add('builder-dragging'); + document.addEventListener('mousemove', onManualSectionMove); + document.addEventListener('mouseup', endManualSectionDrag); + }); + } + }); + + sectionRuleGrid.addEventListener('dragover', (event) => { + event.preventDefault(); + sectionRuleGrid.classList.add('drag-over'); + if (!draggingSectionCard) return; + const beforeNode = getSectionInsertBeforeNode(event.clientY); + if (beforeNode) { + sectionRuleGrid.insertBefore(draggingSectionCard, beforeNode); + } else { + sectionRuleGrid.appendChild(draggingSectionCard); + } + }); + + sectionRuleGrid.addEventListener('dragleave', () => { + sectionRuleGrid.classList.remove('drag-over'); + }); + + sectionRuleGrid.addEventListener('drop', (event) => { + event.preventDefault(); + sectionRuleGrid.classList.remove('drag-over'); + }); + } })(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index b032696..80fc5ec 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -32,81 +32,6 @@
- - -
-
- {% trans "Fixe Kernfelder" %} - {{ builder_summary.locked_field_count }} -
-
- {% trans "Konfigurierbar" %} - {{ builder_summary.configurable_field_count }} -
-
- {% trans "Aktuell ausgeblendet" %} - {{ builder_summary.hidden_field_count }} -
-
- {% trans "Eigene Felder" %} - {{ builder_summary.custom_field_count }} -
- {% if form_type == 'onboarding' %} -
- {% trans "Versteckte Abschnitte" %} - {{ builder_summary.hidden_section_count }} -
- {% endif %} -
- -
- - {% csrf_token %} - - - - - -
- -
- -
-
-

{% trans "Live-Vorschau" %}

-
- {% trans "Öffnen" %} -
-
-
-
- {% for section in preview_sections %} -
-
-

{{ section.title }}

- {% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %} -
-
- {% for item in section.items %} - {{ item.label }} - {% empty %} - {% trans "Keine sichtbaren Felder." %} - {% endfor %} -
-
- {% endfor %} -
-
-
-
@@ -158,46 +83,63 @@
-
-
-
-

{% trans "Abschnitte steuern" %}

-
+
+
+ +
+

{% trans "Abschnitte steuern" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %} -
+
{% for section in section_rule_items %} -
-
- -
-
-

{% trans "Feldregeln verwalten" %}

+
+ +
+ +
+

{% trans "Feldregeln verwalten" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %}
@@ -250,61 +192,104 @@
- +
+
{% if form_type == 'onboarding' %} -
-
-

{% trans "Bedingte Logik" %}

-
+
+ +
+

{% trans "Bedingte Logik" %}

+ {% trans "Öffnen" %} +
+
+
{% csrf_token %} -
+
{% for item in conditional_rule_items %} -
-
-

{{ item.title }}

- {{ item.target_fields|join:", " }} -
-
-
-
- {{ item.title }} -
{{ item.description }}
-
- +
+
+
+ {% trans "Sichtbarkeit" %} +

{{ item.title }}

+ {% if item.description %} +

{{ item.description }}

+ {% endif %}
- {% for clause in item.clauses %} -
-
- {% blocktrans trimmed with number=forloop.counter %}Bedingung {{ number }}{% endblocktrans %} + +
+
+ {% trans "Steuert" %} +
+ {% for field_name in item.target_fields %} + {{ field_name }} + {% empty %} + {% trans "Keine Ziel-Felder." %} + {% endfor %} +
+
+
+ {% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} +
+
+ {% trans "Wenn" %}
-
- {% endfor %} +
+ {% trans "Zusätzliche Bedingung" %} +
+
+ {% trans "Und" %} +
+ + + +
+
+ {% endwith %}
{% endfor %} @@ -313,7 +298,8 @@
-
+
+
{% endif %}
@@ -457,6 +443,86 @@ + {% if form_type == 'onboarding' %} +
+ +
+

{% trans "Eigene Abschnitte" %}

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

{% trans "Abschnitt hinzufügen" %}

+

{% trans "Erstellen Sie zusätzliche Bereiche für deployment-spezifische Informationen." %}

+
+
+
+ + + +
+
+ +
+
+ +
+ {% csrf_token %} +
+ {% for item in custom_section_items %} +
+ +
+
+ {{ item.title }} +
{{ item.section_key }}
+
+ +
+
+ + + +
+
+ {% empty %} +
{% trans "Keine eigenen Abschnitte vorhanden." %}
+ {% endfor %} +
+
+ +
+
+
+
+ {% endif %} +
@@ -465,98 +531,155 @@
-
+ {% csrf_token %} - - - - - - - - - - - +
+
+

{% trans "Feld hinzufügen" %}

+

{% trans "Erstellen Sie zusätzliche Eingaben innerhalb eines bestehenden oder eigenen Abschnitts." %}

+
+
+
+ + + + + + + + + + +
+
+ +
{% csrf_token %} -
- - - - - - - - - - - - - - - - {% for group in custom_field_groups %} - - - +
+ {% for group in custom_field_groups %} +
+
+

{{ group.title }}

+ {% blocktrans trimmed with count=group.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
+
{% for item in group.items %} -
- - - - - - - - - - + + {% empty %} - +
{% trans "Keine eigenen Felder vorhanden." %}
{% endfor %} - {% endfor %} - -
{% trans "Schlüssel" %}{% trans "Abschnitt" %}{% trans "Typ" %}{% trans "Sortierung" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Pflicht" %}{% trans "Aktiv" %}{% trans "Löschen" %}
{{ group.title }}
- - {{ item.field_key }} - - - - - - - - - - - - - +
+ +
+
+ {{ item.label }} +
{{ item.field_key }}
+
+
+ + +
+
+
+ + + + + + + + + +
+
-
{% trans "Keine eigenen Felder vorhanden." %}
+
+ + {% endfor %}
@@ -564,6 +687,34 @@
+ +
+ +
+

{% trans "Live-Vorschau" %}

+ {% trans "Öffnen" %} +
+
+
+
+ {% for section in preview_sections %} +
+
+

{{ section.title }}

+ {% blocktrans trimmed with count=section.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
+
+ {% for item in section.items %} + {{ item.label }} + {% empty %} + {% trans "Keine sichtbaren Felder." %} + {% endfor %} +
+
+ {% endfor %} +
+
+
diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 0fdfb48..295f06a 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -59,13 +59,7 @@ {% with field=block.field %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
- {{ field }} {{ field.label_tag }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {{ field.errors }} -
- {% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %} + {% elif field.name in onboarding_checkbox_lists %}

{{ field.label }}

@@ -83,8 +77,14 @@ {{ field.errors }}
+ {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
{% else %} -
+
{{ field.label_tag }} {{ field }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} @@ -102,13 +102,7 @@ {% for field in block.fields %} {% if field.is_hidden %} {{ field }} - {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
- {{ field }} {{ field.label_tag }} - {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {{ field.errors }} -
- {% elif section.key == 'itsetup' and field.name in onboarding_checkbox_lists %} + {% elif field.name in onboarding_checkbox_lists %}

{{ field.label }}

@@ -126,8 +120,14 @@ {{ field.errors }}
+ {% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} +
+ {{ field }} {{ field.label_tag }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {{ field.errors }} +
{% else %} -
+
{{ field.label_tag }} {{ field }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index 3620174..85bd536 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -3,7 +3,8 @@ import json from django.contrib.auth import get_user_model from django.test import TestCase -from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig +from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role class FormBuilderAdminTests(TestCase): @@ -20,6 +21,14 @@ class FormBuilderAdminTests(TestCase): password='secret123', email='builder_user@tub.co', ) + self.platform_owner = user_model.objects.create_user( + username='builder_owner', + password='secret123', + email='builder_owner@tub.co', + is_staff=True, + is_superuser=True, + ) + assign_user_role(self.platform_owner, ROLE_PLATFORM_OWNER) def test_staff_can_open_form_builder(self): self.client.force_login(self.staff) @@ -118,6 +127,25 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(department.is_required, True) self.assertEqual(contract_start.is_required, None) + def test_platform_owner_can_modify_locked_field_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + full_name = FormFieldConfig.objects.get(form_type='onboarding', field_name='full_name') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_field_rules', + 'field_rule_ids': [str(full_name.id)], + f'is_required_{full_name.id}': 'optional', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + full_name.refresh_from_db() + self.assertEqual(full_name.is_required, False) + def test_staff_can_save_section_rules_with_locked_sections_preserved(self): self.client.force_login(self.staff) self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') @@ -126,6 +154,7 @@ class FormBuilderAdminTests(TestCase): '/admin-tools/form-builder/?form_type=onboarding&option_category=device', data={ 'builder_action': 'save_section_rules', + 'section_order': ['itsetup', 'stammdaten', 'vertrag', 'abschluss'], }, HTTP_HOST='localhost', ) @@ -135,6 +164,31 @@ class FormBuilderAdminTests(TestCase): stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') self.assertEqual(itsetup.is_visible, False) self.assertEqual(stammdaten.is_visible, True) + self.assertEqual(itsetup.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + + def test_platform_owner_can_modify_locked_section_rules(self): + self.client.force_login(self.platform_owner) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'save_section_rules', + 'section_order': ['vertrag', 'stammdaten', 'itsetup', 'abschluss'], + 'section_visible_vertrag': 'on', + 'section_visible_itsetup': 'on', + 'section_visible_abschluss': 'on', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + stammdaten = FormSectionConfig.objects.get(form_type='onboarding', section_key='stammdaten') + vertrag = FormSectionConfig.objects.get(form_type='onboarding', section_key='vertrag') + self.assertEqual(vertrag.sort_order, 0) + self.assertEqual(stammdaten.sort_order, 1) + self.assertEqual(stammdaten.is_visible, False) def test_apply_onboarding_lean_preset_updates_section_and_field_rules(self): self.client.force_login(self.staff) @@ -185,19 +239,19 @@ class FormBuilderAdminTests(TestCase): 'conditional_field_employment-end-box_0': 'employment_type', 'conditional_operator_employment-end-box_0': 'equals', 'conditional_value_employment-end-box_0': 'befristet', - 'conditional_active_phone-box': 'on', - 'conditional_field_phone-box_0': 'successor_required_choice', - 'conditional_operator_phone-box_0': 'equals', - 'conditional_value_phone-box_0': 'ja', - 'conditional_field_phone-box_1': 'inherit_phone_number_choice', - 'conditional_operator_phone-box_1': 'not_equals', - 'conditional_value_phone-box_1': 'ja', + 'conditional_active_successor-box': 'on', + 'conditional_field_successor-box_0': 'successor_required_choice', + 'conditional_operator_successor-box_0': 'equals', + 'conditional_value_successor-box_0': 'ja', + 'conditional_field_successor-box_1': 'inherit_phone_number_choice', + 'conditional_operator_successor-box_1': 'not_equals', + 'conditional_value_successor-box_1': 'ja', }, HTTP_HOST='localhost', ) self.assertEqual(response.status_code, 302) - rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='phone-box') + rule = FormConditionalRuleConfig.objects.get(form_type='onboarding', target_key='successor-box') self.assertEqual(rule.is_active, True) self.assertEqual(len(rule.clauses), 2) self.assertEqual(rule.clauses[0]['field'], 'successor_required_choice') @@ -258,3 +312,62 @@ class FormBuilderAdminTests(TestCase): custom_field.refresh_from_db() self.assertEqual(custom_field.section_key, 'itsetup') self.assertEqual(custom_field.sort_order, 2) + + def test_staff_can_add_custom_section(self): + self.client.force_login(self.staff) + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={ + 'builder_action': 'add_custom_section', + 'custom_section_title': 'Benefits', + 'custom_section_title_en': 'Benefits', + 'custom_section_sort_order': '5', + }, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + section = FormCustomSectionConfig.objects.get(form_type='onboarding', section_key='benefits') + self.assertEqual(section.title, 'Benefits') + self.assertEqual(section.sort_order, 5) + + def test_save_order_accepts_custom_section_column(self): + self.client.force_login(self.staff) + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + custom_field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='stammdaten', + sort_order=99, + field_type='text', + label='Essenszuschuss', + ) + self.client.get('/admin-tools/form-builder/?form_type=onboarding', HTTP_HOST='localhost') + + payload = { + 'form_type': 'onboarding', + 'columns': { + 'stammdaten': ['department'], + 'vertrag': ['contract_start'], + 'itsetup': [], + 'abschluss': [], + 'benefits': ['custom__meal_allowance'], + }, + } + + response = self.client.post( + '/admin-tools/form-builder/save-order/', + data=json.dumps(payload), + content_type='application/json', + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 200) + custom_field.refresh_from_db() + self.assertEqual(custom_field.section_key, 'benefits') diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index 8cad82c..5da61f5 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from workflows.branding import get_company_email_domain -from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest +from workflows.models import FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OnboardingRequest class OnboardingFlowTests(TestCase): @@ -153,7 +153,7 @@ class OnboardingFlowTests(TestCase): self.assertIn('business-card-box', html) self.assertIn('employment-end-box', html) self.assertIn('data-conditional-target="business-card-box"', html) - self.assertIn('data-conditional-target="phone-box"', html) + self.assertNotIn('data-conditional-target="phone-box"', html) def test_onboarding_page_uses_stored_conditional_rule_config(self): FormConditionalRuleConfig.objects.update_or_create( @@ -193,6 +193,39 @@ class OnboardingFlowTests(TestCase): self.assertLess(html.index('Bürostandort'), html.index('Anrede')) + def test_phone_direct_dial_field_is_visible_without_successor(self): + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Telefon-Direktwahl', html) + self.assertNotIn('data-conditional-target="phone-box"', html) + + def test_onboarding_custom_section_is_rendered_in_navigation(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('Benefits', html) + self.assertIn('Essenszuschuss', html) + @patch('workflows.views.process_onboarding_request.delay') def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay): FormCustomFieldConfig.objects.create( diff --git a/backend/workflows/tests/test_pdf_sections.py b/backend/workflows/tests/test_pdf_sections.py index 80ee418..8ae826b 100644 --- a/backend/workflows/tests/test_pdf_sections.py +++ b/backend/workflows/tests/test_pdf_sections.py @@ -1,6 +1,6 @@ from django.test import TestCase -from workflows.models import FormCustomFieldConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest +from workflows.models import FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormSectionConfig, OffboardingRequest, OnboardingRequest from workflows.pdf_sections import build_pdf_sections @@ -123,3 +123,38 @@ class PDFSectionBuilderTests(TestCase): self.assertEqual(custom_field['label'], 'Bürostandort') self.assertEqual(custom_field['display_value'], 'Berlin Mitte') + + def test_custom_section_title_is_used_in_pdf_sections(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='text', + is_active=True, + label='Essenszuschuss', + ) + request_obj = OnboardingRequest.objects.create( + full_name='Max Mustermann', + gender='herr', + job_title='Consultant', + department='IT-Service', + work_email='max.mustermann@workdock.de', + contract_start='2026-11-01', + employment_type='unbefristet', + agreement='accepted', + custom_field_values={'meal_allowance': 'Ja'}, + ) + + sections = build_pdf_sections('onboarding', request_obj, 'de') + + custom_section = next(section for section in sections if section['key'] == 'benefits') + self.assertEqual(custom_section['title'], 'Benefits') + self.assertIn('custom__meal_allowance', [field['name'] for field in custom_section['fields']]) diff --git a/backend/workflows/views.py b/backend/workflows/views.py index ad2422d..f061f4f 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -45,20 +45,20 @@ from .form_builder import ( OFFBOARDING_PAGE_LABELS, OFFBOARDING_PAGE_ORDER, ONBOARDING_DEFAULT_PAGE, - ONBOARDING_PAGE_LABELS, - ONBOARDING_PAGE_ORDER, build_custom_field_key, custom_field_target_key, ensure_form_field_configs, ensure_form_conditional_rule_configs, ensure_form_section_configs, get_custom_field_configs, + get_custom_section_configs, get_default_page_map, + get_section_definitions, get_section_labels, get_section_order, apply_form_preset, ) -from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig +from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormConditionalRuleConfig, FormCustomFieldConfig, FormCustomSectionConfig, FormFieldConfig, FormOption, FormSectionConfig, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig from .emailing import send_system_email from .notifications import notify_user from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability @@ -106,7 +106,6 @@ ONBOARDING_GROUPS = { 'extra-software-box': ['additional_software_multi', 'additional_software'], 'extra-access-box': ['additional_access_text'], 'successor-box': ['successor_name', 'inherit_phone_number_choice'], - 'phone-box': ['phone_number_choice'], } ONBOARDING_INLINE_CHECKS = {'order_business_cards', 'agreement_confirm'} @@ -119,7 +118,6 @@ ONBOARDING_CHECKBOX_LISTS = { 'needed_workspace_groups_multi', 'needed_resources_multi', } -ONBOARDING_SECTION_ORDER = ['stammdaten', 'vertrag', 'itsetup', 'abschluss'] ONBOARDING_SECTION_META = { 'stammdaten': {'title': gettext_lazy('Stammdaten'), 'subtitle': gettext_lazy('Person, Rolle, Abteilung')}, 'vertrag': {'title': gettext_lazy('Vertrag'), 'subtitle': gettext_lazy('Beschäftigung und Termine')}, @@ -602,21 +600,24 @@ def _section_for_block(block: dict, field_pages: dict[str, str]) -> str: def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], visible_section_keys: set[str] | None = None) -> list[dict]: - grouped = {key: [] for key in ONBOARDING_SECTION_ORDER} + section_defs = get_section_definitions('onboarding') + section_order = [item['key'] for item in section_defs] + section_titles = {item['key']: item['title'] for item in section_defs} + grouped = {key: [] for key in section_order} for block in blocks: section_key = _section_for_block(block, field_pages) if section_key not in grouped: section_key = 'abschluss' grouped[section_key].append(block) - visible_keys = visible_section_keys or set(ONBOARDING_SECTION_ORDER) + visible_keys = visible_section_keys or set(section_order) return [ { 'key': key, - 'title': ONBOARDING_SECTION_META[key]['title'], - 'subtitle': ONBOARDING_SECTION_META[key]['subtitle'], + 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), + 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), 'blocks': grouped[key], } - for key in ONBOARDING_SECTION_ORDER + for key in section_order if key in visible_keys ] @@ -1930,10 +1931,14 @@ def onboarding_create(request): onboarding_blocks = _build_onboarding_layout(form) field_pages = getattr(form, '_field_page_keys', {}) section_configs = ensure_form_section_configs('onboarding') - visible_section_keys = { - key for key in ONBOARDING_SECTION_ORDER - if key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible - } + visible_section_keys = set() + for section in get_section_definitions('onboarding'): + key = section['key'] + if section.get('is_custom'): + if section.get('is_active', True): + visible_section_keys.add(key) + elif key in LOCKED_SECTION_RULES.get('onboarding', set()) or section_configs.get(key, None) is None or section_configs[key].is_visible: + visible_section_keys.add(key) onboarding_sections = _build_onboarding_sections(onboarding_blocks, field_pages, visible_section_keys=visible_section_keys) onboarding_conditional_rules = _normalized_conditional_rule_payload('onboarding') @@ -2162,9 +2167,11 @@ def offboarding_success(request, request_id: int): def form_builder_page(request): language_code = get_language() form_type = request.GET.get('form_type', 'onboarding') + can_override_locked_builder_rules = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER anchor = (request.GET.get('anchor') or '').strip() active_panel = (request.GET.get('panel') or '').strip() active_subpanel = (request.GET.get('subpanel') or '').strip() + active_rules_panel = (request.GET.get('rules_panel') or '').strip() if form_type not in DEFAULT_FIELD_ORDER: form_type = 'onboarding' option_category = request.GET.get('option_category', 'department') @@ -2267,6 +2274,54 @@ def form_builder_page(request): _audit(request, 'form_field_texts_saved', target_type='form_config', target_label=form_type, details={'count': len(field_ids)}) messages.success(request, 'Feldtexte wurden gespeichert.') + elif action == 'add_custom_section' and form_type == 'onboarding': + title = (request.POST.get('custom_section_title') or '').strip() + title_en = (request.POST.get('custom_section_title_en') or '').strip() + sort_order_raw = (request.POST.get('custom_section_sort_order') or '').strip() + if not title: + messages.error(request, 'Bitte einen Titel für den benutzerdefinierten Abschnitt angeben.') + else: + section_key_base = build_custom_field_key(title) + section_key = section_key_base + suffix = 2 + while FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).exists(): + section_key = f'{section_key_base}_{suffix}' + suffix += 1 + try: + sort_order = int(sort_order_raw or 0) + except ValueError: + sort_order = 0 + FormCustomSectionConfig.objects.create( + form_type=form_type, + section_key=section_key, + sort_order=max(0, sort_order), + title=title, + title_en=title_en, + is_active=True, + ) + _audit(request, 'form_custom_section_added', target_type='form_custom_section', target_label=title, details={'form_type': form_type, 'section_key': section_key}) + messages.success(request, 'Benutzerdefinierter Abschnitt wurde hinzugefügt.') + + elif action == 'save_custom_sections' and form_type == 'onboarding': + section_ids = request.POST.getlist('custom_section_ids') + updated = 0 + for raw_id in section_ids: + cfg = FormCustomSectionConfig.objects.filter(id=raw_id, form_type=form_type).first() + if not cfg: + continue + try: + sort_order = int((request.POST.get(f'custom_section_sort_order_{cfg.id}') or '').strip() or cfg.sort_order) + except ValueError: + sort_order = cfg.sort_order + cfg.title = (request.POST.get(f'custom_section_title_{cfg.id}') or '').strip() or cfg.title + cfg.title_en = (request.POST.get(f'custom_section_title_en_{cfg.id}') or '').strip() + cfg.is_active = request.POST.get(f'custom_section_is_active_{cfg.id}') == 'on' + cfg.sort_order = max(0, sort_order) + cfg.save(update_fields=['title', 'title_en', 'is_active', 'sort_order']) + updated += 1 + _audit(request, 'form_custom_sections_saved', target_type='form_custom_section', target_label=form_type, details={'count': updated}) + messages.success(request, 'Benutzerdefinierte Abschnitte wurden gespeichert.') + elif action == 'add_custom_field': label = (request.POST.get('custom_label') or '').strip() label_en = (request.POST.get('custom_label_en') or '').strip() @@ -2360,7 +2415,7 @@ def form_builder_page(request): cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first() if not cfg: continue - if cfg.field_name in locked_fields: + if cfg.field_name in locked_fields and not can_override_locked_builder_rules: cfg.is_visible = True cfg.is_required = None else: @@ -2375,9 +2430,27 @@ def form_builder_page(request): elif action == 'save_section_rules' and form_type in {'onboarding', 'offboarding'}: section_configs = ensure_form_section_configs(form_type) locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) + posted_order = request.POST.getlist('section_order') + next_sort_order = 0 updated = 0 + for section_key in posted_order: + cfg = section_configs.get(section_key) + if cfg is not None: + if cfg.sort_order != next_sort_order: + cfg.sort_order = next_sort_order + cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 + continue + if form_type == 'onboarding': + custom_cfg = FormCustomSectionConfig.objects.filter(form_type=form_type, section_key=section_key).first() + if custom_cfg and custom_cfg.sort_order != next_sort_order: + custom_cfg.sort_order = next_sort_order + custom_cfg.save(update_fields=['sort_order']) + updated += 1 + next_sort_order += 1 for section_key, cfg in section_configs.items(): - if section_key in locked_sections: + if section_key in locked_sections and not can_override_locked_builder_rules: if not cfg.is_visible: cfg.is_visible = True cfg.save(update_fields=['is_visible']) @@ -2385,6 +2458,11 @@ def form_builder_page(request): cfg.is_visible = request.POST.get(f'section_visible_{section_key}') == 'on' cfg.save(update_fields=['is_visible']) updated += 1 + if form_type == 'onboarding': + for cfg in FormCustomSectionConfig.objects.filter(form_type=form_type): + cfg.is_active = request.POST.get(f'section_visible_{cfg.section_key}') == 'on' + cfg.save(update_fields=['is_active']) + updated += 1 _audit(request, 'form_section_rules_saved', target_type='form_config', target_label=form_type, details={'count': updated}) messages.success(request, 'Abschnittsregeln wurden gespeichert.') @@ -2412,13 +2490,14 @@ def form_builder_page(request): elif action == 'apply_preset': preset_key = (request.POST.get('preset_key') or '').strip() if apply_form_preset(form_type, preset_key): - active_panel = 'builder-preview' + active_panel = 'builder-content' + active_subpanel = 'preview' _audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key}) messages.success(request, 'Preset wurde angewendet.') else: messages.error(request, 'Preset konnte nicht angewendet werden.') - if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields'}: + if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields', 'add_custom_section', 'save_custom_sections'}: active_panel = 'builder-content' if action in {'add_option', 'save_options'}: active_subpanel = 'options' @@ -2426,13 +2505,23 @@ def form_builder_page(request): active_subpanel = 'field-texts' elif action in {'add_custom_field', 'save_custom_fields'}: active_subpanel = 'custom-fields' + elif action in {'add_custom_section', 'save_custom_sections'}: + active_subpanel = 'custom-sections' elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: active_panel = 'builder-rules' + if action == 'save_section_rules': + active_rules_panel = 'section-rules' + elif action == 'save_field_rules': + active_rules_panel = 'field-rules' + elif action == 'save_conditional_rules': + active_rules_panel = 'conditional-rules' redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" if active_panel: redirect_target += f"&panel={active_panel}" if active_subpanel: redirect_target += f"&subpanel={active_subpanel}" + if active_rules_panel: + redirect_target += f"&rules_panel={active_rules_panel}" if anchor == 'builder-content' or active_panel == 'builder-content': redirect_target += "#builder-content" return redirect(redirect_target) @@ -2450,8 +2539,9 @@ def form_builder_page(request): ensure_form_field_configs(form_type, default_names) section_configs = ensure_form_section_configs(form_type) conditional_rule_configs = ensure_form_conditional_rule_configs(form_type) if form_type == 'onboarding' else {} - section_order = get_section_order(form_type) - section_labels = get_section_labels(form_type) + section_definitions = get_section_definitions(form_type, include_inactive_custom=True) + section_order = [item['key'] for item in section_definitions] + section_labels = {item['key']: item['title'] for item in section_definitions} default_page_map = get_default_page_map(form_type) configs = list( @@ -2461,15 +2551,16 @@ def form_builder_page(request): locked = LOCKED_FIELD_RULES.get(form_type, set()) locked_sections = LOCKED_SECTION_RULES.get(form_type, set()) custom_field_configs = list(FormCustomFieldConfig.objects.filter(form_type=form_type).order_by('section_key', 'sort_order', 'field_key')) + custom_section_configs = get_custom_section_configs(form_type, include_inactive=True) if form_type == 'onboarding': columns = [ { 'key': key, - 'title': ONBOARDING_PAGE_LABELS.get(key, key), + 'title': section_labels.get(key, key), 'items': [], } - for key in ONBOARDING_PAGE_ORDER + for key in section_order ] column_by_key = {c['key']: c for c in columns} fallback = 'abschluss' @@ -2564,15 +2655,20 @@ def form_builder_page(request): section_rule_items = [] if section_order: fallback_section = section_order[-1] if section_order else '' + custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs} for key in section_order: cfg = section_configs.get(key) + custom_cfg = custom_section_map.get(key) + is_custom = custom_cfg is not None section_rule_items.append( { 'key': key, 'title': section_labels.get(key, key), - 'is_visible': True if not cfg else cfg.is_visible, - 'locked': key in locked_sections, - 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]), + 'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible), + 'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules), + 'is_custom': is_custom, + 'sort_order': custom_cfg.sort_order if is_custom else (cfg.sort_order if cfg else 0), + 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]), } ) @@ -2588,7 +2684,7 @@ def form_builder_page(request): 'page_label': section_labels.get(page_key, page_key) if section_order else '', 'is_visible': cfg.is_visible, 'is_required': cfg.is_required, - 'locked': cfg.field_name in locked, + 'locked': cfg.field_name in locked and not can_override_locked_builder_rules, } ) @@ -2659,7 +2755,6 @@ def form_builder_page(request): 'extra-software-box': _('Zusätzliche Software'), 'extra-access-box': _('Zusätzliche Zugänge'), 'successor-box': _('Nachfolge'), - 'phone-box': _('Direktwahl'), } conditional_target_descriptions = { 'business-card-box': _('Steuert die Detailfelder für Visitenkarten.'), @@ -2669,7 +2764,6 @@ def form_builder_page(request): 'extra-software-box': _('Steuert zusätzliche Software-Felder.'), 'extra-access-box': _('Steuert zusätzliche Zugangsangaben.'), 'successor-box': _('Steuert Nachfolge- und Übernahmefelder.'), - 'phone-box': _('Steuert die manuelle Direktwahl.'), } for target_key, cfg in conditional_rule_configs.items(): clauses = list(cfg.clauses or []) @@ -2704,7 +2798,8 @@ def form_builder_page(request): for key in section_order: section_cfg = section_configs.get(key) section_locked = key in locked_sections - section_visible = True if section_locked or not section_cfg else section_cfg.is_visible + custom_section = next((cfg for cfg in custom_section_configs if cfg.section_key == key), None) + section_visible = bool(custom_section.is_active) if custom_section else (True if section_locked or not section_cfg else section_cfg.is_visible) visible_items = [ item for item in field_rule_group_map.get(key, []) if item['locked'] or item['is_visible'] @@ -2738,6 +2833,7 @@ def form_builder_page(request): 'hidden_field_count': hidden_field_count, 'hidden_section_count': hidden_section_count, 'custom_field_count': len([cfg for cfg in custom_field_configs if cfg.is_active]), + 'custom_section_count': len([cfg for cfg in custom_section_configs if cfg.is_active]), } return render( @@ -2760,9 +2856,12 @@ def form_builder_page(request): 'conditional_rule_items': conditional_rule_items, 'custom_field_groups': custom_field_groups, 'custom_field_type_choices': _translate_choice_list(FormCustomFieldConfig.FIELD_TYPE_CHOICES), + 'custom_section_items': custom_section_configs, 'active_panel': active_panel, 'active_subpanel': active_subpanel, + 'active_rules_panel': active_rules_panel, 'available_presets': FORM_PRESETS.get(form_type, {}), + 'can_override_locked_builder_rules': can_override_locked_builder_rules, }, ) @@ -3213,10 +3312,8 @@ def form_builder_save_order(request): allowed_names = {cfg.field_name for cfg in configs} | {f'custom__{cfg.field_key}' for cfg in custom_configs} seen = set() - if form_type == 'onboarding': - allowed_columns = ONBOARDING_PAGE_ORDER - else: - allowed_columns = OFFBOARDING_PAGE_ORDER + allowed_columns = get_section_order(form_type) + fallback_section = allowed_columns[-1] if allowed_columns else '' name_to_cfg = {cfg.field_name: cfg for cfg in configs} custom_name_to_cfg = {f'custom__{cfg.field_key}': cfg for cfg in custom_configs} @@ -3248,20 +3345,14 @@ def form_builder_save_order(request): cfg = name_to_cfg[name] cfg.sort_order = sort_order sort_order += 1 - if form_type == 'onboarding': - cfg.page_key = cfg.page_key or ONBOARDING_DEFAULT_PAGE.get(name, 'abschluss') - else: - cfg.page_key = cfg.page_key or default_page_map.get(name, OFFBOARDING_PAGE_ORDER[-1]) + cfg.page_key = cfg.page_key or default_page_map.get(name, fallback_section) missing_custom = [name for name in custom_name_to_cfg.keys() if name not in seen] for name in missing_custom: cfg = custom_name_to_cfg[name] cfg.sort_order = sort_order sort_order += 1 - if form_type == 'onboarding': - cfg.section_key = cfg.section_key or 'abschluss' - else: - cfg.section_key = cfg.section_key or OFFBOARDING_PAGE_ORDER[-1] + cfg.section_key = cfg.section_key or fallback_section FormFieldConfig.objects.bulk_update(configs, ['sort_order', 'page_key']) if custom_configs: From b0cc5bda78253906d87e83f1a14a8668b976255a Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 17:02:06 +0100 Subject: [PATCH 23/45] snapshot: preserve builder deletion and onboarding ux improvements --- .../static/workflows/css/form_builder.css | 79 +++++++------ .../static/workflows/css/onboarding_form.css | 12 ++ .../static/workflows/js/form_builder.js | 106 +++++------------- .../static/workflows/js/onboarding_form.js | 40 +++++++ .../templates/workflows/form_builder.html | 29 +++-- .../templates/workflows/onboarding_form.html | 19 +++- .../tests/test_form_builder_admin.py | 54 +++++++++ .../workflows/tests/test_onboarding_flow.py | 35 ++++++ backend/workflows/views.py | 66 +++++++++-- 9 files changed, 300 insertions(+), 140 deletions(-) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index f6db87f..4f7ce6c 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -977,6 +977,14 @@ body { gap: 14px; } +.builder-card-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + .builder-entity-card-head strong { color: #142033; font-size: 15px; @@ -1003,6 +1011,7 @@ body { .builder-switch-stack { display: grid; gap: 8px; + justify-items: end; } .builder-switch input[type='checkbox'], @@ -1031,61 +1040,62 @@ body { gap: 10px; } -.section-rule-grid.drag-over { - outline: 1px dashed #9db4d2; - outline-offset: 6px; -} - .section-rule-card { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; - padding: 13px 14px; + padding: 14px 16px; border: 1px solid #d6e0ec; - border-radius: 14px; - background: linear-gradient(180deg, #f9fbff, #ffffff); - cursor: move; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; width: 100%; } .section-rule-card:hover { transform: translateY(-1px); - box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); - border-color: #b8cae0; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.07); + border-color: #b2c6df; } .section-rule-card.is-locked { - background: linear-gradient(180deg, #f4f7fb, #fafcff); + background: linear-gradient(180deg, #f5f8fc, #fbfdff); } -.section-rule-card.dragging { - opacity: 0.58; -} - -.section-rule-card.manual-dragging { - opacity: 0.72; - border-color: #8fb1d8; - box-shadow: 0 16px 30px rgba(15, 23, 42, 0.10); -} - -.section-rule-drag { - color: #8aa0be; - font-size: 18px; - letter-spacing: -2px; - user-select: none; - align-self: stretch; +.section-rule-actions { display: inline-flex; align-items: center; + gap: 6px; padding-right: 2px; - cursor: grab; } -body.builder-dragging, -body.builder-dragging * { - cursor: grabbing !important; - user-select: none !important; +.section-move-btn { + width: 34px; + height: 34px; + border: 1px solid #cdd9e8; + border-radius: 11px; + background: linear-gradient(180deg, #ffffff, #f5f9ff); + color: #274264; + font-size: 15px; + font-weight: 700; + line-height: 1; + cursor: pointer; + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.04); + transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease; +} + +.section-move-btn:hover { + transform: translateY(-1px); + border-color: #9db4d2; + background: linear-gradient(180deg, #ffffff, #eef5ff); + box-shadow: 0 10px 16px rgba(15, 23, 42, 0.07); +} + +.section-move-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; } .section-rule-copy { @@ -1096,7 +1106,8 @@ body.builder-dragging * { .section-rule-copy strong { color: #0f172a; - font-size: 14px; + font-size: 15px; + font-weight: 700; overflow-wrap: anywhere; } diff --git a/backend/workflows/static/workflows/css/onboarding_form.css b/backend/workflows/static/workflows/css/onboarding_form.css index a787442..9030da4 100644 --- a/backend/workflows/static/workflows/css/onboarding_form.css +++ b/backend/workflows/static/workflows/css/onboarding_form.css @@ -192,6 +192,10 @@ h1 { } .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; margin-bottom: 12px; border-bottom: 1px dashed #dde4f1; padding-bottom: 8px; @@ -210,6 +214,9 @@ h1 { } .section-itsetup .section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; margin: -14px -14px 16px; padding: 12px 14px; border-bottom: 1px solid #d5e2f9; @@ -348,6 +355,11 @@ h1 { transform: translateY(1px); } +.section-toggle-btn { + flex-shrink: 0; + margin-left: auto; +} + .itsetup-checklist-body { padding: 0; background: #ffffff; diff --git a/backend/workflows/static/workflows/js/form_builder.js b/backend/workflows/static/workflows/js/form_builder.js index 1fceee8..393cf0e 100644 --- a/backend/workflows/static/workflows/js/form_builder.js +++ b/backend/workflows/static/workflows/js/form_builder.js @@ -148,92 +148,36 @@ const sectionRuleGrid = document.getElementById('section-rule-grid'); if (sectionRuleGrid) { - let draggingSectionCard = null; - let manualDraggingSectionCard = null; - - function getSectionInsertBeforeNode(mouseY) { - const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.dragging)')); - return cards.find((card) => { - const box = card.getBoundingClientRect(); - return mouseY < box.top + box.height / 2; + function updateSectionMoveButtons() { + const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card')); + cards.forEach((card, index) => { + const upBtn = card.querySelector('[data-move-section="up"]'); + const downBtn = card.querySelector('[data-move-section="down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === cards.length - 1; }); } - function getManualSectionInsertBeforeNode(mouseY) { - const cards = Array.from(sectionRuleGrid.querySelectorAll('.section-rule-card:not(.manual-dragging)')); - return cards.find((card) => { - const box = card.getBoundingClientRect(); - return mouseY < box.top + box.height / 2; - }); - } - - function onManualSectionMove(event) { - if (!manualDraggingSectionCard) return; - event.preventDefault(); - sectionRuleGrid.classList.add('drag-over'); - const beforeNode = getManualSectionInsertBeforeNode(event.clientY); - if (beforeNode) { - sectionRuleGrid.insertBefore(manualDraggingSectionCard, beforeNode); - } else { - sectionRuleGrid.appendChild(manualDraggingSectionCard); - } - } - - function endManualSectionDrag() { - if (!manualDraggingSectionCard) return; - manualDraggingSectionCard.classList.remove('manual-dragging'); - manualDraggingSectionCard = null; - sectionRuleGrid.classList.remove('drag-over'); - document.body.classList.remove('builder-dragging'); - document.removeEventListener('mousemove', onManualSectionMove); - document.removeEventListener('mouseup', endManualSectionDrag); - } - - sectionRuleGrid.querySelectorAll('.section-rule-card').forEach((card) => { - card.addEventListener('dragstart', (event) => { - draggingSectionCard = card; - card.classList.add('dragging'); - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', card.dataset.sectionKey || ''); - }); - card.addEventListener('dragend', () => { - card.classList.remove('dragging'); - sectionRuleGrid.classList.remove('drag-over'); - draggingSectionCard = null; - }); - const handle = card.querySelector('.section-rule-drag'); - if (handle) { - handle.addEventListener('mousedown', (event) => { - event.preventDefault(); - manualDraggingSectionCard = card; - card.classList.add('manual-dragging'); - sectionRuleGrid.classList.add('drag-over'); - document.body.classList.add('builder-dragging'); - document.addEventListener('mousemove', onManualSectionMove); - document.addEventListener('mouseup', endManualSectionDrag); - }); + sectionRuleGrid.addEventListener('click', (event) => { + const button = event.target.closest('[data-move-section]'); + if (!button) return; + const card = button.closest('.section-rule-card'); + if (!card) return; + const direction = button.dataset.moveSection; + if (direction === 'up') { + const previousCard = card.previousElementSibling; + if (previousCard) { + sectionRuleGrid.insertBefore(card, previousCard); + } + } else if (direction === 'down') { + const nextCard = card.nextElementSibling; + if (nextCard) { + sectionRuleGrid.insertBefore(nextCard, card); + } } + updateSectionMoveButtons(); }); - sectionRuleGrid.addEventListener('dragover', (event) => { - event.preventDefault(); - sectionRuleGrid.classList.add('drag-over'); - if (!draggingSectionCard) return; - const beforeNode = getSectionInsertBeforeNode(event.clientY); - if (beforeNode) { - sectionRuleGrid.insertBefore(draggingSectionCard, beforeNode); - } else { - sectionRuleGrid.appendChild(draggingSectionCard); - } - }); - - sectionRuleGrid.addEventListener('dragleave', () => { - sectionRuleGrid.classList.remove('drag-over'); - }); - - sectionRuleGrid.addEventListener('drop', (event) => { - event.preventDefault(); - sectionRuleGrid.classList.remove('drag-over'); - }); + updateSectionMoveButtons(); } })(); diff --git a/backend/workflows/static/workflows/js/onboarding_form.js b/backend/workflows/static/workflows/js/onboarding_form.js index 534355a..e6de8a3 100644 --- a/backend/workflows/static/workflows/js/onboarding_form.js +++ b/backend/workflows/static/workflows/js/onboarding_form.js @@ -222,6 +222,45 @@ }); } + function setupSectionCheckboxToggles() { + document.querySelectorAll('[data-section-checkbox-toggle]').forEach(function (button) { + const sectionCard = button.closest('.section-card'); + if (!sectionCard) return; + + const getCheckboxes = function () { + return Array.from(sectionCard.querySelectorAll('.custom-section-checkbox input[type="checkbox"]')); + }; + + const refreshButtonLabel = function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) { + button.classList.add('hidden'); + return; + } + button.classList.remove('hidden'); + const allChecked = checkboxes.every(function (box) { return box.checked; }); + button.textContent = allChecked ? (button.dataset.labelClear || 'Auswahl aufheben') : (button.dataset.labelSelect || 'Alle auswählen'); + }; + + button.addEventListener('click', function () { + const checkboxes = getCheckboxes(); + if (!checkboxes.length) return; + const shouldCheck = checkboxes.some(function (box) { return !box.checked; }); + checkboxes.forEach(function (box) { + box.checked = shouldCheck; + box.dispatchEvent(new Event('change', { bubbles: true })); + }); + refreshButtonLabel(); + }); + + getCheckboxes().forEach(function (box) { + box.addEventListener('change', refreshButtonLabel); + }); + + refreshButtonLabel(); + }); + } + function setupChecklistColumns() { document.querySelectorAll('.itsetup-checklist-body > [id^="id_"]').forEach(function (container) { const itemCount = container.querySelectorAll(':scope > div').length; @@ -271,6 +310,7 @@ setupWorkEmailAutofill(); setupBusinessCardAutofill(); setupChecklistToggles(); + setupSectionCheckboxToggles(); setupChecklistColumns(); jumpToFirstErrorPage(); updateStep(); diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 80fc5ec..1380495 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -98,11 +98,17 @@ {% for section in section_rule_items %}
- +
+ + +
{{ section.title }} {% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %} @@ -491,10 +497,13 @@ {{ item.title }}
{{ item.section_key }}
- +
+ + +
-
+
-
+ +
-
- -
{% empty %}
{% trans "Keine eigenen Felder vorhanden." %}
diff --git a/backend/workflows/templates/workflows/onboarding_form.html b/backend/workflows/templates/workflows/onboarding_form.html index 295f06a..78dfda4 100644 --- a/backend/workflows/templates/workflows/onboarding_form.html +++ b/backend/workflows/templates/workflows/onboarding_form.html @@ -50,8 +50,19 @@
-

{{ section.title }}

-

{{ section.subtitle }}

+
+

{{ section.title }}

+

{{ section.subtitle }}

+
+ {% if section.has_custom_checkbox_fields %} + + {% endif %}
{% for block in section.blocks %} @@ -78,7 +89,7 @@
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
+
{{ field }} {{ field.label_tag }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} {{ field.errors }} @@ -121,7 +132,7 @@
{% elif field.name in onboarding_inline_checks or field.field.widget.input_type == 'checkbox' %} -
+
{{ field }} {{ field.label_tag }} {% if field.help_text %}
{{ field.help_text }}
{% endif %} {{ field.errors }} diff --git a/backend/workflows/tests/test_form_builder_admin.py b/backend/workflows/tests/test_form_builder_admin.py index 85bd536..a1c6d22 100644 --- a/backend/workflows/tests/test_form_builder_admin.py +++ b/backend/workflows/tests/test_form_builder_admin.py @@ -331,6 +331,60 @@ class FormBuilderAdminTests(TestCase): self.assertEqual(section.title, 'Benefits') self.assertEqual(section.sort_order, 5) + def test_staff_can_delete_custom_field(self): + self.client.force_login(self.staff) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='laptop_tag', + section_key='itsetup', + sort_order=0, + field_type='text', + label='Laptop-Tag', + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_field_id': str(field.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + + def test_staff_can_delete_custom_section_and_its_fields(self): + self.client.force_login(self.staff) + section = FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=0, + title='Benefits', + ) + field = FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='checkbox', + label='Essenszuschuss', + ) + FormConditionalRuleConfig.objects.create( + form_type='onboarding', + target_key='custom__meal_allowance', + clauses=[{'field': 'employment_type', 'operator': 'equals', 'value': 'unbefristet'}], + is_active=True, + ) + + response = self.client.post( + '/admin-tools/form-builder/?form_type=onboarding&option_category=device', + data={'delete_custom_section_id': str(section.id)}, + HTTP_HOST='localhost', + ) + + self.assertEqual(response.status_code, 302) + self.assertFalse(FormCustomSectionConfig.objects.filter(id=section.id).exists()) + self.assertFalse(FormCustomFieldConfig.objects.filter(id=field.id).exists()) + self.assertFalse(FormConditionalRuleConfig.objects.filter(target_key='custom__meal_allowance').exists()) + def test_save_order_accepts_custom_section_column(self): self.client.force_login(self.staff) FormCustomSectionConfig.objects.create( diff --git a/backend/workflows/tests/test_onboarding_flow.py b/backend/workflows/tests/test_onboarding_flow.py index 5da61f5..84d0efc 100644 --- a/backend/workflows/tests/test_onboarding_flow.py +++ b/backend/workflows/tests/test_onboarding_flow.py @@ -226,6 +226,41 @@ class OnboardingFlowTests(TestCase): self.assertIn('Benefits', html) self.assertIn('Essenszuschuss', html) + def test_onboarding_custom_section_with_checkbox_fields_shows_section_select_all(self): + FormCustomSectionConfig.objects.create( + form_type='onboarding', + section_key='benefits', + sort_order=10, + title='Benefits', + is_active=True, + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='meal_allowance', + section_key='benefits', + sort_order=0, + field_type='checkbox', + is_active=True, + label='Essenszuschuss', + ) + FormCustomFieldConfig.objects.create( + form_type='onboarding', + field_key='parking_spot', + section_key='benefits', + sort_order=1, + field_type='checkbox', + is_active=True, + label='Parkplatz', + ) + + response = self.client.get('/onboarding/new/', HTTP_HOST='localhost') + html = response.content.decode('utf-8') + + self.assertEqual(response.status_code, 200) + self.assertIn('data-section-checkbox-toggle', html) + self.assertIn('Essenszuschuss', html) + self.assertIn('Parkplatz', html) + @patch('workflows.views.process_onboarding_request.delay') def test_onboarding_custom_field_is_rendered_and_saved(self, mock_delay): FormCustomFieldConfig.objects.create( diff --git a/backend/workflows/views.py b/backend/workflows/views.py index f061f4f..9f3faa0 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -610,16 +610,33 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str], section_key = 'abschluss' grouped[section_key].append(block) visible_keys = visible_section_keys or set(section_order) - return [ - { - 'key': key, - 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), - 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), - 'blocks': grouped[key], - } - for key in section_order - if key in visible_keys - ] + sections = [] + custom_section_keys = {item['key'] for item in section_defs if item.get('is_custom')} + for key in section_order: + if key not in visible_keys: + continue + blocks_for_section = grouped[key] + has_custom_checkbox_fields = False + for block in blocks_for_section: + candidate_fields = [block['field']] if block['kind'] == 'field' else (block.get('fields') or []) + for bound_field in candidate_fields: + widget_type = getattr(getattr(bound_field.field, 'widget', None), 'input_type', '') + if bound_field.name.startswith('custom__') and widget_type == 'checkbox': + has_custom_checkbox_fields = True + break + if has_custom_checkbox_fields: + break + sections.append( + { + 'key': key, + 'title': section_titles.get(key, ONBOARDING_SECTION_META.get(key, {}).get('title', key)), + 'subtitle': ONBOARDING_SECTION_META.get(key, {}).get('subtitle', ''), + 'blocks': blocks_for_section, + 'is_custom': key in custom_section_keys, + 'has_custom_checkbox_fields': has_custom_checkbox_fields, + } + ) + return sections OFFBOARDING_SECTION_META = { @@ -2182,6 +2199,7 @@ def form_builder_page(request): if request.method == 'POST': delete_option_id = request.POST.get('delete_option_id', '').strip() delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip() + delete_custom_section_id = request.POST.get('delete_custom_section_id', '').strip() if delete_option_id: option = FormOption.objects.filter(id=delete_option_id).first() if not option: @@ -2205,6 +2223,34 @@ def form_builder_page(request): _audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.') return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") + if delete_custom_section_id: + custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first() + if not custom_section: + messages.error(request, 'Benutzerdefinierter Abschnitt nicht gefunden.') + else: + deleted_label = custom_section.title + deleted_id = custom_section.id + section_key = custom_section.section_key + custom_fields = list(FormCustomFieldConfig.objects.filter(form_type=form_type, section_key=section_key)) + deleted_field_count = len(custom_fields) + if custom_fields: + field_keys = [item.field_key for item in custom_fields] + FormConditionalRuleConfig.objects.filter( + form_type=form_type, + target_key__in=[f'custom__{field_key}' for field_key in field_keys], + ).delete() + FormCustomFieldConfig.objects.filter(id__in=[item.id for item in custom_fields]).delete() + custom_section.delete() + _audit( + request, + 'form_custom_section_deleted', + target_type='form_custom_section', + target_id=deleted_id, + target_label=deleted_label, + details={'section_key': section_key, 'deleted_field_count': deleted_field_count}, + ) + messages.success(request, 'Benutzerdefinierter Abschnitt wurde gelöscht.') + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-sections#builder-content") action = request.POST.get('builder_action', '') if action == 'add_option': From 61e3fae18d860bf3f998d96cd44b45668d469474 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 21:06:03 +0100 Subject: [PATCH 24/45] refine form builder workspace interactions --- .../static/workflows/css/form_builder.css | 864 +++++++++++++++--- .../templates/workflows/form_builder.html | 741 +++++++++------ backend/workflows/views.py | 117 ++- 3 files changed, 1290 insertions(+), 432 deletions(-) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index 4f7ce6c..dc9b3e4 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -18,6 +18,121 @@ body { backdrop-filter: blur(12px); } +.builder-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.builder-sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 14px; +} + +.builder-main { + min-width: 0; +} + +.builder-sidebar-card { + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(255, 255, 255, 0.98)); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); +} + +.builder-sidebar-card h2 { + margin: 6px 0 8px; + font-size: 20px; + color: #142033; +} + +.builder-sidebar-card p { + margin: 0; + color: #5f7089; + font-size: 13px; + line-height: 1.6; +} + +.builder-sidebar-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.builder-side-nav { + display: grid; + gap: 8px; +} + +.builder-side-link { + display: grid; + gap: 4px; + padding: 14px 16px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #ffffff); + color: #142033; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.builder-side-link:hover { + transform: translateY(-1px); + border-color: #b8cae0; + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.06); +} + +.builder-side-link.is-active { + border-color: #9eb6d8; + background: linear-gradient(180deg, #eef5ff, #ffffff); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); +} + +.builder-side-link-title { + font-size: 14px; + font-weight: 800; +} + +.builder-side-link-meta { + color: #607086; + font-size: 12px; + font-weight: 700; +} + +.builder-sidebar-stats { + display: grid; + gap: 12px; +} + +.builder-side-stat { + display: grid; + gap: 2px; +} + +.builder-side-stat strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.builder-side-stat span { + color: #607086; + font-size: 12px; + font-weight: 700; +} + .builder-hero, .builder-panel, .builder-stat-card, @@ -39,6 +154,14 @@ body { max-width: 760px; } +.builder-hero-sub { + margin: 10px 0 0; + max-width: 640px; + color: #5c6d87; + font-size: 15px; + line-height: 1.6; +} + .builder-eyebrow { display: inline-flex; align-items: center; @@ -142,12 +265,91 @@ body { box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); } +.builder-module-surface { + overflow: hidden; +} + +.builder-module-hidden { + display: none; +} + +.builder-module-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.builder-module-link { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 12px; + border: 1px solid #d1dbea; + border-radius: 999px; + background: #f7fbff; + color: #304159; + font-size: 13px; + font-weight: 800; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease; +} + +.builder-module-link:hover { + transform: translateY(-1px); + border-color: #adc2dd; + background: #ffffff; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06); +} + +.builder-module-link.is-active { + border-color: #164a99; + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); + color: #ffffff; +} + .builder-panel-head h2, .options-head h2 { margin: 0; color: #142033; } +.builder-panel-copy, +.options-copy { + min-width: 0; +} + +.builder-panel-sub, +.options-sub { + margin: 6px 0 0; + color: #61718a; + font-size: 13px; + line-height: 1.5; +} + +.builder-panel-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.builder-panel-count { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border: 1px solid #d7e1ee; + border-radius: 999px; + background: #f8fbff; + color: #294567; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.01em; + white-space: nowrap; +} + .mini { color: #61718a; font-size: 13px; @@ -234,114 +436,18 @@ body { margin-bottom: 14px; } -.builder-accordion { - padding: 0; - overflow: hidden; -} - -.nested-accordion { - padding: 0; - overflow: hidden; -} - -.nested-accordion-summary { - list-style: none; - cursor: pointer; - padding: 14px 16px; - transition: background-color 0.18s ease; -} - -.nested-accordion-summary::-webkit-details-marker { - display: none; -} - -.nested-accordion-summary .options-head { - margin-bottom: 0; -} - -.nested-accordion-summary:hover { - background: rgba(242, 247, 255, 0.72); -} - -.nested-accordion[open] .nested-accordion-summary { - border-bottom: 1px solid rgba(201, 212, 226, 0.8); -} - -.nested-accordion:not([open]) .builder-panel-toggle::after { - transform: rotate(-90deg); -} - -.nested-accordion[open] .builder-panel-toggle { - color: #194ea7; -} - -.nested-accordion-body { - padding: 14px 16px 16px; -} - -.nested-accordion[open] .nested-accordion-body { - animation: builderReveal 0.24s ease; -} - -.builder-panel-summary { - list-style: none; - cursor: pointer; - padding: 14px 16px; - transition: background-color 0.18s ease; -} - -.builder-panel-summary::-webkit-details-marker { - display: none; -} - -.builder-panel-summary .builder-panel-head { - margin-bottom: 0; -} - -.builder-panel-summary:hover { - background: rgba(242, 247, 255, 0.72); -} - -.builder-panel-toggle { - display: inline-flex; - align-items: center; - gap: 8px; - color: #5f7089; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.builder-panel-toggle::after { - content: "▾"; - font-size: 13px; - line-height: 1; - transition: transform 0.18s ease; -} - -.builder-accordion:not([open]) .builder-panel-toggle::after { - transform: rotate(-90deg); -} - -.builder-accordion[open] .builder-panel-toggle { - color: #194ea7; -} - -.builder-accordion[open] .builder-panel-toggle::before { - content: ""; -} - -.builder-accordion[open] .builder-panel-summary { - border-bottom: 1px solid rgba(201, 212, 226, 0.8); +.module-card-body { + display: grid; + gap: 14px; } .builder-panel-body { padding: 14px 16px 16px; } -.builder-accordion[open] .builder-panel-body { - animation: builderReveal 0.24s ease; +.builder-panel-body-static { + display: grid; + gap: 16px; } .builder-rule-layout { @@ -355,6 +461,10 @@ body { gap: 14px; } +.builder-module-card { + overflow: hidden; +} + .preview-shell { display: grid; gap: 12px; @@ -458,29 +568,43 @@ body { .field-rule-list { display: grid; + gap: 10px; + padding: 10px; } .field-rule-row { display: grid; - grid-template-columns: minmax(220px, 1.4fr) 120px 160px 120px; - gap: 12px; + grid-template-columns: minmax(240px, 1.5fr) minmax(120px, 0.55fr) minmax(170px, 0.7fr) auto; + gap: 14px; align-items: center; - padding: 12px 14px; - border-top: 1px solid #edf2f7; + padding: 14px; + border: 1px solid #e7edf6; + border-radius: 14px; + background: rgba(255, 255, 255, 0.96); transition: background-color 0.18s ease; } .field-rule-row:first-child { - border-top: 0; + border-top: 1px solid #e7edf6; } .field-rule-row:hover { - background: rgba(246, 250, 255, 0.92); + background: rgba(246, 250, 255, 0.96); } .field-rule-main strong { display: block; color: #162133; + font-size: 14px; +} + +.field-rule-meta { + margin-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; } .field-rule-control { @@ -491,6 +615,18 @@ body { font-weight: 700; } +.field-rule-settings { + display: flex; + align-items: end; + justify-content: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.field-rule-control-compact { + min-width: 120px; +} + .field-rule-control input[type='checkbox'] { width: 16px; height: 16px; @@ -501,6 +637,10 @@ body { justify-content: flex-end; } +.field-rule-status-inline { + justify-content: flex-start; +} + .conditional-rule-grid { display: grid; gap: 12px; @@ -573,6 +713,33 @@ body { .conditional-targets { display: grid; gap: 8px; + padding: 12px; + border: 1px solid #e7edf6; + border-radius: 14px; + background: #f9fbff; +} + +.conditional-rule-summary { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #dbe6f5; + border-radius: 14px; + background: linear-gradient(180deg, #f6faff, #ffffff); +} + +.conditional-summary-prefix { + color: #294567; + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-summary-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; } .conditional-target-label { @@ -594,6 +761,42 @@ body { gap: 10px; } +.conditional-sentence-builder { + display: grid; + gap: 10px; +} + +.conditional-sentence-row { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.3fr) minmax(160px, 0.9fr) minmax(160px, 0.9fr); + gap: 10px; + align-items: end; + padding: 12px; + border: 1px solid #e5ebf3; + border-radius: 14px; + background: #f8fbff; +} + +.conditional-sentence-label { + color: #33506f; + font-size: 12px; + font-weight: 800; + line-height: 1.45; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.conditional-sentence-row select, +.conditional-sentence-row input[type='text'] { + width: 100%; + min-height: 40px; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 8px 10px; + box-sizing: border-box; + background: #fff; +} + .conditional-clause-row { display: grid; grid-template-columns: 56px minmax(220px, 1.35fr) minmax(180px, 0.8fr) minmax(180px, 0.85fr); @@ -666,14 +869,161 @@ body { .columns { display: grid; - grid-template-columns: repeat(4, minmax(220px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; + min-width: 0; +} + +.structure-workspace { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 16px; + align-items: start; + min-width: 0; +} + +.structure-sidebar { + display: grid; + gap: 12px; + position: sticky; + top: 18px; +} + +.structure-canvas { + min-width: 0; + overflow: visible; + padding-bottom: 4px; + display: grid; + gap: 14px; +} + +.structure-section-nav { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.structure-section-pill { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 0 14px 0 10px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + color: #142033; + text-decoration: none; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; +} + +.structure-section-pill:hover { + transform: translateY(-1px); + border-color: #bfd0e4; + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.06); +} + +.structure-section-pill.is-active { + border-color: #9eb6d8; + background: linear-gradient(180deg, #eef5ff 0%, #ffffff 100%); + box-shadow: 0 12px 22px rgba(15, 23, 42, 0.08); +} + +.structure-section-pill-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 12px; + font-weight: 800; +} + +.structure-section-pill-copy { + display: grid; + gap: 1px; +} + +.structure-section-pill-copy strong { + font-size: 14px; + line-height: 1.2; +} + +.structure-section-pill-copy span { + color: #607086; + font-size: 11px; + font-weight: 700; +} + +.structure-card { + display: grid; + gap: 8px; + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); +} + +.structure-card h3 { + margin: 0; + font-size: 17px; + color: #142033; +} + +.structure-card p { + margin: 0; + color: #5f7089; + font-size: 13px; + line-height: 1.6; +} + +.structure-card-muted { + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); +} + +.structure-card-eyebrow { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 9px; + border-radius: 999px; + background: #eaf1ff; + color: #214d99; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.structure-stat { + display: grid; + gap: 2px; +} + +.structure-stat strong { + font-size: 22px; + line-height: 1; + color: #163566; +} + +.structure-stat span { + color: #607086; + font-size: 12px; + font-weight: 700; } .columns.single { grid-template-columns: minmax(320px, 1fr); } +.structure-columns-single { + grid-template-columns: 1fr; +} + .column { border: 1px solid #d7e0ec; border-radius: 16px; @@ -685,6 +1035,10 @@ body { transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; } +.column.is-collapsed { + min-height: 0; +} + .column:hover { transform: translateY(-2px); border-color: #bfd0e4; @@ -721,10 +1075,30 @@ body { flex: 1; } +.column.is-collapsed .dropzone { + display: none; +} + +.btn-compact { + min-height: 34px; + padding: 0 12px; +} + .dropzone.drag-over { background: #ecf5ff; } +.structure-empty { + padding: 14px; + border: 1px dashed #cbd7e6; + border-radius: 14px; + background: #f8fbff; + color: #61718a; + font-size: 13px; + font-weight: 700; + text-align: center; +} + .field-card { background: rgba(255, 255, 255, 0.96); border: 1px solid #d7dfeb; @@ -815,8 +1189,9 @@ body { } .category-switch select, -.option-table select, .add-option-form input, +.option-card input[type='text'], +.option-table select, .option-table input[type='text'] { border: 1px solid #cbd5e1; border-radius: 10px; @@ -832,10 +1207,6 @@ body { margin-bottom: 12px; } -.option-table-wrap { - overflow-x: auto; -} - .option-table { width: 100%; border-collapse: collapse; @@ -852,6 +1223,9 @@ body { .option-table th { background: #f8fbff; color: #3d4c63; + position: sticky; + top: 0; + z-index: 1; } .option-table-group-row th { @@ -862,15 +1236,6 @@ body { letter-spacing: 0.04em; } -.option-row { - cursor: grab; -} - -.option-row.dragging { - opacity: 0.5; - background: #ecf5ff; -} - .drag-handle { display: inline-flex; align-items: center; @@ -886,20 +1251,186 @@ body { user-select: none; } +.option-card-list { + display: grid; + gap: 12px; +} + +.option-card { + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); + cursor: grab; + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} + +.option-card:hover { + transform: translateY(-1px); + border-color: #c4d4e7; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.06); +} + +.option-row.dragging { + opacity: 0.55; + background: #ecf5ff; + box-shadow: none; +} + +.option-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.option-card-order { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} + +.option-card-title-block { + display: grid; + gap: 4px; + min-width: 0; +} + +.option-card-title-block strong { + color: #142033; + font-size: 15px; + line-height: 1.35; +} + +.option-card-meta { + color: #61718a; + font-size: 12px; + font-weight: 700; +} + +.option-card-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.option-card-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 36px; + padding: 0 12px; + border: 1px solid #d7e1ee; + border-radius: 999px; + background: #f8fbff; + color: #294567; + font-size: 12px; + font-weight: 800; +} + +.option-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.option-empty-state { + padding: 18px; + border: 1px dashed #c8d5e7; + border-radius: 16px; + background: #f8fbff; + color: #61718a; + font-size: 14px; + font-weight: 700; + text-align: center; +} + .options-actions { margin-top: 12px; display: flex; justify-content: flex-end; } +.options-actions-sticky { + position: sticky; + bottom: 0; + z-index: 2; + padding-top: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.98) 32%); +} + +.field-text-card-list { + display: grid; + gap: 12px; +} + +.field-text-card { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid #d7e0ec; + border-radius: 18px; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); +} + +.field-text-card-head strong { + color: #142033; + font-size: 15px; +} + +.field-text-card-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.custom-fields-surface .builder-entity-form { + gap: 10px; + padding: 14px; +} + +.custom-fields-surface .builder-entity-grid { + gap: 10px; +} + +.custom-fields-surface .builder-group-stack { + gap: 10px; +} + +.custom-fields-surface .builder-group-card { + border-radius: 16px; +} + +.custom-fields-surface .builder-group-head { + padding: 10px 12px; +} + +.custom-fields-surface .builder-entity-card { + padding: 14px; + gap: 10px; + border-radius: 14px; +} + +.custom-fields-surface .builder-entity-card-head strong { + font-size: 14px; +} + .builder-entity-form { display: grid; gap: 12px; margin-bottom: 14px; - padding: 14px; + padding: 16px; border: 1px solid #d7e0ec; - border-radius: 16px; - background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + border-radius: 18px; + background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); } .builder-entity-head h3, @@ -930,6 +1461,10 @@ body { font-weight: 800; } +.builder-entity-control input[type='text'] { + width: 100%; +} + .builder-entity-control-narrow { max-width: 180px; } @@ -946,9 +1481,10 @@ body { .builder-group-card { border: 1px solid #d7e0ec; - border-radius: 16px; + border-radius: 18px; background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%); overflow: hidden; + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04); } .builder-group-head { @@ -962,12 +1498,13 @@ body { } .builder-entity-card { - padding: 14px; + padding: 16px; border: 1px solid #e5ebf3; border-radius: 16px; background: #ffffff; display: grid; gap: 12px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04); } .builder-entity-card-head { @@ -998,6 +1535,14 @@ body { overflow-wrap: anywhere; } +.builder-inline-meta { + margin-top: 8px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .builder-switch, .builder-switch-inline { display: inline-flex; @@ -1042,7 +1587,7 @@ body { .section-rule-card { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: auto auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; @@ -1063,6 +1608,20 @@ body { background: linear-gradient(180deg, #f5f8fc, #fbfdff); } +.section-rule-order { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 999px; + background: #eaf1ff; + color: #23457a; + font-size: 12px; + font-weight: 800; + box-shadow: inset 0 0 0 1px #cdddff; +} + .section-rule-actions { display: inline-flex; align-items: center; @@ -1172,9 +1731,6 @@ body { transition: none; } - .builder-accordion[open] .builder-panel-body { - animation: none; - } } @media (max-width: 1220px) { @@ -1182,9 +1738,30 @@ body { .columns { grid-template-columns: repeat(2, minmax(220px, 1fr)); } + + .structure-workspace { + grid-template-columns: 1fr; + } + + .structure-sidebar { + position: static; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + } + + .structure-canvas { + overflow-x: visible; + } } @media (max-width: 900px) { + .builder-workspace { + grid-template-columns: 1fr; + } + + .builder-sidebar { + position: static; + } + .builder-hero, .builder-panel-head, .options-head, @@ -1197,6 +1774,10 @@ body { justify-content: flex-start; } + .builder-panel-meta { + justify-content: flex-start; + } + .builder-rule-layout { grid-template-columns: 1fr; } @@ -1212,11 +1793,26 @@ body { grid-template-columns: 1fr; } - .field-rule-row, - .conditional-clause-row { + .structure-sidebar { grid-template-columns: 1fr; } + .field-rule-row, + .conditional-clause-row, + .conditional-sentence-row, + .field-text-card-grid { + grid-template-columns: 1fr; + } + + .section-rule-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .section-rule-toggle { + grid-column: 1 / -1; + justify-content: flex-start; + } + .field-rule-status { justify-content: flex-start; } diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 1380495..576a138 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -10,10 +10,51 @@ {% block shell_body %} {% include 'workflows/includes/app_header.html' with header_show_home=1 header_inside_shell=1 %} +
+ + +
{% trans "Deployment Configuration" %}

{% trans "Form Builder" %}

+

{% trans "Steuern Sie Struktur, Regeln und Inhalte Ihrer Standard-Workflows an einem Ort." %}

{% for key, label in form_types %} @@ -32,75 +73,150 @@
-
- +
+
-
+

{% trans "Struktur & Reihenfolge" %}

+

{% trans "Ordnen Sie Abschnitte und Felder in der Reihenfolge, in der sie im Formular erscheinen sollen." %}

+
+
+ {{ columns|length }} {% trans "Abschnitte" %} + {{ builder_summary.configurable_field_count }} {% trans "konfigurierbare Felder" %}
- {% trans "Geöffnet" %}
-
-
-
- {% for column in columns %} -
-
-

{{ column.title }}

- {% blocktrans trimmed with count=column.items|length %}{{ count }} Feld/Felder{% endblocktrans %} -
-
- {% for item in column.items %} -
-
-
{{ item.label }}
-
{{ item.field_name }}
-
-
- {% if item.is_custom %}{% trans "Eigen" %}{% endif %} - {% if item.locked %}{% trans "Fix" %}{% endif %} - {% if not item.is_visible %}{% endif %} - {% if item.is_required %}{% trans "Pflicht" %}{% endif %} -
-
- {% endfor %} -
-
- {% endfor %} -
-
-
+
+ + +
+ + +
+ {% for column in columns %} + {% if active_structure_section == column.key %} +
+
+
+

{{ column.title }}

+ {% blocktrans trimmed with count=column.items|length %}{{ count }} Feld/Felder{% endblocktrans %} +
+ {% trans "Geöffnet" %} +
+
+ {% for item in column.items %} +
+
+
{{ item.label }}
+
{{ item.field_name }}
+
+
+ {% if item.is_custom %}{% trans "Eigen" %}{% endif %} + {% if item.locked %}{% trans "Fix" %}{% endif %} + {% if not item.is_visible %}{% endif %} + {% if item.is_required %}{% trans "Pflicht" %}{% endif %} +
+
+ {% empty %} +
{% trans "Noch keine Felder in diesem Abschnitt." %}
+ {% endfor %} +
+
+ {% endif %} + {% endfor %} +
+
+
+
+
+ +
+
-
+

{% trans "Sichtbarkeit & Regeln" %}

+

{% trans "Legen Sie fest, welche Teile sichtbar, erforderlich oder regelgesteuert sein sollen." %}

+
+
+ {{ builder_summary.hidden_field_count }} {% trans "ausgeblendet" %} + {{ builder_summary.hidden_section_count }} {% trans "versteckte Abschnitte" %}
- {% trans "Öffnen" %}
- -
+ +
-
- + {% if active_module == 'section-rules' %} +
+
-

{% trans "Abschnitte steuern" %}

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

{% trans "Abschnitte steuern" %}

+

{% trans "Reihenfolge und Sichtbarkeit der Formularabschnitte." %}

+
+
+ {{ section_rule_items|length }} {% trans "Abschnitte" %} +
-
-
{% csrf_token %} + +
{% for section in section_rule_items %} + {% if active_section_rules_section == section.key %}
+
{{ forloop.counter }}
+ {% endif %} {% endfor %}
-
+
-
+
+ {% elif active_module == 'field-rules' %} -
- +
+
-

{% trans "Feldregeln verwalten" %}

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

{% trans "Feldregeln verwalten" %}

+

{% trans "Steuern Sie Sichtbarkeit und Pflichtstatus für einzelne Felder." %}

+
+
+ {{ builder_summary.configurable_field_count }} {% trans "konfigurierbar" %} +
-
-
{% csrf_token %} + +
{% for group in field_rule_groups %} + {% if active_field_rules_section == group.key %}

{{ group.title }}

@@ -161,30 +294,34 @@
{{ item.label }} -
{{ item.field_name }}
+
+ {{ item.field_name }} +
+ {% if item.locked %} + {% trans "Fix" %} + {% elif not item.is_visible %} + + {% elif item.is_required %} + {% trans "Pflicht" %} + {% else %} + {% trans "Flexibel" %} + {% endif %} +
+
- - -
- {% if item.locked %} - {% trans "Fix" %} - {% elif not item.is_visible %} - - {% elif item.is_required %} - {% trans "Pflicht" %} - {% else %} - {% trans "Flexibel" %} - {% endif %} +
+ +
{% empty %} @@ -192,28 +329,44 @@ {% endfor %}
+ {% endif %} {% endfor %}
-
+
-
+
+ {% elif form_type == 'onboarding' and active_module == 'conditional-rules' %} - {% if form_type == 'onboarding' %} -
- +
+
-

{% trans "Bedingte Logik" %}

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

{% trans "Bedingte Logik" %}

+

{% trans "Lassen Sie Felder abhängig von anderen Antworten ein- oder ausblenden." %}

+
+
+ {{ conditional_rule_items|length }} {% trans "Regeln" %} +
-
-
{% csrf_token %} + +
{% for item in conditional_rule_items %} + {% if active_conditional_target == item.target_key %}
@@ -228,6 +381,26 @@
+
+ {% trans "Sichtbar, wenn" %} +
+ {% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} + {% if first_clause.field %} + {{ first_clause.field }} + {{ first_clause.operator }} + {% if first_clause.value %}{{ first_clause.value }}{% endif %} + {% else %} + {% trans "Keine Bedingung" %} + {% endif %} + {% if second_clause.field %} + {% trans "und" %} + {{ second_clause.field }} + {{ second_clause.operator }} + {% if second_clause.value %}{{ second_clause.value }}{% endif %} + {% endif %} + {% endwith %} +
+
{% trans "Steuert" %}
@@ -238,105 +411,94 @@ {% endfor %}
-
+
{% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} -
-
- {% trans "Wenn" %} -
- - - +
+ {% trans "Zeige dieses Element, wenn" %} + + +
{% trans "Zusätzliche Bedingung" %} -
-
- {% trans "Und" %} -
- - - +
+ {% trans "Und zusätzlich" %} + + +
{% endwith %}
+
+ {% trans "Nutzen Sie die zusätzliche Bedingung nur, wenn ein zweites Kriterium wirklich nötig ist." %} +
+ {% endif %} {% endfor %}
-
+
-
+ {% endif %}
- + -
- +
+
-
+

{% trans "Optionen & Texte" %}

+

{% trans "Pflegen Sie Auswahlwerte, Feldtexte und benutzerdefinierte Erweiterungen." %}

+
+
+ {{ builder_summary.custom_field_count }} {% trans "eigene Felder" %} + {% if form_type == 'onboarding' %} + {{ builder_summary.custom_section_count }} {% trans "eigene Abschnitte" %} + {% endif %}
- {% trans "Öffnen" %}
-
-
+ +
-
- -
-

{% trans "Optionen verwalten" %}

- {% trans "Öffnen" %} -
-
-
+ {% if active_module == 'options' %} +
+
- - - + - ⋮⋮ - - - - - - - - - - {% empty %} - {% trans "Keine Optionen in dieser Kategorie." %} - {% endfor %} - - +
+ {% for item in option_items %} +
+ +
+
+ ⋮⋮ +
+ {{ item.label }} + #{{ forloop.counter }}{% if item.value %} · {{ item.value }}{% endif %} +
+
+
+ + +
+
+
+ + + +
+
+ {% empty %} +
{% trans "Keine Optionen in dieser Kategorie." %}
+ {% endfor %}
-
+
-
+ + {% elif active_module == 'field-texts' %} -
- +
+
-

{% trans "Feldtexte verwalten" %}

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

{% trans "Feldtexte verwalten" %}

+

{% trans "Überschreiben Sie Labels und Hilfetexte pro Feld." %}

+
-
-
{% csrf_token %} -
- - - - - - - - - - - - {% for group in field_text_groups %} - - - - {% for item in group.items %} - - - - - - - - {% empty %} - - {% endfor %} - {% endfor %} - -
{% trans "Feld" %}{% trans "Label (DE)" %}{% trans "Label (EN)" %}{% trans "Hilfetext (DE)" %}{% trans "Hilfetext (EN)" %}
{{ group.title }}
- - {{ item.field_name }} -
{% trans "Keine Feldkonfigurationen verfügbar." %}
+ + +
+ {% for group in field_text_groups %} + {% if active_field_texts_section == group.key %} + {% for item in group.items %} +
+ +
+
+ {{ item.label_override|default:item.field_name }} +
{{ item.field_name }}
+
+
+
+ + + + +
+
+ {% empty %} +
{% trans "Keine Feldkonfigurationen verfügbar." %}
+ {% endfor %} + {% endif %} + {% endfor %}
-
+
-
+ + {% elif form_type == 'onboarding' and active_module == 'custom-sections' %} - {% if form_type == 'onboarding' %} -
- +
+
-

{% trans "Eigene Abschnitte" %}

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

{% trans "Eigene Abschnitte" %}

+

{% trans "Erweitern Sie den Workflow um eigene inhaltliche Blöcke." %}

+
+
+ {{ builder_summary.custom_section_count }} {% trans "eigene Abschnitte" %} +
-
-
{% csrf_token %} @@ -496,13 +682,16 @@
{{ item.title }}
{{ item.section_key }}
+
+ {{ item.custom_field_count }} {% trans "Feld/Felder" %} +
- +
@@ -524,22 +713,25 @@
{% trans "Keine eigenen Abschnitte vorhanden." %}
{% endfor %}
-
+
-
- {% endif %} + + {% elif active_module == 'custom-fields' %} -
- +
+
-

{% trans "Eigene Felder" %}

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

{% trans "Eigene Felder" %}

+

{% trans "Erstellen Sie zusätzliche Eingaben innerhalb bestehender oder eigener Abschnitte." %}

+
+
+ {{ builder_summary.custom_field_count }} {% trans "eigene Felder" %} +
-
-
{% csrf_token %} @@ -606,8 +798,20 @@ {% csrf_token %} + +
{% for group in custom_field_groups %} + {% if active_custom_fields_section == group.key %}

{{ group.title }}

@@ -686,23 +890,25 @@ {% endfor %}
+ {% endif %} {% endfor %}
-
+
-
+ + {% elif active_module == 'preview' %} -
- +
+
-

{% trans "Live-Vorschau" %}

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

{% trans "Live-Vorschau" %}

+

{% trans "So wirkt die aktuelle Struktur für das aktive Formular." %}

+
-
-
{% for section in preview_sections %}
@@ -721,27 +927,16 @@ {% endfor %}
-
+ + {% endif %}
-
+ +
+ {% endblock %} {% block extra_scripts %} - {% endblock %} diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 9f3faa0..4aa33ba 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -2189,6 +2189,13 @@ def form_builder_page(request): active_panel = (request.GET.get('panel') or '').strip() active_subpanel = (request.GET.get('subpanel') or '').strip() active_rules_panel = (request.GET.get('rules_panel') or '').strip() + active_module = (request.GET.get('module') or '').strip() + active_structure_section = (request.GET.get('structure_section') or '').strip() + active_field_rules_section = ((request.POST.get('field_rules_section') if request.method == 'POST' else '') or request.GET.get('field_rules_section') or '').strip() + active_field_texts_section = ((request.POST.get('field_texts_section') if request.method == 'POST' else '') or request.GET.get('field_texts_section') or '').strip() + active_custom_fields_section = ((request.POST.get('custom_fields_section') if request.method == 'POST' else '') or request.GET.get('custom_fields_section') or '').strip() + active_section_rules_section = ((request.POST.get('section_rules_section') if request.method == 'POST' else '') or request.GET.get('section_rules_section') or '').strip() + active_conditional_target = ((request.POST.get('conditional_target') if request.method == 'POST' else '') or request.GET.get('conditional_target') or '').strip() if form_type not in DEFAULT_FIELD_ORDER: form_type = 'onboarding' option_category = request.GET.get('option_category', 'department') @@ -2196,6 +2203,34 @@ def form_builder_page(request): if option_category not in option_categories: option_category = option_categories[0] + valid_modules = { + 'structure', + 'section-rules', + 'field-rules', + 'conditional-rules', + 'options', + 'field-texts', + 'custom-sections', + 'custom-fields', + 'preview', + } + + if not active_module: + if active_panel == 'builder-structure': + active_module = 'structure' + elif active_panel == 'builder-rules': + active_module = active_rules_panel or 'section-rules' + elif active_panel == 'builder-content': + active_module = active_subpanel or 'options' + else: + active_module = 'structure' + if active_module not in valid_modules: + active_module = 'structure' + if form_type != 'onboarding' and active_module == 'custom-sections': + active_module = 'options' + if form_type != 'onboarding' and active_module == 'conditional-rules': + active_module = 'field-rules' + if request.method == 'POST': delete_option_id = request.POST.get('delete_option_id', '').strip() delete_custom_field_id = request.POST.get('delete_custom_field_id', '').strip() @@ -2211,7 +2246,7 @@ def form_builder_page(request): option.delete() _audit(request, 'form_option_deleted', target_type='form_option', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Option wurde gelöscht.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=options#builder-content") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=options") if delete_custom_field_id: custom_field = FormCustomFieldConfig.objects.filter(id=delete_custom_field_id, form_type=form_type).first() if not custom_field: @@ -2222,7 +2257,7 @@ def form_builder_page(request): custom_field.delete() _audit(request, 'form_custom_field_deleted', target_type='form_custom_field', target_id=deleted_id, target_label=deleted_label) messages.success(request, 'Benutzerdefiniertes Feld wurde gelöscht.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields") if delete_custom_section_id: custom_section = FormCustomSectionConfig.objects.filter(id=delete_custom_section_id, form_type=form_type).first() if not custom_section: @@ -2250,7 +2285,7 @@ def form_builder_page(request): details={'section_key': section_key, 'deleted_field_count': deleted_field_count}, ) messages.success(request, 'Benutzerdefinierter Abschnitt wurde gelöscht.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-sections#builder-content") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-sections") action = request.POST.get('builder_action', '') if action == 'add_option': @@ -2301,7 +2336,7 @@ def form_builder_page(request): option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order']) except IntegrityError: messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&panel=builder-content&subpanel=options#builder-content") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}&module=options") option_category = option.category _audit(request, 'form_options_saved', target_type='form_option', target_label=option_category, details={'count': len(option_ids)}) messages.success(request, 'Optionen wurden gespeichert.') @@ -2447,7 +2482,7 @@ def form_builder_page(request): cfg.select_options_en = (request.POST.get(f'custom_select_options_en_{cfg.id}') or '').strip() if cfg.field_type == FormCustomFieldConfig.FIELD_TYPE_SELECT and not cfg.select_options: messages.error(request, f'Auswahlfeld "{cfg.label}" benötigt mindestens eine Option.') - return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&panel=builder-content&subpanel=custom-fields#builder-content") + return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}&module=custom-fields") cfg.save() updated += 1 _audit(request, 'form_custom_fields_saved', target_type='form_custom_field', target_label=form_type, details={'count': updated}) @@ -2536,40 +2571,43 @@ def form_builder_page(request): elif action == 'apply_preset': preset_key = (request.POST.get('preset_key') or '').strip() if apply_form_preset(form_type, preset_key): - active_panel = 'builder-content' - active_subpanel = 'preview' + active_module = 'preview' _audit(request, 'form_preset_applied', target_type='form_config', target_label=form_type, details={'preset': preset_key}) messages.success(request, 'Preset wurde angewendet.') else: messages.error(request, 'Preset konnte nicht angewendet werden.') - if action in {'add_option', 'save_options', 'save_field_texts', 'add_custom_field', 'save_custom_fields', 'add_custom_section', 'save_custom_sections'}: - active_panel = 'builder-content' if action in {'add_option', 'save_options'}: - active_subpanel = 'options' + active_module = 'options' elif action == 'save_field_texts': - active_subpanel = 'field-texts' + active_module = 'field-texts' elif action in {'add_custom_field', 'save_custom_fields'}: - active_subpanel = 'custom-fields' + active_module = 'custom-fields' elif action in {'add_custom_section', 'save_custom_sections'}: - active_subpanel = 'custom-sections' + active_module = 'custom-sections' elif action in {'save_field_rules', 'save_section_rules', 'save_conditional_rules'}: - active_panel = 'builder-rules' + active_module = 'section-rules' if action == 'save_section_rules': - active_rules_panel = 'section-rules' + active_module = 'section-rules' elif action == 'save_field_rules': - active_rules_panel = 'field-rules' + active_module = 'field-rules' elif action == 'save_conditional_rules': - active_rules_panel = 'conditional-rules' + active_module = 'conditional-rules' redirect_target = f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}" - if active_panel: - redirect_target += f"&panel={active_panel}" - if active_subpanel: - redirect_target += f"&subpanel={active_subpanel}" - if active_rules_panel: - redirect_target += f"&rules_panel={active_rules_panel}" - if anchor == 'builder-content' or active_panel == 'builder-content': - redirect_target += "#builder-content" + if active_module: + redirect_target += f"&module={active_module}" + if active_structure_section: + redirect_target += f"&structure_section={active_structure_section}" + if active_section_rules_section and active_module == 'section-rules': + redirect_target += f"§ion_rules_section={active_section_rules_section}" + if active_field_rules_section and active_module == 'field-rules': + redirect_target += f"&field_rules_section={active_field_rules_section}" + if active_conditional_target and active_module == 'conditional-rules': + redirect_target += f"&conditional_target={active_conditional_target}" + if active_field_texts_section and active_module == 'field-texts': + redirect_target += f"&field_texts_section={active_field_texts_section}" + if active_custom_fields_section and active_module == 'custom-fields': + redirect_target += f"&custom_fields_section={active_custom_fields_section}" return redirect(redirect_target) default_names = list(DEFAULT_FIELD_ORDER.get(form_type, [])) @@ -2700,6 +2738,8 @@ def form_builder_page(request): section_rule_items = [] if section_order: + if active_structure_section not in section_order: + active_structure_section = section_order[0] fallback_section = section_order[-1] if section_order else '' custom_section_map = {cfg.section_key: cfg for cfg in custom_section_configs} for key in section_order: @@ -2717,6 +2757,9 @@ def form_builder_page(request): 'field_count': len([c for c in configs if (c.page_key or default_page_map.get(c.field_name, fallback_section)) == key]) + len([c for c in custom_field_configs if c.section_key == key]), } ) + section_rule_keys = [item['key'] for item in section_rule_items] + if section_rule_keys and active_section_rules_section not in section_rule_keys: + active_section_rules_section = section_rule_keys[0] field_rule_items = [] for cfg in configs: @@ -2747,6 +2790,11 @@ def form_builder_page(request): 'items': grouped_custom.get(key, []), } ) + custom_section_field_counts: dict[str, int] = {} + for cfg in custom_field_configs: + custom_section_field_counts[cfg.section_key] = custom_section_field_counts.get(cfg.section_key, 0) + 1 + for cfg in custom_section_configs: + cfg.custom_field_count = custom_section_field_counts.get(cfg.section_key, 0) field_rule_groups = [] if section_order: @@ -2761,6 +2809,9 @@ def form_builder_page(request): 'items': grouped_rules.get(key, []), } ) + field_rule_group_keys = [group['key'] for group in field_rule_groups] + if field_rule_group_keys and active_field_rules_section not in field_rule_group_keys: + active_field_rules_section = field_rule_group_keys[0] field_text_groups = [] if section_order: @@ -2776,6 +2827,12 @@ def form_builder_page(request): 'items': grouped_texts.get(key, []), } ) + field_text_group_keys = [group['key'] for group in field_text_groups] + if field_text_group_keys and active_field_texts_section not in field_text_group_keys: + active_field_texts_section = field_text_group_keys[0] + custom_field_group_keys = [group['key'] for group in custom_field_groups] + if custom_field_group_keys and active_custom_fields_section not in custom_field_group_keys: + active_custom_fields_section = custom_field_group_keys[0] conditional_rule_items = [] if form_type == 'onboarding': @@ -2837,6 +2894,9 @@ def form_builder_page(request): 'target_fields': target_fields, } ) + conditional_rule_keys = [item['target_key'] for item in conditional_rule_items] + if conditional_rule_keys and active_conditional_target not in conditional_rule_keys: + active_conditional_target = conditional_rule_keys[0] preview_sections = [] if section_order: @@ -2906,6 +2966,13 @@ def form_builder_page(request): 'active_panel': active_panel, 'active_subpanel': active_subpanel, 'active_rules_panel': active_rules_panel, + 'active_module': active_module, + 'active_structure_section': active_structure_section, + 'active_field_rules_section': active_field_rules_section, + 'active_field_texts_section': active_field_texts_section, + 'active_custom_fields_section': active_custom_fields_section, + 'active_section_rules_section': active_section_rules_section, + 'active_conditional_target': active_conditional_target, 'available_presets': FORM_PRESETS.get(form_type, {}), 'can_override_locked_builder_rules': can_override_locked_builder_rules, }, From 855eb8e02f94ebf5eac7edb83dea7ab22efd93b9 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 21:18:03 +0100 Subject: [PATCH 25/45] snapshot: preserve builder section-rules layout refinement --- .../static/workflows/css/form_builder.css | 43 +++++++++++-------- .../templates/workflows/form_builder.html | 15 +------ backend/workflows/views.py | 6 ++- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index dc9b3e4..e5a3afc 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -1582,20 +1582,23 @@ body { .section-rule-grid { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; + min-width: 0; } .section-rule-card { display: grid; - grid-template-columns: auto auto minmax(0, 1fr) auto; + grid-template-columns: 30px auto minmax(0, 1fr) auto; align-items: center; - gap: 12px; - padding: 14px 16px; + gap: 10px; + padding: 12px 14px; border: 1px solid #d6e0ec; - border-radius: 16px; + border-radius: 14px; background: linear-gradient(180deg, #fbfdff, #ffffff); transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; width: 100%; + box-sizing: border-box; + max-width: 100%; } .section-rule-card:hover { @@ -1608,6 +1611,13 @@ body { background: linear-gradient(180deg, #f5f8fc, #fbfdff); } +.section-rule-actions { + display: inline-flex; + align-items: center; + gap: 5px; + padding-right: 0; +} + .section-rule-order { display: inline-flex; align-items: center; @@ -1622,21 +1632,14 @@ body { box-shadow: inset 0 0 0 1px #cdddff; } -.section-rule-actions { - display: inline-flex; - align-items: center; - gap: 6px; - padding-right: 2px; -} - .section-move-btn { - width: 34px; - height: 34px; + width: 32px; + height: 32px; border: 1px solid #cdd9e8; - border-radius: 11px; + border-radius: 10px; background: linear-gradient(180deg, #ffffff, #f5f9ff); color: #274264; - font-size: 15px; + font-size: 14px; font-weight: 700; line-height: 1; cursor: pointer; @@ -1659,13 +1662,13 @@ body { .section-rule-copy { display: grid; - gap: 4px; + gap: 3px; min-width: 0; } .section-rule-copy strong { color: #0f172a; - font-size: 15px; + font-size: 14px; font-weight: 700; overflow-wrap: anywhere; } @@ -1681,6 +1684,10 @@ body { display: inline-flex; align-items: center; gap: 8px; + min-width: 0; + justify-self: end; + justify-content: flex-end; + white-space: nowrap; } .section-rule-checkbox { diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 576a138..01c788e 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -197,20 +197,8 @@
{% csrf_token %} - -
{% for section in section_rule_items %} - {% if active_section_rules_section == section.key %}
- {{ section.title }} + {{ section.display_title }} {% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %}
@@ -245,7 +233,6 @@
- {% endif %} {% endfor %}
diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 4aa33ba..6416bee 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -1,4 +1,5 @@ from pathlib import Path +import re from datetime import timedelta from tempfile import NamedTemporaryFile import json @@ -2746,10 +2747,13 @@ def form_builder_page(request): cfg = section_configs.get(key) custom_cfg = custom_section_map.get(key) is_custom = custom_cfg is not None + raw_title = section_labels.get(key, key) + display_title = re.sub(r'^\d+\.\s*', '', raw_title) if not is_custom else raw_title section_rule_items.append( { 'key': key, - 'title': section_labels.get(key, key), + 'title': raw_title, + 'display_title': display_title, 'is_visible': bool(custom_cfg.is_active) if is_custom else (True if not cfg else cfg.is_visible), 'locked': False if is_custom else (key in locked_sections and not can_override_locked_builder_rules), 'is_custom': is_custom, From e84ddd558b5c10992841be9cd876ab5e2f64a6d1 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 21:19:55 +0100 Subject: [PATCH 26/45] snapshot: preserve compact builder rule and custom-field refinement --- .../static/workflows/css/form_builder.css | 120 +++++++++++------- .../templates/workflows/form_builder.html | 59 +++++---- 2 files changed, 104 insertions(+), 75 deletions(-) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index e5a3afc..1906a90 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -643,16 +643,16 @@ body { .conditional-rule-grid { display: grid; - gap: 12px; + gap: 10px; } .conditional-rule-card { border: 1px solid #d7e0ec; - border-radius: 16px; + border-radius: 14px; background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); - padding: 14px; + padding: 12px; display: grid; - gap: 12px; + gap: 10px; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; } @@ -666,8 +666,8 @@ body { display: flex; align-items: flex-start; justify-content: space-between; - gap: 16px; - padding-bottom: 10px; + gap: 12px; + padding-bottom: 8px; border-bottom: 1px solid #e6edf6; } @@ -676,8 +676,8 @@ body { } .conditional-rule-head h3 { - margin: 2px 0 4px; - font-size: 16px; + margin: 2px 0 2px; + font-size: 15px; color: #142033; } @@ -698,39 +698,45 @@ body { .conditional-toggle { display: inline-flex; align-items: center; - gap: 8px; + gap: 6px; color: #5f7089; - font-size: 12px; + font-size: 11px; font-weight: 800; white-space: nowrap; } .conditional-toggle input[type='checkbox'] { - width: 16px; - height: 16px; + width: 15px; + height: 15px; +} + +.conditional-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; } .conditional-targets { display: grid; - gap: 8px; - padding: 12px; + gap: 6px; + padding: 10px 12px; border: 1px solid #e7edf6; - border-radius: 14px; + border-radius: 12px; background: #f9fbff; } .conditional-rule-summary { display: grid; - gap: 8px; - padding: 12px; + gap: 6px; + padding: 10px 12px; border: 1px solid #dbe6f5; - border-radius: 14px; + border-radius: 12px; background: linear-gradient(180deg, #f6faff, #ffffff); } .conditional-summary-prefix { color: #294567; - font-size: 12px; + font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; @@ -744,7 +750,7 @@ body { .conditional-target-label { color: #5f7089; - font-size: 12px; + font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em; @@ -763,23 +769,23 @@ body { .conditional-sentence-builder { display: grid; - gap: 10px; + gap: 8px; } .conditional-sentence-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.3fr) minmax(160px, 0.9fr) minmax(160px, 0.9fr); - gap: 10px; + gap: 8px; align-items: end; - padding: 12px; + padding: 10px 12px; border: 1px solid #e5ebf3; - border-radius: 14px; + border-radius: 12px; background: #f8fbff; } .conditional-sentence-label { color: #33506f; - font-size: 12px; + font-size: 11px; font-weight: 800; line-height: 1.45; text-transform: uppercase; @@ -789,10 +795,10 @@ body { .conditional-sentence-row select, .conditional-sentence-row input[type='text'] { width: 100%; - min-height: 40px; + min-height: 38px; border: 1px solid #cbd5e1; border-radius: 10px; - padding: 8px 10px; + padding: 7px 9px; box-sizing: border-box; background: #fff; } @@ -838,16 +844,16 @@ body { .conditional-extra-clause { border: 1px dashed #d7e0ec; - border-radius: 14px; + border-radius: 12px; background: #fbfdff; } .conditional-extra-clause summary { list-style: none; cursor: pointer; - padding: 10px 12px; + padding: 8px 12px; color: #35506f; - font-size: 12px; + font-size: 11px; font-weight: 800; letter-spacing: 0.03em; text-transform: uppercase; @@ -1392,34 +1398,54 @@ body { } .custom-fields-surface .builder-entity-form { - gap: 10px; - padding: 14px; + gap: 8px; + padding: 12px; } .custom-fields-surface .builder-entity-grid { - gap: 10px; + gap: 8px; } .custom-fields-surface .builder-group-stack { - gap: 10px; + gap: 8px; } .custom-fields-surface .builder-group-card { - border-radius: 16px; -} - -.custom-fields-surface .builder-group-head { - padding: 10px 12px; -} - -.custom-fields-surface .builder-entity-card { - padding: 14px; - gap: 10px; border-radius: 14px; } +.custom-fields-surface .builder-group-head { + padding: 8px 12px; +} + +.custom-fields-surface .builder-entity-card { + padding: 11px 12px; + gap: 8px; + border-radius: 12px; +} + .custom-fields-surface .builder-entity-card-head strong { - font-size: 14px; + font-size: 13px; +} + +.custom-fields-surface .builder-entity-card-head { + gap: 10px; +} + +.custom-fields-surface .builder-switch-stack { + gap: 6px; +} + +.custom-fields-surface .builder-entity-control span { + font-size: 11px; +} + +.custom-fields-surface .builder-entity-control input[type='text'], +.custom-fields-surface .builder-entity-control input[type='number'], +.custom-fields-surface .builder-entity-control select, +.custom-fields-surface .builder-entity-control textarea { + min-height: 38px; + padding: 7px 9px; } .builder-entity-form { @@ -1789,6 +1815,10 @@ body { grid-template-columns: 1fr; } + .conditional-meta-grid { + grid-template-columns: 1fr; + } + .builder-entity-card-head { flex-direction: column; align-items: flex-start; diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 01c788e..25829d9 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -368,34 +368,36 @@
-
- {% trans "Sichtbar, wenn" %} -
- {% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} - {% if first_clause.field %} - {{ first_clause.field }} - {{ first_clause.operator }} - {% if first_clause.value %}{{ first_clause.value }}{% endif %} - {% else %} - {% trans "Keine Bedingung" %} - {% endif %} - {% if second_clause.field %} - {% trans "und" %} - {{ second_clause.field }} - {{ second_clause.operator }} - {% if second_clause.value %}{{ second_clause.value }}{% endif %} - {% endif %} - {% endwith %} +
+
+ {% trans "Sichtbar, wenn" %} +
+ {% with first_clause=item.clauses.0 second_clause=item.clauses.1 %} + {% if first_clause.field %} + {{ first_clause.field }} + {{ first_clause.operator }} + {% if first_clause.value %}{{ first_clause.value }}{% endif %} + {% else %} + {% trans "Keine Bedingung" %} + {% endif %} + {% if second_clause.field %} + {% trans "und" %} + {{ second_clause.field }} + {{ second_clause.operator }} + {% if second_clause.value %}{{ second_clause.value }}{% endif %} + {% endif %} + {% endwith %} +
-
-
- {% trans "Steuert" %} -
- {% for field_name in item.target_fields %} - {{ field_name }} - {% empty %} - {% trans "Keine Ziel-Felder." %} - {% endfor %} +
+ {% trans "Steuert" %} +
+ {% for field_name in item.target_fields %} + {{ field_name }} + {% empty %} + {% trans "Keine Ziel-Felder." %} + {% endfor %} +
@@ -435,9 +437,6 @@ {% endwith %}
-
- {% trans "Nutzen Sie die zusätzliche Bedingung nur, wenn ein zweites Kriterium wirklich nötig ist." %} -
{% endif %} {% endfor %} From 8e950de994a222ebba3741d2c9e77bb19a5f1d50 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 23:03:17 +0100 Subject: [PATCH 27/45] snapshot: refine builder field rules and conditional summaries --- backend/locale/en/LC_MESSAGES/django.mo | Bin 35298 -> 35221 bytes backend/locale/en/LC_MESSAGES/django.po | 1521 +++++++++++------ .../static/workflows/css/form_builder.css | 194 ++- .../templates/workflows/form_builder.html | 102 +- backend/workflows/views.py | 141 +- 5 files changed, 1405 insertions(+), 553 deletions(-) diff --git a/backend/locale/en/LC_MESSAGES/django.mo b/backend/locale/en/LC_MESSAGES/django.mo index 56fa243a2f8ce8a6a0da851d4c43ee4b57c91218..d98df548e5e08f2a152abee0e86324ac716a1df5 100644 GIT binary patch delta 9153 zcmYk>34Bgh8prV~30Z7eWJ5M0NQf;FOD$2cg;+ueir8W&_DH$3c8#jn*0Hy=cB#;g zDpOPK7=x-BMysYnb!N0`X-8X(&hMXdd_HqOectbR&fU&=?z!)qwl_RpYdv1>^MHk@pVkYQ<#8HP{-G<<2bdjJ=Vt&7=gvu7~eu2 ze+6UksqK%Zv$o^7PEQ)X?3jlBScDaE5eDFE$e5f>7>Ii?2#=v3o7<^apv&R5O-h` zyokD?56{aN+n_3zfvQ-J&BvfBQ;4o+vW7-D?#5U=iP3li^(e|EsZ>-6`(QO>my?gW z@M_eJcAy4&1Uum!)Id`jm%Iy8f;*c2-=tuELWyW%{uW%OrjGF0WX2;l_1*i%h#9Z`pnJ(9M24gV3f;w<3 z7UDS^hMk+4%IrcN_a$;|=Le+9PE3laL?-I|Rmd*q1oFl?4^WRHBGqxSu>-1-?m8M8 z`J1Scoy4Aa234}^%}r(EunKt^YKEOq6&i+BaUllc8r0(3i4}1_2H;WD8asvhzPN@A z$aQ|Bp$gPz<-}qxYKC)=$veduf*+!0bP1QBA4^>qUWIx~Hll904VU9Stc+8?Y{x0gZ{1i2_A5a5%f|_~x=S*pXQ2i;W zfww_jue;4hS*N3`6JNF+ucA)ef*Q!%Hb0ER$d6+_Mz=8+UTj^9S}QwH1KW$L%u&1l z9O{u?v;B9`pWM4G^{+{zGVhjFX(DP-^})*y_MgWv@(0)s{rS+^>54k95Ot%? z7>OmQM|%cU`U}=Ct>3nD&HI0!4qdoHdw$6F=W+S#AeY;!&sy z7NTyv4)y3Z;zZnq8d%u#<~47Kx?d)0QMvVGMaOF2((*N83E3>^iQKN5hwnS*QxQs2Q$7-FO?0#KSg^ z==N$;1$$`ZlIpg ze{B8~wWxx+nEoVG2~$u5?}>Rh5_SG3sES=hP3R%2GG6?k=n(~CMegrJ(a?_isFJ3k zA7-I0)E{+&aaa#s+rJBS{4vyGy@(C*Ci-JgH*=jDSb;nqRe>b*#g6D|B;9FHT_+cn zZ^Z!IhdS{+)BsPQ_MgS(consp12fI)PCyN$1!@3Yk@f0iqmEmPy53%!AIhZudVNmQ zk%(X011femuVFB1=GmxMo~-^1h)s2{1?sDU&?o!7Mo z_1723Aba2n)M8tQy1-*B#DFYQfrY3?vKn=xov4|X*!+D|i9bdS{5tB!KcZ&t-P8Q| z1fU+l5SPXV8l!PI{)XjocrP>3F{l&gVMSboRq!>`s@{gl_yMYt-(fC#_cjB20o#-3 zV<+5&fp{177PINBD345bA4#6NCZu_UBR{a80DL0`8bja>MiMq~t)C|9{ z`){Du*nQM_Y1w5zX0FqNhDJ02b>du9Nta<9dQcTOf>rSY)ECNG9E;!Me9Y-<@+;^= zp5D(K-wE~F=3pJ1g6(k~=IQ;vLPO80Wsc)Cz$~ncMOc8#Z2lNEQ~&;EW)Z04Vlfqy zu|AGO4R9suQ64}|>@X(bhp6MeM_=ymgbgsyG8$vaTiJXN#*@!NJ%UZB0UW@(cpO#Y zTi68epcZ8e3qbve_$O?N`rdeiS}U~&n)~!YH7dbm+uiQ2B4DnfeYk0|`UT ztTC#jt!asi(YAjQHX@&aT4Os=m3$XN@bqBnuV;6S4n51@7t9}-v8WsN z#WWm>n$ddHaobTBI%xBgr~!P68o&+fZPa3XfGYjZr~xMoF@LZ$bZI2e(Hr%wXJZO( zMjdz&HS(*d8{S3@;0fx60YlAuAB7R*saPGeP>Xsj>iBu6>%D@l(1Uf+J#PVnfy7g~uL;3iZ>K11E;OVmW}p;mWzp7|G)bZk#P74;}eQTMrtd`r8| zT^eoac#f~X3@pMvcoOxUUyUDgz4uY588k#ayA*2|)LI#A^YK;}YtZk(4tNk--~-fz zl156GuSFVRbmU1W)A2Csb$W;zxc697u{hKK z6R|u_L9Lycs7JUI%O;3!G#w>0RN_xjtN!m;0dJ!}-bdZ&F={dTjWYv@#8~o{7>@%n z99`56*JBghjb&>D^|sx^moZ^H^$(!2XT15bIf7aoM{zlRj2dyT31%ik(3gB9@+LVG zP;23g-G3c5fN!uPK0!?+eWICQ7pz4-0CnG@iPT>+UrdKaybdejE>!7`*!&oVlAlIh z@H+O!Tc~HAHpx_~8&)Roi+UZ0V=NY-&fkQ3REJQ9WRZm8pOZ9d%Q1*k_i$L0&MCwVar!>>@4cH2%drRavbP(M@!hM;CP9;@O! z48)bFl5a&FUy6FRAD~M68P>#~Fad)KOn(d1Yc~irk(t<7?|(6k6go~|ZTt~+YB&>B+7%drZ((zM9|Q3|YT#Zo&2d522Iy)gooVQX z15p_nYcf_elW+5WSr0bH^D z-xc!wHM5`S&Uq=n_j_vm@GRIXy?T6MF5gqE_v7)XZ+97MIU_vnaz*kD@MSVKS<;b8!_e!a*3e zz#KOfRhdGYzlys4I@E;U!0Oz?DWRbf{{^+GotOBd0U}V3U^cc=H+I6~s0;YKY}P_B zMw2I@D%A~DnSQ8=4M!cHkGkFr)EaWp)wAC~L$BRF)C`Yf*|V~qLzVI}YLVSSU9izY zGs8Bh#hZ;fZ-jLM)*_#QKDYsOoz1Aq>|03v2h#W>9lU`~)FOVHaXo4c+(Vu45H-;9 zi_Pm3iLJ>~QRf$+25=S|;5D23FX8V(@<`PFnW)MvMh(cbg!=178|X;IEm#LHVGKUP z3Rr!qnOPiau_a=C%(4CRu?G1vjK!Tc{{-X7Z=lwa?=n-lNYwdtT^brmZ)}V?=!Z*e z|0*m;UW<`sjmgA2FK#MZ_zFCU;*u*rt=$APQ)^2Wa#m9+0TS zyftN8HTtxw^N2qZGsq7U&$a~HK4Lpmm6gPML^EPC`vVA-{Y7j~3?;rMPs8U4Hcx)d zAotH@P5!qHzDj=;v#;!%xIgXR)87uCqKnh9H=#$}+#WlY_I}$QVEvK4m9#J5x5Tq; z5{*^#&&EOJ_#PZXqa87u&J{#D?L=Z5?Jk72R@OGO^|saaG>i(b{2yBV*rOMFnWuMD zxVxN8YqOY`Z_lCWyhU^&%G1}^_P>EG>3a!x6NhO(+ZvJU`%v%2ueLLkzL$wqq5?64 z{#c?C?Y9Uv4b5z$=$J%YBRxhG(k}7DMTdAANBel~DyOF2`j3e>iOTHX>q(Ce$=yV& zl(}sLhA~VLT^)i zwtYxr7txd84-BWKz0w@?C;aGJhjoeJgtmFs-k!*q8m=GN0%C{R=|s_vBL)-NrdoH? z{)$K^e;-c~S;SiMJ@y#hJLd+WP2Y9n=x<0YqRlUqbB{PnXe%N3 zeM4yT*u6#g4taG9vweY99T!AAN3P$BEyQ_ZCixm-JMGtq$;4*z3@nHHJey;~%f0D2 z5*w4+nq1pb;waIUyb6xTgXl|m)Ak|WrmgR{BOd>_822x<2GSc%j3u4axQ0zmYs1x8hW6LewX+319j$F~*$3mp$7ClE)ZKykmBh{YvH1 zu1#M@VgkXhtkaUd&i0u8p71Xxa;8di~i}pYDU&IRR$?fSFKrAOV5yR-uw)-x6eyv@j z+&NEZd{W*S_8lUAAx03|;)vHcb{6p`+7pQdgtjk;G4%gdmYI)A5Jtq)pYnhGdS1;+ fx5u9jEuGYARh819uG8a7S4`|yx%8VkPpkY7N|8SR delta 9218 zcmYk=30##`8prV$ML-Y`5l|3hQ(0Vb#|?MP6vTbU1#km56mh$1?xtneCCxFn(lXPO zG-q1f#xV|jcH{qPO+#rKdgIUiz0Jc&X0E&AYX)Oo*Jom!3)NbZZ_ z7>?TC7|UTB)O9_}kFD_pc1NFjrjmV8k0Kj2feENbQDj|KkNT?uuhXGtQ(`@dtWM`Lc0o^;tZvX9 zwThRc2EGeJ@i<1}cUTplViAThE6wyJWbDpnR0Ypse~fi8UGCw`#7g)M>cCI17;j@X z<~1~xIgUE+zsNnDfJRgc+n_2j7Ipp}ygPjyD%6pqh|B~7hwWRT^HVidQ1L>y5Xm|6i=f+_HAwkl!KaJKB@v! zv8LYtlQi_c|AJxo2(@|xS*n_GAJh$UY(54P$cs@I+=3e5PU|PAfqjK#@e-=kS1}y# zqW1f;qKE7K522wMPeYY%8R`T#R>4geg9mN?E$a1rfSO@&OEZudR0WbyC2nH-`=cf@ z9Cf{kHeZS^?N~=cC+@I2N>DR9j2g&kn_s|e@~fDK?OT}(@3j6EwPsGB2KEK2GMDWB zd#H(e@G7ala%<{eo<;&4)i4FMS~F0KYYHaeI;@LFZ2x@>B@b)E%E!8VXblyh&fAE( z(IKpg=TVRNE~@kotxlTDboiy2?}1R%g_DtgPCGuds$a0KLRIVyYl-zRYVCZ9I{zkW z&3LrshX=h-189l*K4^y_*vmyjr5=O2@MNrv^N@eeTAN?MTI6?7r4Maq9@%VEDVL%u zxDiX=2Gpbb6!Y;6YG5teo7X)HbwAex8d{Yu)CJa~N_POMl5^7Lz8%bsB2mXRK+T{# z>cS&YrJaGg@IurYSZ(vISe<+?F2*mBN9%G1b~G1Sh~DhjfU3YY)S^6yn&D9#i5G0% zns+pkJPUQ>38>XQ12utFI0CoZ+^@6w*^NRSmx^KB-|0z1&txpl##yKfK0#f`JKa2@ za8w?LT2xJJeM}ou250gV3c?jG*C-Gf@MXk5t)N zY4fkqm;5^F#Ji{g{)#%@vn$oWAk>;jM^$tPY9JF(1DK1fUuPNWxHDa;zb<&qcHBh0 zM*7iDf-0!}?J*6XL(O~{Y9^bkZ=r6q7j@&qn2qPr7n^i5i?S^$&qV!b4eUn!HIlJ( z=)}3GFA}#s@Gxr8eU4hy4ZEA4<#y;#{vqm-oJ3vtB5I~T+Wa1>!cS2H56v()PDV|v zg^NZ{8tqVzU=6;C8*w-`%`{&e>rn&UggS9Q`r$_yfG02l|Alq&0jiR5Jkn zjcNEgc0$)>8Wm~O$}+E0GptG82X%v4SOJ${SzLobxZd`^gIfIuQ3E`O8qiI<|1s)1 zUd&Pxtc2Phj;t}4lSo4+j>jlmfEv&?)QRt-M*bPb;3-rEZet)mKz*@z_Hvxj7>{#s zh0Ozdn{UKo)bX=XukQ-1q4$3WjWjwwN4>X!eay3(h_%TVqMr3WoQR*Levg{iPngX8o!@Ba!1#XVLfui%vJXb%B%8Z2o_sgz5u8Jn z@_Vd>4^ab&>~H>>u7O&VeNp|x@$WbW^}UfYfck5(45Sf_OR*~MM2+|;*1#LK-*2FK zEo-12MJDRFNtlAKpuUoiqHcH%^{8&49^pOP|HS5=gQ&k2SHK{%$RbcDCZJBNZ}X<8 znYKXyht5t+9)PsJ}{ihmK%$2AgMB8TBlm z!-m)ob;B32DXvA$=q&2E3#jY-VDrbQ0eBBF0|>Xqq9^@HsLI!K(a?y8U>xRPOMT9JNyBg z>HSX{X=XISIvYbdU>!!{9@L`y91HPB)C~sbm}fW+HPF|v8(zUAte0yZ$pBOZ@=@on zMNOze9&3sFJ40z`ajiu?f-R^EoWz#+5VZ(XMwv&Ii`u^#tK(^Gi$9^>rn;lez+0dX zd4JRZhhsV1fm%E7qD#;4QyQf+#3=F~Q6=^sV+Iz6UgWV@9urYFYKVG`(oh5Gfzdb- zLVmyGl;aO~eSFm)AjHUkis!SNmpV&AAeeo)m#oOqIzu;1QiW>0Zab_lK(3^Y% z^2RyaP-~$~zS$p&8bCGdfQ?WSDYpCPXt3e1J8VGr^+)ay6}Rk6kBkFTI!%S{-K2VFFDfp1WY?Pt`10praVMK#n6V=x92 zQO~q1>bPF0#X1}Vu-Lj7b=+E;Z?gGr)PO#)x$6*(3_4C>HbzV^C7+3^#A4Ki+^7m{ zK+Wte48#wyBA!N-{vzu5Ur^8ZcT{BqCz`*0Q?Mp^2GZ|xrqa+C$~x3cO0Yhj!bbQ6 zWj;_*gWHS3?om)Ak4zqI1+WeYpC7gwPMei&2q{-2yaFM`9!f=>4BZfX^Ir;Rw|0R}-~*lTkBkidtknP>XW} zYUYL59cQ2_dlFy9FL5A_m}`#v1Vgx&bIf-98+C(gs2SfyEv~1i5?7gLR(CfXN}hxI zEUuL#k8mmKQEft1c#rkqeCi)d$00gA@doNbH&G`(#sL_(!2Df`x*H4R!E?6%B6^U=@ezsICaHsME3UytHdkMkKG=2hQvhn`+2aw z^&DoCv?GQQSIC=VM|az5VXl*8+WteVq&JYzmPhobeGuE?pEwr_u?JC`Xl9QcO?$U( z53oL6JGT7vHjbyIeqhSC-FY*XIleueH9iH zzuV4G`W6sP32$Nw{n3O!?HvSL)6!3Uv@IjPCC(9d2yNZimtpr0#mV%2Nz|sT z*W{{uc~n^Mztj2`(S+Uv0iiyNXLfaf`Pxs@f2v>Qs z7l=2^PA7_XEHQ-8R%m^j_9db{`QPv;(VbXJ{*FC{7tpyzXw#cJj{bVY0@|~PUx+h= zwi(11d`md5(MaN;Wa4=uo48G1Swfo!`666LTmYZHtM&5^3ZCn2&qVoA9Jvj@Y3#alrkb=<2S`vjJc zn9z2Cc!~ByCDru7wu+8_<7>E(Xh!=@_vYAO*CFk;+j#c$q3y!Uwy%Kp0ov1vBg8*# z|Hl~4z7saDLR(upX4!Uk%p`K@A3~JS{t2@QZB6uN<3}_)uwyjw3GJ`7hiwG;O{|OB z-oi212|L@qBJ$S6CYyJmeVtfG-XC`pxwKzIZ3_)f1^UX+{!#y%X0kmwjgEfAQep!! zoc`W+-&gJnaSUlNZH#h!x$i5D#s{?{ECKQeyUyxG}JvC=i(XeraIs3j&pBlffbAG0Oxo*P> MhUM*BKHVqqe;@l*AOHXW diff --git a/backend/locale/en/LC_MESSAGES/django.po b/backend/locale/en/LC_MESSAGES/django.po index 28ef53a..65605e6 100644 --- a/backend/locale/en/LC_MESSAGES/django.po +++ b/backend/locale/en/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: tubco-portal\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-27 12:07+0000\n" +"POT-Creation-Date: 2026-03-27 22:03+0000\n" "PO-Revision-Date: 2026-03-24 00:00+0000\n" "Language: en\n" "MIME-Version: 1.0\n" @@ -10,10 +10,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" #: workflows/app_registry.py:35 workflows/models.py:485 workflows/models.py:524 -#: workflows/models.py:552 workflows/models.py:582 workflows/models.py:690 +#: workflows/models.py:544 workflows/models.py:564 workflows/models.py:602 +#: workflows/models.py:701 #: workflows/templates/workflows/onboarding_form.html:25 #: workflows/templates/workflows/requests_dashboard.html:68 #: workflows/templates/workflows/requests_dashboard.html:131 +#: workflows/views.py:3008 workflows/views.py:3025 workflows/views.py:3037 msgid "Onboarding" msgstr "Onboarding" @@ -38,9 +40,10 @@ msgid "E-Mail Routing" msgstr "Email routing" #: workflows/app_registry.py:46 workflows/models.py:486 workflows/models.py:525 -#: workflows/models.py:583 workflows/models.py:691 +#: workflows/models.py:603 workflows/models.py:702 #: workflows/templates/workflows/requests_dashboard.html:78 #: workflows/templates/workflows/requests_dashboard.html:132 +#: workflows/views.py:3009 workflows/views.py:3037 msgid "Offboarding" msgstr "Offboarding" @@ -94,10 +97,11 @@ msgstr "Search" #: workflows/app_registry.py:62 #: workflows/templates/workflows/app_registry.html:32 #: workflows/templates/workflows/backup_recovery.html:72 +#: workflows/templates/workflows/form_builder.html:436 #: workflows/templates/workflows/job_monitor.html:68 #: workflows/templates/workflows/job_monitor.html:89 #: workflows/templates/workflows/onboarding_intro_session.html:37 -#: workflows/templates/workflows/request_timeline.html:70 +#: workflows/templates/workflows/request_timeline.html:76 #: workflows/templates/workflows/requests_dashboard.html:136 #: workflows/templates/workflows/trial_expired.html:20 #: workflows/templates/workflows/trial_management.html:25 @@ -128,12 +132,6 @@ msgstr "" #: workflows/app_registry.py:142 workflows/app_registry.py:151 #: workflows/app_registry.py:160 workflows/app_registry.py:169 #: workflows/app_registry.py:178 workflows/app_registry.py:187 -#: workflows/templates/workflows/form_builder.html:86 -#: workflows/templates/workflows/form_builder.html:155 -#: workflows/templates/workflows/form_builder.html:327 -#: workflows/templates/workflows/form_builder.html:337 -#: workflows/templates/workflows/form_builder.html:412 -#: workflows/templates/workflows/form_builder.html:463 #: workflows/templates/workflows/includes/app_header.html:57 msgid "Öffnen" msgstr "Open" @@ -226,8 +224,8 @@ msgid "Geplante Welcome Mails verwalten." msgstr "Manage scheduled welcome emails." #: workflows/app_registry.py:158 -#: workflows/templates/workflows/form_builder.html:4 -#: workflows/templates/workflows/form_builder.html:16 +#: workflows/templates/workflows/form_builder.html:5 +#: workflows/templates/workflows/form_builder.html:77 msgid "Form Builder" msgstr "Form Builder" @@ -553,7 +551,7 @@ msgstr "Save offboarding request" msgid "Backup erfolgreich" msgstr "Submitted" -#: workflows/forms.py:398 workflows/views.py:1448 +#: workflows/forms.py:398 workflows/views.py:1553 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Backup fehlgeschlagen" @@ -583,13 +581,14 @@ msgstr "Introduction" msgid "System-Hinweise" msgstr "Introduction" -#: workflows/forms.py:418 +#: workflows/forms.py:418 workflows/templates/workflows/form_builder.html:41 +#: workflows/templates/workflows/form_builder.html:102 #, fuzzy #| msgid "Workflow-Regeln" msgid "Workflow" msgstr "Workflow rules" -#: workflows/forms.py:419 workflows/views.py:1605 +#: workflows/forms.py:419 workflows/views.py:1712 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail" @@ -615,11 +614,11 @@ msgstr "Role:" msgid "Dieser Benutzername ist bereits vergeben." msgstr "This username is already taken." -#: workflows/forms.py:501 workflows/views.py:1257 +#: workflows/forms.py:501 workflows/views.py:1362 msgid "Ungültige Rolle." msgstr "Invalid role." -#: workflows/forms.py:503 workflows/views.py:1260 +#: workflows/forms.py:503 workflows/views.py:1365 msgid "Nur Platform Owner dürfen diese Rolle vergeben." msgstr "" @@ -789,12 +788,12 @@ msgstr "" msgid "Das Trial-Ende muss nach dem Trial-Beginn liegen." msgstr "" -#: workflows/forms.py:802 workflows/forms.py:964 +#: workflows/forms.py:802 workflows/forms.py:969 #, python-format msgid "Bitte nutzen Sie das Format name@%(domain)s." msgstr "" -#: workflows/forms.py:825 workflows/forms.py:979 +#: workflows/forms.py:825 workflows/forms.py:984 #, python-format msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse." msgstr "" @@ -870,35 +869,35 @@ msgstr "" msgid "Fehler" msgstr "" -#: workflows/models.py:308 workflows/views.py:695 +#: workflows/models.py:308 workflows/views.py:800 #, fuzzy #| msgid "Gesamtbestand" msgid "Gestartet" msgstr "Total records" -#: workflows/models.py:309 workflows/views.py:695 +#: workflows/models.py:309 workflows/views.py:800 #, fuzzy #| msgid "Eingereicht" msgid "Erfolgreich" msgstr "Submitted" -#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:744 +#: workflows/models.py:310 workflows/models.py:363 workflows/models.py:755 #: workflows/templates/workflows/backup_recovery.html:102 #: workflows/templates/workflows/requests_dashboard.html:222 -#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:451 -#: workflows/views.py:695 +#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:493 +#: workflows/views.py:800 msgid "Fehlgeschlagen" msgstr "Failed" -#: workflows/models.py:360 workflows/views.py:448 +#: workflows/models.py:360 workflows/views.py:490 msgid "Eingereicht" msgstr "Submitted" -#: workflows/models.py:361 workflows/views.py:449 +#: workflows/models.py:361 workflows/views.py:491 msgid "In Bearbeitung" msgstr "Processing" -#: workflows/models.py:362 workflows/models.py:804 workflows/views.py:450 +#: workflows/models.py:362 workflows/models.py:815 workflows/views.py:492 msgid "Abgeschlossen" msgstr "Completed" @@ -952,201 +951,195 @@ msgstr "" msgid "Automatisch" msgstr "" -#: workflows/models.py:477 workflows/models.py:528 workflows/models.py:586 -#: workflows/views.py:125 +#: workflows/models.py:477 workflows/views.py:123 msgid "Stammdaten" msgstr "Master data" -#: workflows/models.py:478 workflows/models.py:529 workflows/models.py:587 -#: workflows/views.py:126 +#: workflows/models.py:478 workflows/views.py:124 msgid "Vertrag" msgstr "Contract" -#: workflows/models.py:479 workflows/models.py:530 workflows/models.py:588 -#: workflows/views.py:127 +#: workflows/models.py:479 workflows/views.py:125 msgid "IT-Setup" msgstr "IT setup" -#: workflows/models.py:480 workflows/models.py:531 workflows/models.py:589 -#: workflows/views.py:128 workflows/views.py:581 +#: workflows/models.py:480 workflows/views.py:126 workflows/views.py:686 msgid "Abschluss" msgstr "Finish" -#: workflows/models.py:481 workflows/models.py:532 workflows/models.py:590 -#: workflows/views.py:579 +#: workflows/models.py:481 workflows/views.py:684 #, fuzzy #| msgid "Mitarbeiter" msgid "Mitarbeitende" msgstr "Staff" -#: workflows/models.py:482 workflows/models.py:533 workflows/models.py:591 -#: workflows/views.py:580 +#: workflows/models.py:482 workflows/views.py:685 msgid "Austritt" msgstr "" -#: workflows/models.py:576 +#: workflows/models.py:596 msgid "Text" msgstr "" -#: workflows/models.py:577 +#: workflows/models.py:597 msgid "Mehrzeilig" msgstr "" -#: workflows/models.py:578 workflows/templates/workflows/welcome_emails.html:80 +#: workflows/models.py:598 workflows/templates/workflows/welcome_emails.html:80 msgid "Auswahl" msgstr "Select" -#: workflows/models.py:579 +#: workflows/models.py:599 msgid "Checkbox" msgstr "" -#: workflows/models.py:648 +#: workflows/models.py:659 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: IT" msgstr "Onboarding" -#: workflows/models.py:649 +#: workflows/models.py:660 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Onboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:650 +#: workflows/models.py:661 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Visitenkarte" msgstr "Start onboarding" -#: workflows/models.py:651 +#: workflows/models.py:662 #, fuzzy #| msgid "Onboarding" msgid "Onboarding: HR Works" msgstr "Onboarding" -#: workflows/models.py:652 +#: workflows/models.py:663 #, fuzzy #| msgid "Onboarding starten" msgid "Onboarding: Schlüssel" msgstr "Start onboarding" -#: workflows/models.py:653 +#: workflows/models.py:664 msgid "Onboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:654 +#: workflows/models.py:665 #, fuzzy #| msgid "Welcome E-Mails" msgid "Onboarding: Welcome E-Mail" msgstr "Welcome Emails" -#: workflows/models.py:655 +#: workflows/models.py:666 #, fuzzy #| msgid "Offboarding" msgid "Offboarding: IT" msgstr "Offboarding" -#: workflows/models.py:656 +#: workflows/models.py:667 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Offboarding: Allgemeine Info" msgstr "Save offboarding request" -#: workflows/models.py:657 +#: workflows/models.py:668 #, fuzzy #| msgid "Offboarding starten" msgid "Offboarding: HR Works Deaktivierung" msgstr "Start offboarding" -#: workflows/models.py:658 +#: workflows/models.py:669 msgid "Offboarding: Referenz Anfordernde Person" msgstr "" -#: workflows/models.py:694 +#: workflows/models.py:705 msgid "Immer" msgstr "" -#: workflows/models.py:695 workflows/models.py:773 +#: workflows/models.py:706 workflows/models.py:784 msgid "Enthält" msgstr "" -#: workflows/models.py:696 workflows/models.py:774 +#: workflows/models.py:707 workflows/models.py:785 msgid "Ist gleich" msgstr "" -#: workflows/models.py:697 +#: workflows/models.py:708 msgid "Ist aktiv/Ja" msgstr "" -#: workflows/models.py:698 +#: workflows/models.py:709 #, fuzzy #| msgid "inaktiv" msgid "Ist inaktiv/Nein" msgstr "inactive" -#: workflows/models.py:740 +#: workflows/models.py:751 #: workflows/templates/workflows/welcome_emails.html:100 msgid "Geplant" msgstr "Scheduled" -#: workflows/models.py:741 +#: workflows/models.py:752 #: workflows/templates/workflows/welcome_emails.html:102 msgid "Pausiert" msgstr "Paused" -#: workflows/models.py:742 +#: workflows/models.py:753 #: workflows/templates/workflows/welcome_emails.html:104 msgid "Abgebrochen" msgstr "Cancelled" -#: workflows/models.py:743 +#: workflows/models.py:754 #: workflows/templates/workflows/welcome_emails.html:106 msgid "Gesendet" msgstr "Sent" -#: workflows/models.py:766 workflows/tasks.py:628 +#: workflows/models.py:777 workflows/tasks.py:628 msgid "Geräte und Arbeitsplatz" msgstr "Devices and workplace" -#: workflows/models.py:767 workflows/tasks.py:629 +#: workflows/models.py:778 workflows/tasks.py:629 msgid "Konten und Berechtigungen" msgstr "Accounts and permissions" -#: workflows/models.py:768 workflows/tasks.py:630 +#: workflows/models.py:779 workflows/tasks.py:630 msgid "Software und Tools" msgstr "Software and tools" -#: workflows/models.py:769 workflows/tasks.py:631 +#: workflows/models.py:780 workflows/tasks.py:631 msgid "Prozesse und Hinweise" msgstr "Processes and notes" -#: workflows/models.py:772 +#: workflows/models.py:783 msgid "Immer anzeigen" msgstr "Always show" -#: workflows/models.py:775 +#: workflows/models.py:786 msgid "Ist Ja / aktiv" msgstr "Is yes / active" -#: workflows/models.py:776 +#: workflows/models.py:787 msgid "Ist Nein / inaktiv" msgstr "Is no / inactive" -#: workflows/models.py:803 +#: workflows/models.py:814 msgid "Entwurf" msgstr "Draft" -#: workflows/models.py:823 +#: workflows/models.py:834 #, fuzzy #| msgid "Nextcloud:" msgid "Nextcloud" msgstr "Nextcloud:" -#: workflows/models.py:824 +#: workflows/models.py:835 msgid "S3" msgstr "" -#: workflows/models.py:825 +#: workflows/models.py:836 msgid "NFS" msgstr "" @@ -1501,13 +1494,13 @@ msgstr "Processing" #: workflows/templates/workflows/account_profile.html:108 #: workflows/templates/workflows/onboarding_intro_session.html:27 -#: workflows/templates/workflows/request_timeline.html:66 +#: workflows/templates/workflows/request_timeline.html:72 #: workflows/templates/workflows/user_management.html:71 msgid "Name" msgstr "Name" #: workflows/templates/workflows/account_profile.html:112 -#: workflows/templates/workflows/request_timeline.html:74 +#: workflows/templates/workflows/request_timeline.html:80 #: workflows/templates/workflows/requests_dashboard.html:190 #: workflows/templates/workflows/user_management.html:73 #: workflows/templates/workflows/user_management.html:172 @@ -1543,9 +1536,12 @@ msgstr "" #: workflows/templates/workflows/account_profile.html:262 #: workflows/templates/workflows/app_registry.html:35 #: workflows/templates/workflows/app_registry.html:84 -#: workflows/templates/workflows/form_builder.html:275 -#: workflows/templates/workflows/form_builder.html:376 -#: workflows/templates/workflows/form_builder.html:507 +#: workflows/templates/workflows/form_builder.html:111 +#: workflows/templates/workflows/form_builder.html:426 +#: workflows/templates/workflows/form_builder.html:437 +#: workflows/templates/workflows/form_builder.html:564 +#: workflows/templates/workflows/form_builder.html:718 +#: workflows/templates/workflows/form_builder.html:862 #: workflows/templates/workflows/integrations_setup.html:263 #: workflows/templates/workflows/intro_builder.html:65 #: workflows/templates/workflows/trial_management.html:28 @@ -1734,9 +1730,10 @@ msgstr "Last updated" #: workflows/templates/workflows/app_registry.html:4 #: workflows/templates/workflows/app_registry.html:103 -#: workflows/templates/workflows/form_builder.html:372 -#: workflows/templates/workflows/form_builder.html:482 -#: workflows/templates/workflows/form_builder.html:503 +#: workflows/templates/workflows/form_builder.html:692 +#: workflows/templates/workflows/form_builder.html:733 +#: workflows/templates/workflows/form_builder.html:796 +#: workflows/templates/workflows/form_builder.html:885 #: workflows/templates/workflows/intro_builder.html:58 msgid "Sortierung" msgstr "Sort order" @@ -1867,7 +1864,6 @@ msgid "Platzierung" msgstr "Sort order" #: workflows/templates/workflows/app_registry.html:166 -#: workflows/templates/workflows/form_builder.html:36 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Reihenfolge" @@ -1989,8 +1985,9 @@ msgid "Zeit" msgstr "" #: workflows/templates/workflows/audit_log.html:55 -#: workflows/templates/workflows/form_builder.html:502 -#: workflows/templates/workflows/request_timeline.html:62 +#: workflows/templates/workflows/form_builder.html:788 +#: workflows/templates/workflows/form_builder.html:877 +#: workflows/templates/workflows/request_timeline.html:68 #: workflows/templates/workflows/requests_dashboard.html:128 #: workflows/templates/workflows/requests_dashboard.html:188 msgid "Typ" @@ -2226,10 +2223,9 @@ msgid "Backup-Bundle wirklich löschen?" msgstr "Delete this backup bundle?" #: workflows/templates/workflows/backup_recovery.html:133 -#: workflows/templates/workflows/form_builder.html:377 -#: workflows/templates/workflows/form_builder.html:392 -#: workflows/templates/workflows/form_builder.html:508 -#: workflows/templates/workflows/form_builder.html:550 +#: workflows/templates/workflows/form_builder.html:566 +#: workflows/templates/workflows/form_builder.html:720 +#: workflows/templates/workflows/form_builder.html:865 #: workflows/templates/workflows/integrations_setup.html:265 #: workflows/templates/workflows/intro_builder.html:66 #: workflows/templates/workflows/intro_builder.html:102 @@ -2358,374 +2354,691 @@ msgid "" "Firmendaten, nicht um visuelle Gestaltung." msgstr "" -#: workflows/templates/workflows/form_builder.html:15 -msgid "Deployment Configuration" +#: workflows/templates/workflows/form_builder.html:17 +msgid "Arbeitsbereich" msgstr "" -#: workflows/templates/workflows/form_builder.html:27 -msgid "Reihenfolge speichern" -msgstr "Save order" - -#: workflows/templates/workflows/form_builder.html:35 -#, fuzzy -#| msgid "Eingereicht" -msgid "Bereiche" -msgstr "Submitted" - -#: workflows/templates/workflows/form_builder.html:37 -#, fuzzy -#| msgid "Regelname" -msgid "Regeln" -msgstr "Rule name" - -#: workflows/templates/workflows/form_builder.html:38 -#: workflows/templates/workflows/form_builder.html:325 -#, fuzzy -#| msgid "Optionen verwalten" -msgid "Optionen & Texte" -msgstr "Manage options" - -#: workflows/templates/workflows/form_builder.html:43 -msgid "Fixe Kernfelder" +#: workflows/templates/workflows/form_builder.html:18 +msgid "Formularsteuerung" msgstr "" -#: workflows/templates/workflows/form_builder.html:47 -msgid "Konfigurierbar" +#: workflows/templates/workflows/form_builder.html:19 +msgid "" +"Arbeiten Sie blockweise: zuerst Struktur, dann Regeln, danach Inhalte und " +"Erweiterungen." msgstr "" -#: workflows/templates/workflows/form_builder.html:51 -#, fuzzy -#| msgid "Ausgeblendet" -msgid "Aktuell ausgeblendet" -msgstr "Hidden" - -#: workflows/templates/workflows/form_builder.html:55 -#: workflows/templates/workflows/form_builder.html:462 -msgid "Eigene Felder" +#: workflows/templates/workflows/form_builder.html:22 +msgid "Builder Navigation" msgstr "" -#: workflows/templates/workflows/form_builder.html:60 -#, fuzzy -#| msgid "Abschnitt" -msgid "Versteckte Abschnitte" -msgstr "Section" - -#: workflows/templates/workflows/form_builder.html:70 -msgid "Vorlage anwenden" -msgstr "" - -#: workflows/templates/workflows/form_builder.html:76 -msgid "Anwenden" -msgstr "" - -#: workflows/templates/workflows/form_builder.html:84 -msgid "Live-Vorschau" -msgstr "" - -#: workflows/templates/workflows/form_builder.html:95 -#: workflows/templates/workflows/form_builder.html:126 -#: workflows/templates/workflows/form_builder.html:207 -#, fuzzy, python-format -#| msgid "Keine konfigurierten Felder in diesem Schritt." -msgid "%(count)s Feld/Felder" -msgstr "No configured fields in this step." - -#: workflows/templates/workflows/form_builder.html:101 -msgid "Keine sichtbaren Felder." -msgstr "" - -#: workflows/templates/workflows/form_builder.html:114 +#: workflows/templates/workflows/form_builder.html:24 +#: workflows/templates/workflows/form_builder.html:129 workflows/views.py:2996 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Struktur & Reihenfolge" msgstr "Save order" -#: workflows/templates/workflows/form_builder.html:116 +#: workflows/templates/workflows/form_builder.html:25 +#: workflows/templates/workflows/form_builder.html:133 +#: workflows/templates/workflows/form_builder.html:163 +#: workflows/templates/workflows/form_builder.html:227 +#: workflows/templates/workflows/form_builder.html:244 workflows/views.py:2997 #, fuzzy -#| msgid "öffnen" -msgid "Geöffnet" -msgstr "open" +#| msgid "Abschnitt" +msgid "Abschnitte" +msgstr "Section" -#: workflows/templates/workflows/form_builder.html:136 -#: workflows/templates/workflows/form_builder.html:182 -#: workflows/templates/workflows/form_builder.html:231 -msgid "Fix" -msgstr "Fixed" - -#: workflows/templates/workflows/form_builder.html:137 -#: workflows/templates/workflows/form_builder.html:184 -#: workflows/templates/workflows/form_builder.html:233 -msgid "Ausgeblendet" -msgstr "Hidden" - -#: workflows/templates/workflows/form_builder.html:138 -#: workflows/templates/workflows/form_builder.html:222 -#: workflows/templates/workflows/form_builder.html:225 -#: workflows/templates/workflows/form_builder.html:235 -#: workflows/templates/workflows/form_builder.html:484 -#: workflows/templates/workflows/form_builder.html:506 -msgid "Pflicht" -msgstr "Required" - -#: workflows/templates/workflows/form_builder.html:153 +#: workflows/templates/workflows/form_builder.html:28 +#: workflows/templates/workflows/form_builder.html:217 #, fuzzy #| msgid "Sicherheitsregeln" msgid "Sichtbarkeit & Regeln" msgstr "Safety rules" -#: workflows/templates/workflows/form_builder.html:163 +#: workflows/templates/workflows/form_builder.html:29 +#: workflows/templates/workflows/form_builder.html:221 +#, fuzzy +#| msgid "Ausgeblendet" +msgid "ausgeblendet" +msgstr "Hidden" + +#: workflows/templates/workflows/form_builder.html:32 +#: workflows/templates/workflows/form_builder.html:499 +#, fuzzy +#| msgid "Optionen verwalten" +msgid "Optionen & Texte" +msgstr "Manage options" + +#: workflows/templates/workflows/form_builder.html:33 +#: workflows/templates/workflows/form_builder.html:68 +#: workflows/templates/workflows/form_builder.html:153 +#: workflows/templates/workflows/form_builder.html:503 +#: workflows/templates/workflows/form_builder.html:758 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "eigene Felder" +msgstr "Save field text" + +#: workflows/templates/workflows/form_builder.html:38 +#, fuzzy +#| msgid "Abschnitt" +msgid "Aktive Ansicht" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:45 +#: workflows/templates/workflows/form_builder.html:106 +msgid "Modul" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:50 +msgid "Fokus" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:60 +#: workflows/templates/workflows/form_builder.html:134 +msgid "konfigurierbare Felder" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:64 +#: workflows/templates/workflows/form_builder.html:505 +#: workflows/templates/workflows/form_builder.html:670 +#, fuzzy +#| msgid "Abschnitt" +msgid "eigene Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:76 +msgid "Deployment Configuration" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:78 +msgid "" +"Steuern Sie Struktur, Regeln und Inhalte Ihrer Standard-Workflows an einem " +"Ort." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:95 +msgid "Reihenfolge speichern" +msgstr "Save order" + +#: workflows/templates/workflows/form_builder.html:99 +#, fuzzy +#| msgid "Testmodus" +msgid "Arbeitskontext" +msgstr "Test mode" + +#: workflows/templates/workflows/form_builder.html:117 +msgid "" +"Arbeiten Sie jeweils nur in einem Bereich und speichern Sie Änderungen " +"abschnittsweise." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:130 +msgid "" +"Ordnen Sie Abschnitte und Felder in der Reihenfolge, in der sie im Formular " +"erscheinen sollen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:141 +#, fuzzy +#| msgid "Testmodus" +msgid "Arbeitsmodus" +msgstr "Test mode" + +#: workflows/templates/workflows/form_builder.html:142 +#, fuzzy +#| msgid "Letzte Anmeldung" +msgid "Direkte Anordnung" +msgstr "Last login" + +#: workflows/templates/workflows/form_builder.html:143 +msgid "" +"Verschieben Sie Felder direkt zwischen Abschnitten. Die Reihenfolge wird " +"erst mit dem globalen Speichern oben übernommen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:149 +#, fuzzy +#| msgid "Abschnitt" +msgid "aktive Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:157 +#, fuzzy +#| msgid "Ausgeblendet" +msgid "ausgeblendete Felder" +msgstr "Hidden" + +#: workflows/templates/workflows/form_builder.html:169 +#: workflows/templates/workflows/form_builder.html:182 +#: workflows/templates/workflows/form_builder.html:314 +#: workflows/templates/workflows/form_builder.html:325 +#: workflows/templates/workflows/form_builder.html:612 +#: workflows/templates/workflows/form_builder.html:833 +#: workflows/templates/workflows/form_builder.html:844 +#: workflows/templates/workflows/form_builder.html:943 +#, fuzzy, python-format +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "%(count)s Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:184 +#, fuzzy +#| msgid "öffnen" +msgid "Geöffnet" +msgstr "open" + +#: workflows/templates/workflows/form_builder.html:194 +msgid "Eigen" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:195 +#: workflows/templates/workflows/form_builder.html:279 +#: workflows/templates/workflows/form_builder.html:338 +msgid "Fix" +msgstr "Fixed" + +#: workflows/templates/workflows/form_builder.html:196 +#: workflows/templates/workflows/form_builder.html:281 +#: workflows/templates/workflows/form_builder.html:340 +msgid "Ausgeblendet" +msgstr "Hidden" + +#: workflows/templates/workflows/form_builder.html:197 +#: workflows/templates/workflows/form_builder.html:342 +#: workflows/templates/workflows/form_builder.html:355 +#: workflows/templates/workflows/form_builder.html:358 +#: workflows/templates/workflows/form_builder.html:858 +msgid "Pflicht" +msgstr "Required" + +#: workflows/templates/workflows/form_builder.html:201 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Noch keine Felder in diesem Abschnitt." +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:218 +msgid "" +"Legen Sie fest, welche Teile sichtbar, erforderlich oder regelgesteuert sein " +"sollen." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:222 +#, fuzzy +#| msgid "Abschnitt" +msgid "versteckte Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:226 +#, fuzzy +#| msgid "Regelname" +msgid "Regelmodule" +msgstr "Rule name" + +#: workflows/templates/workflows/form_builder.html:228 workflows/views.py:2998 +#, fuzzy +#| msgid "Feldtexte verwalten" +msgid "Feldregeln" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:230 +#: workflows/templates/workflows/form_builder.html:384 workflows/views.py:2999 +msgid "Bedingte Logik" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:240 #, fuzzy #| msgid "Abschnitt" msgid "Abschnitte steuern" msgstr "Section" -#: workflows/templates/workflows/form_builder.html:172 +#: workflows/templates/workflows/form_builder.html:241 +msgid "Reihenfolge und Sichtbarkeit der Formularabschnitte." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:258 +#, fuzzy +#| msgid "Reihenfolge speichern" +msgid "Nach oben" +msgstr "Save order" + +#: workflows/templates/workflows/form_builder.html:261 +msgid "Nach unten" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:267 #, fuzzy, python-format #| msgid "Keine konfigurierten Felder in diesem Schritt." msgid "%(count)s Feld/Felder in diesem Abschnitt." msgstr "No configured fields in this step." -#: workflows/templates/workflows/form_builder.html:183 -#: workflows/templates/workflows/form_builder.html:218 +#: workflows/templates/workflows/form_builder.html:280 +#: workflows/templates/workflows/form_builder.html:351 msgid "Sichtbar" msgstr "" -#: workflows/templates/workflows/form_builder.html:191 +#: workflows/templates/workflows/form_builder.html:288 #, fuzzy #| msgid "Regeln speichern" msgid "Abschnittsregeln speichern" msgstr "Save rules" -#: workflows/templates/workflows/form_builder.html:198 +#: workflows/templates/workflows/form_builder.html:299 #, fuzzy #| msgid "Feldtexte verwalten" msgid "Feldregeln verwalten" msgstr "Manage field text" -#: workflows/templates/workflows/form_builder.html:224 +#: workflows/templates/workflows/form_builder.html:300 +msgid "Steuern Sie Sichtbarkeit und Pflichtstatus für einzelne Felder." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:303 +msgid "konfigurierbar" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:309 +#, fuzzy +#| msgid "Abschnitt" +msgid "Feldregel-Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:344 +msgid "Flexibel" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:357 #, fuzzy #| msgid "Standardsprache" msgid "Standard" msgstr "Default language" -#: workflows/templates/workflows/form_builder.html:226 +#: workflows/templates/workflows/form_builder.html:359 #: workflows/templates/workflows/user_management.html:109 msgid "Optional" msgstr "Optional" -#: workflows/templates/workflows/form_builder.html:237 -msgid "Flexibel" -msgstr "" - -#: workflows/templates/workflows/form_builder.html:242 +#: workflows/templates/workflows/form_builder.html:365 #, fuzzy #| msgid "Keine Feldkonfigurationen verfügbar." msgid "Keine Feldregeln verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:249 +#: workflows/templates/workflows/form_builder.html:373 #, fuzzy #| msgid "Regeln speichern" msgid "Feldregeln speichern" msgstr "Save rules" -#: workflows/templates/workflows/form_builder.html:257 -msgid "Bedingte Logik" +#: workflows/templates/workflows/form_builder.html:385 +msgid "Lassen Sie Felder abhängig von anderen Antworten ein- oder ausblenden." msgstr "" -#: workflows/templates/workflows/form_builder.html:282 -#, python-format -msgid "Bedingung %(number)s" +#: workflows/templates/workflows/form_builder.html:388 +#, fuzzy +#| msgid "Regelname" +msgid "Regeln" +msgstr "Rule name" + +#: workflows/templates/workflows/form_builder.html:394 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Regeln" +msgstr "Save branding" + +#: workflows/templates/workflows/form_builder.html:399 +#, fuzzy, python-format +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "%(count)s Ziel-Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:411 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbarkeit" +msgstr "Safety rules" + +#: workflows/templates/workflows/form_builder.html:415 +msgid "Steuert" msgstr "" -#: workflows/templates/workflows/form_builder.html:285 -#: workflows/templates/workflows/form_builder.html:422 -msgid "Feld" -msgstr "Field" +#: workflows/templates/workflows/form_builder.html:420 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine Ziel-Felder." +msgstr "No scheduled welcome emails available." -#: workflows/templates/workflows/form_builder.html:287 +#: workflows/templates/workflows/form_builder.html:432 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbar, wenn" +msgstr "Safety rules" + +#: workflows/templates/workflows/form_builder.html:437 +#, fuzzy +#| msgid "inaktiv" +msgid "Inaktiv" +msgstr "inactive" + +#: workflows/templates/workflows/form_builder.html:446 +msgid "Zeige dieses Element, wenn" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:448 +#: workflows/templates/workflows/form_builder.html:465 msgid "Keine" msgstr "" -#: workflows/templates/workflows/form_builder.html:294 -#: 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/form_builder.html:302 +#: workflows/templates/workflows/form_builder.html:458 +#: workflows/templates/workflows/form_builder.html:475 #: workflows/templates/workflows/intro_builder.html:64 msgid "Wert" msgstr "Value" -#: workflows/templates/workflows/form_builder.html:303 -msgid "wird ignoriert" +#: workflows/templates/workflows/form_builder.html:461 +#, fuzzy +#| msgid "Zusätzlicher Zugang besprochen: %(item)s" +msgid "Zusätzliche Bedingung" +msgstr "Additional access discussed: %(item)s" + +#: workflows/templates/workflows/form_builder.html:463 +msgid "Und zusätzlich" msgstr "" -#: workflows/templates/workflows/form_builder.html:312 +#: workflows/templates/workflows/form_builder.html:485 #, fuzzy #| msgid "Branding speichern" msgid "Bedingte Logik speichern" msgstr "Save branding" -#: workflows/templates/workflows/form_builder.html:336 -msgid "Optionen verwalten" -msgstr "Manage options" +#: workflows/templates/workflows/form_builder.html:500 +msgid "" +"Pflegen Sie Auswahlwerte, Feldtexte und benutzerdefinierte Erweiterungen." +msgstr "" -#: workflows/templates/workflows/form_builder.html:347 +#: workflows/templates/workflows/form_builder.html:510 +#, fuzzy +#| msgid "Inhalt" +msgid "Inhaltsmodule" +msgstr "Contents" + +#: workflows/templates/workflows/form_builder.html:511 workflows/views.py:3000 +#, fuzzy +#| msgid "Aktion" +msgid "Optionen" +msgstr "Action" + +#: workflows/templates/workflows/form_builder.html:512 workflows/views.py:3001 +#, fuzzy +#| msgid "Feldtexte verwalten" +msgid "Feldtexte" +msgstr "Manage field text" + +#: workflows/templates/workflows/form_builder.html:514 +#: workflows/templates/workflows/form_builder.html:666 workflows/views.py:3002 +#, fuzzy +#| msgid "Abschnitt" +msgid "Eigene Abschnitte" +msgstr "Section" + +#: workflows/templates/workflows/form_builder.html:516 +#: workflows/templates/workflows/form_builder.html:754 workflows/views.py:3003 +msgid "Eigene Felder" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:517 workflows/views.py:3004 +msgid "Vorschau" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:528 msgid "Kategorie" msgstr "Category" -#: workflows/templates/workflows/form_builder.html:360 -#: workflows/templates/workflows/form_builder.html:373 -#: workflows/templates/workflows/form_builder.html:423 -#: workflows/templates/workflows/form_builder.html:470 -#: workflows/templates/workflows/form_builder.html:504 +#: workflows/templates/workflows/form_builder.html:541 +#: workflows/templates/workflows/form_builder.html:571 +#: workflows/templates/workflows/form_builder.html:631 +#: workflows/templates/workflows/form_builder.html:772 +#: workflows/templates/workflows/form_builder.html:889 msgid "Label (DE)" msgstr "Label (DE)" -#: workflows/templates/workflows/form_builder.html:361 -#: workflows/templates/workflows/form_builder.html:471 +#: workflows/templates/workflows/form_builder.html:542 msgid "Label (EN, optional)" msgstr "Label (EN, optional)" -#: workflows/templates/workflows/form_builder.html:362 +#: workflows/templates/workflows/form_builder.html:543 msgid "Technischer Wert (optional)" msgstr "Technical value (optional)" -#: workflows/templates/workflows/form_builder.html:363 +#: workflows/templates/workflows/form_builder.html:544 msgid "Option hinzufügen" msgstr "Add option" -#: workflows/templates/workflows/form_builder.html:374 -#: workflows/templates/workflows/form_builder.html:424 -#: workflows/templates/workflows/form_builder.html:505 -msgid "Label (EN)" -msgstr "Label (EN)" - -#: workflows/templates/workflows/form_builder.html:385 +#: workflows/templates/workflows/form_builder.html:555 msgid "Ziehen zum Sortieren" msgstr "Drag to reorder" -#: workflows/templates/workflows/form_builder.html:392 +#: workflows/templates/workflows/form_builder.html:566 msgid "Option wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/form_builder.html:396 +#: workflows/templates/workflows/form_builder.html:575 +#: workflows/templates/workflows/form_builder.html:635 +#: workflows/templates/workflows/form_builder.html:776 +#: workflows/templates/workflows/form_builder.html:893 +msgid "Label (EN)" +msgstr "Label (EN)" + +#: workflows/templates/workflows/form_builder.html:585 msgid "Keine Optionen in dieser Kategorie." msgstr "No options in this category." -#: workflows/templates/workflows/form_builder.html:402 +#: workflows/templates/workflows/form_builder.html:589 msgid "Optionen speichern" msgstr "Save options" -#: workflows/templates/workflows/form_builder.html:411 +#: workflows/templates/workflows/form_builder.html:600 msgid "Feldtexte verwalten" msgstr "Manage field text" -#: workflows/templates/workflows/form_builder.html:425 -#: workflows/templates/workflows/form_builder.html:539 -msgid "Hilfetext (DE)" -msgstr "Help text (DE)" +#: workflows/templates/workflows/form_builder.html:601 +msgid "Überschreiben Sie Labels und Hilfetexte pro Feld." +msgstr "" -#: workflows/templates/workflows/form_builder.html:426 -#: workflows/templates/workflows/form_builder.html:544 -msgid "Hilfetext (EN)" -msgstr "Help text (EN)" +#: workflows/templates/workflows/form_builder.html:607 +#, fuzzy +#| msgid "Abschnitt" +msgid "Feldtext-Abschnitte" +msgstr "Section" -#: workflows/templates/workflows/form_builder.html:440 +#: workflows/templates/workflows/form_builder.html:632 msgid "Fallback: Standardlabel" msgstr "Fallback: default label" -#: workflows/templates/workflows/form_builder.html:441 +#: workflows/templates/workflows/form_builder.html:636 msgid "English label" msgstr "English label" -#: workflows/templates/workflows/form_builder.html:442 +#: workflows/templates/workflows/form_builder.html:639 +#: workflows/templates/workflows/form_builder.html:804 +#: workflows/templates/workflows/form_builder.html:897 +msgid "Hilfetext (DE)" +msgstr "Help text (DE)" + +#: workflows/templates/workflows/form_builder.html:640 msgid "Optionaler Hilfetext" msgstr "Optional help text" -#: workflows/templates/workflows/form_builder.html:443 +#: workflows/templates/workflows/form_builder.html:643 +#: workflows/templates/workflows/form_builder.html:808 +#: workflows/templates/workflows/form_builder.html:901 +msgid "Hilfetext (EN)" +msgstr "Help text (EN)" + +#: workflows/templates/workflows/form_builder.html:644 msgid "Optional English help text" msgstr "Optional English help text" -#: workflows/templates/workflows/form_builder.html:446 +#: workflows/templates/workflows/form_builder.html:649 msgid "Keine Feldkonfigurationen verfügbar." msgstr "No field configurations available." -#: workflows/templates/workflows/form_builder.html:453 +#: workflows/templates/workflows/form_builder.html:655 msgid "Feldtexte speichern" msgstr "Save field text" -#: workflows/templates/workflows/form_builder.html:487 -#, fuzzy -#| msgid "Hilfetext (DE)" -msgid "Hilfetext (DE, optional)" -msgstr "Help text (DE)" - -#: workflows/templates/workflows/form_builder.html:488 -#, fuzzy -#| msgid "Hilfetext (EN)" -msgid "Hilfetext (EN, optional)" -msgstr "Help text (EN)" - -#: workflows/templates/workflows/form_builder.html:489 -msgid "Optionen (eine pro Zeile, optional: wert|Label)" +#: workflows/templates/workflows/form_builder.html:667 +msgid "Erweitern Sie den Workflow um eigene inhaltliche Blöcke." msgstr "" -#: workflows/templates/workflows/form_builder.html:490 -msgid "Optionen EN (eine pro Zeile, optional: value|Label)" +#: workflows/templates/workflows/form_builder.html:678 +#: workflows/templates/workflows/form_builder.html:697 +#, fuzzy +#| msgid "Punkt hinzufügen" +msgid "Abschnitt hinzufügen" +msgstr "Add item" + +#: workflows/templates/workflows/form_builder.html:679 +msgid "" +"Erstellen Sie zusätzliche Bereiche für deployment-spezifische Informationen." msgstr "" -#: workflows/templates/workflows/form_builder.html:491 +#: workflows/templates/workflows/form_builder.html:684 +#: workflows/templates/workflows/form_builder.html:725 +#, fuzzy +#| msgid "Label (DE)" +msgid "Titel (DE)" +msgstr "Label (DE)" + +#: workflows/templates/workflows/form_builder.html:688 +#: workflows/templates/workflows/form_builder.html:729 +#, fuzzy +#| msgid "Label (EN)" +msgid "Titel (EN)" +msgstr "Label (EN)" + +#: workflows/templates/workflows/form_builder.html:712 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Feld/Felder" +msgstr "No configured fields in this step." + +#: workflows/templates/workflows/form_builder.html:720 +#, python-format +msgid "" +"Eigenen Abschnitt wirklich löschen? %(count)s zugehörige eigene Felder " +"werden ebenfalls entfernt." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:739 +#, fuzzy +#| msgid "Keine geplanten Welcome E-Mails vorhanden." +msgid "Keine eigenen Abschnitte vorhanden." +msgstr "No scheduled welcome emails available." + +#: workflows/templates/workflows/form_builder.html:743 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Abschnitte speichern" +msgstr "Save rules" + +#: workflows/templates/workflows/form_builder.html:755 +msgid "" +"Erstellen Sie zusätzliche Eingaben innerhalb bestehender oder eigener " +"Abschnitte." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:766 #, fuzzy #| msgid "Neue Regel hinzufügen" -msgid "Eigenes Feld hinzufügen" +msgid "Feld hinzufügen" msgstr "Add new rule" -#: workflows/templates/workflows/form_builder.html:500 -#, fuzzy -#| msgid "Onboarding starten" -msgid "Schlüssel" -msgstr "Start onboarding" +#: workflows/templates/workflows/form_builder.html:767 +msgid "" +"Erstellen Sie zusätzliche Eingaben innerhalb eines bestehenden oder eigenen " +"Abschnitts." +msgstr "" -#: workflows/templates/workflows/form_builder.html:501 +#: workflows/templates/workflows/form_builder.html:780 +#: workflows/templates/workflows/form_builder.html:869 #: workflows/templates/workflows/intro_builder.html:29 #: workflows/templates/workflows/intro_builder.html:59 msgid "Abschnitt" msgstr "Section" -#: workflows/templates/workflows/form_builder.html:540 +#: workflows/templates/workflows/form_builder.html:801 +#, fuzzy +#| msgid "Pflicht" +msgid "Pflichtfeld" +msgstr "Required" + +#: workflows/templates/workflows/form_builder.html:812 +#: workflows/templates/workflows/form_builder.html:905 #, fuzzy #| msgid "Aktion" msgid "Optionen (DE)" msgstr "Action" -#: workflows/templates/workflows/form_builder.html:545 +#: workflows/templates/workflows/form_builder.html:813 +msgid "Eine Option pro Zeile, optional: wert|Label" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:816 +#: workflows/templates/workflows/form_builder.html:909 #, fuzzy #| msgid "Aktion" msgid "Optionen (EN)" msgstr "Action" -#: workflows/templates/workflows/form_builder.html:550 +#: workflows/templates/workflows/form_builder.html:817 +msgid "Eine Option pro Zeile, optional: value|Label" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:821 +#, fuzzy +#| msgid "Neue Regel hinzufügen" +msgid "Eigenes Feld hinzufügen" +msgstr "Add new rule" + +#: workflows/templates/workflows/form_builder.html:828 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Eigene Feld-Abschnitte" +msgstr "Save field text" + +#: workflows/templates/workflows/form_builder.html:865 #, fuzzy #| msgid "Option wirklich löschen?" msgid "Eigenes Feld wirklich löschen?" msgstr "Delete this option?" -#: workflows/templates/workflows/form_builder.html:554 +#: workflows/templates/workflows/form_builder.html:915 #, fuzzy #| msgid "Keine geplanten Welcome E-Mails vorhanden." msgid "Keine eigenen Felder vorhanden." msgstr "No scheduled welcome emails available." -#: workflows/templates/workflows/form_builder.html:561 +#: workflows/templates/workflows/form_builder.html:923 #, fuzzy #| msgid "Feldtexte speichern" msgid "Eigene Felder speichern" msgstr "Save field text" +#: workflows/templates/workflows/form_builder.html:934 workflows/views.py:3029 +msgid "Live-Vorschau" +msgstr "" + +#: workflows/templates/workflows/form_builder.html:935 +msgid "So wirkt die aktuelle Struktur für das aktive Formular." +msgstr "" + +#: workflows/templates/workflows/form_builder.html:949 +msgid "Keine sichtbaren Felder." +msgstr "" + #: workflows/templates/workflows/handbook.html:17 msgid "" "Single documentation entry point for both operational knowledge and long-" @@ -2947,7 +3260,7 @@ msgstr "Back to home" #: workflows/templates/workflows/includes/app_header.html:17 #: workflows/templates/workflows/offboarding_success.html:30 #: workflows/templates/workflows/onboarding_success.html:29 -#: workflows/templates/workflows/request_timeline.html:55 +#: workflows/templates/workflows/request_timeline.html:61 msgid "Zum Dashboard" msgstr "Go to dashboard" @@ -3136,6 +3449,12 @@ msgstr "Event" msgid "Feldname" msgstr "Field name" +#: 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:229 #: workflows/templates/workflows/integrations_setup.html:300 msgid "Vergleichswert" @@ -3423,7 +3742,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/request_timeline.html:124 #: workflows/templates/workflows/requests_dashboard.html:217 msgid "PDF öffnen" msgstr "Open PDF" @@ -3466,42 +3785,45 @@ msgid "" "Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt." msgstr "Please check the highlighted fields. Invalid input was detected." -#: workflows/templates/workflows/onboarding_form.html:76 -#: workflows/templates/workflows/onboarding_form.html:78 -#: workflows/templates/workflows/onboarding_form.html:119 -#: workflows/templates/workflows/onboarding_form.html:121 +#: workflows/templates/workflows/onboarding_form.html:62 +#: workflows/templates/workflows/onboarding_form.html:64 +#: workflows/templates/workflows/onboarding_form.html:81 +#: workflows/templates/workflows/onboarding_form.html:83 +#: workflows/templates/workflows/onboarding_form.html:124 +#: workflows/templates/workflows/onboarding_form.html:126 #: workflows/templates/workflows/welcome_emails.html:65 msgid "Alle auswählen" msgstr "Select all" -#: workflows/templates/workflows/onboarding_form.html:77 -#: workflows/templates/workflows/onboarding_form.html:120 +#: workflows/templates/workflows/onboarding_form.html:63 +#: workflows/templates/workflows/onboarding_form.html:82 +#: workflows/templates/workflows/onboarding_form.html:125 #, fuzzy #| msgid "Auswahl löschen" msgid "Auswahl aufheben" msgstr "Delete selection" -#: workflows/templates/workflows/onboarding_form.html:143 +#: workflows/templates/workflows/onboarding_form.html:154 msgid "Keine konfigurierten Felder in diesem Schritt." msgstr "No configured fields in this step." -#: workflows/templates/workflows/onboarding_form.html:148 +#: workflows/templates/workflows/onboarding_form.html:159 msgid "Fast geschafft. Bitte Abschlussdaten prüfen und die Anfrage absenden." msgstr "Almost done. Please review the final details and submit the request." -#: workflows/templates/workflows/onboarding_form.html:160 +#: workflows/templates/workflows/onboarding_form.html:171 msgid "Zurück" msgstr "Back" -#: workflows/templates/workflows/onboarding_form.html:161 +#: workflows/templates/workflows/onboarding_form.html:172 msgid "Weiter" msgstr "Next" -#: workflows/templates/workflows/onboarding_form.html:162 +#: workflows/templates/workflows/onboarding_form.html:173 msgid "Wird gesendet..." msgstr "" -#: workflows/templates/workflows/onboarding_form.html:162 +#: workflows/templates/workflows/onboarding_form.html:173 msgid "Onboarding-Anfrage absenden" msgstr "Submit onboarding request" @@ -3531,7 +3853,7 @@ msgid "Dienstliche E-Mail" msgstr "Work email" #: workflows/templates/workflows/onboarding_intro_session.html:31 -#: workflows/views.py:1544 +#: workflows/views.py:1651 msgid "Vertragsbeginn" msgstr "Contract start" @@ -3796,15 +4118,21 @@ msgid "" msgstr "" #: workflows/templates/workflows/request_timeline.html:4 -#: workflows/templates/workflows/request_timeline.html:51 +#: workflows/templates/workflows/request_timeline.html:57 msgid "Request Timeline" msgstr "" -#: workflows/templates/workflows/request_timeline.html:78 +#: workflows/templates/workflows/request_timeline.html:84 msgid "Hardware-Übergabetermin" msgstr "Hardware handover date" -#: workflows/templates/workflows/request_timeline.html:130 +#: workflows/templates/workflows/request_timeline.html:91 +#, fuzzy +#| msgid "Benutzer erstellen" +msgid "Benutzerdefinierte Felder" +msgstr "Create user" + +#: workflows/templates/workflows/request_timeline.html:150 #, fuzzy #| msgid "Noch keine Vorgänge vorhanden." msgid "Noch keine Timeline-Einträge vorhanden." @@ -4397,354 +4725,417 @@ msgstr "" msgid "Die Signatur-Datei konnte nicht gelesen werden." msgstr "Password could not be saved" -#: workflows/views.py:125 +#: workflows/views.py:123 msgid "Person, Rolle, Abteilung" msgstr "Person, role, department" -#: workflows/views.py:126 +#: workflows/views.py:124 msgid "Beschäftigung und Termine" msgstr "Employment and dates" -#: workflows/views.py:127 +#: workflows/views.py:125 msgid "Geräte, Software und Zugänge" msgstr "Devices, software, and access" -#: workflows/views.py:128 +#: workflows/views.py:126 msgid "Notizen und Freigabe" msgstr "Notes and approval" -#: workflows/views.py:132 +#: workflows/views.py:130 #, fuzzy #| msgid "Deaktivieren" msgid "ist aktiviert" msgstr "Disabled" -#: workflows/views.py:133 +#: workflows/views.py:131 msgid "ist gleich" msgstr "" -#: workflows/views.py:134 +#: workflows/views.py:132 msgid "ist nicht gleich" msgstr "" -#: workflows/views.py:279 +#: workflows/views.py:138 +msgid "Fixes Kernfeld, immer sichtbar." +msgstr "" + +#: workflows/views.py:140 +msgid "Ausgeblendet, erscheint nicht im Formular." +msgstr "" + +#: workflows/views.py:142 +msgid "Sichtbar und als Pflichtfeld markiert." +msgstr "" + +#: workflows/views.py:144 +#, fuzzy +#| msgid "Sicherheitsregeln" +msgid "Sichtbar und optional." +msgstr "Safety rules" + +#: workflows/views.py:145 +msgid "Sichtbar mit Standardverhalten." +msgstr "" + +#: workflows/views.py:156 +#, fuzzy, python-format +#| msgid "Deaktivieren" +msgid "%(field)s ist aktiviert" +msgstr "Disabled" + +#: workflows/views.py:159 +#, python-format +msgid "%(field)s ist gleich %(value)s" +msgstr "" + +#: workflows/views.py:160 +#, python-format +msgid "%(field)s ist gleich" +msgstr "" + +#: workflows/views.py:163 +#, python-format +msgid "%(field)s ist nicht gleich %(value)s" +msgstr "" + +#: workflows/views.py:164 +#, python-format +msgid "%(field)s ist nicht gleich" +msgstr "" + +#: workflows/views.py:165 +#, python-format +msgid "%(field)s erfüllt die Bedingung" +msgstr "" + +#: workflows/views.py:171 +msgid "Immer sichtbar." +msgstr "" + +#: workflows/views.py:173 +#, fuzzy, python-format +#| msgid "Sicherheitsregeln" +msgid "Sichtbar, wenn %(conditions)s." +msgstr "Safety rules" + +#: workflows/views.py:321 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profilbild gespeichert." msgstr "Stored locally" -#: workflows/views.py:281 +#: workflows/views.py:323 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profilbild konnte nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:287 +#: workflows/views.py:329 #, fuzzy #| msgid "Lokal gespeichert" msgid "Profildaten gespeichert." msgstr "Stored locally" -#: workflows/views.py:289 +#: workflows/views.py:331 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Profildaten konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:295 +#: workflows/views.py:337 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungseinstellungen gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:297 +#: workflows/views.py:339 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Benachrichtigungseinstellungen konnten nicht gespeichert werden." msgstr "Password could not be saved" -#: workflows/views.py:306 +#: workflows/views.py:348 #, fuzzy #| msgid "Deaktivieren" msgid "TOTP wurde aktiviert." msgstr "Disabled" -#: workflows/views.py:308 +#: workflows/views.py:350 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht aktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:315 +#: workflows/views.py:357 msgid "TOTP wurde deaktiviert." msgstr "" -#: workflows/views.py:317 +#: workflows/views.py:359 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "TOTP konnte nicht deaktiviert werden." msgstr "Password could not be saved" -#: workflows/views.py:326 +#: workflows/views.py:368 msgid "Recovery-Codes wurden neu erzeugt." msgstr "" -#: workflows/views.py:328 +#: workflows/views.py:370 #, fuzzy #| msgid "Passwort konnte nicht gespeichert werden" msgid "Recovery-Codes konnten nicht neu erzeugt werden." msgstr "Password could not be saved" -#: workflows/views.py:377 workflows/views.py:1630 workflows/views.py:1635 +#: workflows/views.py:419 workflows/views.py:1738 workflows/views.py:1743 msgid "Sie haben keine Berechtigung für diese Aktion." msgstr "You do not have permission for this action." -#: workflows/views.py:458 +#: workflows/views.py:530 #, fuzzy #| msgid "Vorgänge" msgid "Vorgänge gelöscht" msgstr "Requests" -#: workflows/views.py:459 +#: workflows/views.py:531 msgid "Vorgang gelöscht" msgstr "" -#: workflows/views.py:460 +#: workflows/views.py:532 msgid "Vorgang erneut angestoßen" msgstr "" -#: workflows/views.py:461 +#: workflows/views.py:533 #, fuzzy #| msgid "Einweisung" msgid "Einweisungs-PDF erzeugt" msgstr "Introduction" -#: workflows/views.py:462 +#: workflows/views.py:534 #, fuzzy #| msgid "Live-Protokoll erzeugen" msgid "Live-Protokoll erzeugt" msgstr "Generate live protocol" -#: workflows/views.py:463 +#: workflows/views.py:535 #, fuzzy #| msgid "Einweisung wurde zurückgesetzt." msgid "Einweisung zurückgesetzt" msgstr "Introduction was reset." -#: workflows/views.py:464 +#: workflows/views.py:536 #, fuzzy #| msgid "Einweisung wurde als Entwurf gespeichert." msgid "Einweisung als Entwurf gespeichert" msgstr "Introduction was saved as draft." -#: workflows/views.py:465 +#: workflows/views.py:537 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Einweisung abgeschlossen" msgstr "Introduction was saved as completed." -#: workflows/views.py:466 +#: workflows/views.py:538 msgid "Formularoption gelöscht" msgstr "" -#: workflows/views.py:467 +#: workflows/views.py:539 #, fuzzy #| msgid "Optionen speichern" msgid "Formularoptionen gespeichert" msgstr "Save options" -#: workflows/views.py:468 +#: workflows/views.py:540 #, fuzzy #| msgid "Feldtexte speichern" msgid "Feldtexte gespeichert" msgstr "Save field text" -#: workflows/views.py:469 +#: workflows/views.py:541 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Formularlayout gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:470 +#: workflows/views.py:542 msgid "Einweisungs-Checkpunkt gelöscht" msgstr "" -#: workflows/views.py:471 +#: workflows/views.py:543 msgid "Einweisungs-Checkpunkt hinzugefügt" msgstr "" -#: workflows/views.py:472 +#: workflows/views.py:544 #, fuzzy #| msgid "Checkliste speichern" msgid "Einweisungs-Checkliste gespeichert" msgstr "Save checklist" -#: workflows/views.py:473 +#: workflows/views.py:545 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail sofort ausgelöst" msgstr "Welcome Emails" -#: workflows/views.py:474 +#: workflows/views.py:546 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Welcome E-Mail Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:475 +#: workflows/views.py:547 msgid "Welcome E-Mail Sammelaktion ausgeführt" msgstr "" -#: workflows/views.py:476 +#: workflows/views.py:548 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail pausiert" msgstr "Welcome Emails" -#: workflows/views.py:477 +#: workflows/views.py:549 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail fortgesetzt" msgstr "Welcome Emails" -#: workflows/views.py:478 +#: workflows/views.py:550 #, fuzzy #| msgid "Welcome E-Mails" msgid "Welcome E-Mail abgebrochen" msgstr "Welcome Emails" -#: workflows/views.py:479 +#: workflows/views.py:551 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test gesendet" msgstr "SMTP test" -#: workflows/views.py:480 +#: workflows/views.py:552 #, fuzzy #| msgid "Nextcloud-Test" msgid "Nextcloud-Testupload ausgeführt" msgstr "Nextcloud test" -#: workflows/views.py:481 +#: workflows/views.py:553 #, fuzzy #| msgid "Nextcloud schalten" msgid "Nextcloud-Modus umgeschaltet" msgstr "Toggle Nextcloud" -#: workflows/views.py:482 +#: workflows/views.py:554 msgid "E-Mail-Modus umgeschaltet" msgstr "" -#: workflows/views.py:483 +#: workflows/views.py:555 #, fuzzy #| msgid "Integrationen Setup" msgid "Integrationen gespeichert" msgstr "Integrations Setup" -#: workflows/views.py:484 +#: workflows/views.py:556 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Nextcloud-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:485 +#: workflows/views.py:557 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Mail-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:486 +#: workflows/views.py:558 #, fuzzy #| msgid "E-Mail Routing & Vorlagen speichern" msgid "E-Mail-Routing gespeichert" msgstr "Save email routing & templates" -#: workflows/views.py:487 +#: workflows/views.py:559 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Benachrichtigungsregeln gespeichert" msgstr "Save offboarding request" -#: workflows/views.py:488 +#: workflows/views.py:560 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Benutzer erstellt" msgstr "Request saved" -#: workflows/views.py:489 +#: workflows/views.py:561 msgid "Benutzer aktualisiert" msgstr "" -#: workflows/views.py:490 +#: workflows/views.py:562 msgid "Passwort-Reset-Link versendet" msgstr "" -#: workflows/views.py:491 +#: workflows/views.py:563 #, fuzzy #| msgid "Benutzerübersicht" msgid "Benutzer gelöscht" msgstr "User overview" -#: workflows/views.py:492 +#: workflows/views.py:564 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup erstellt" msgstr "Request saved" -#: workflows/views.py:493 +#: workflows/views.py:565 msgid "Backup verifiziert" msgstr "" -#: workflows/views.py:494 +#: workflows/views.py:566 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Backup gelöscht" msgstr "Request saved" -#: workflows/views.py:495 +#: workflows/views.py:567 #, fuzzy #| msgid "Welcome-Einstellungen speichern" msgid "Backup-Einstellungen gespeichert" msgstr "Save welcome settings" -#: workflows/views.py:496 +#: workflows/views.py:568 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert" msgstr "Request saved" -#: workflows/views.py:579 +#: workflows/views.py:684 #, fuzzy #| msgid "Person, Rolle, Abteilung" msgid "Person, Rolle und Bereich" msgstr "Person, role, department" -#: workflows/views.py:580 +#: workflows/views.py:685 msgid "Letzter Arbeitstag" msgstr "" -#: workflows/views.py:581 +#: workflows/views.py:686 #, fuzzy #| msgid "Einweisung wurde als abgeschlossen gespeichert." msgid "Hinweise und Abschlussnotizen" msgstr "Introduction was saved as completed." -#: workflows/views.py:744 +#: workflows/views.py:849 #, fuzzy #| msgid "Anfrage gespeichert" msgid "App-Registry gespeichert." msgstr "Request saved" -#: workflows/views.py:843 +#: workflows/views.py:948 msgid "Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt." msgstr "" -#: workflows/views.py:852 +#: workflows/views.py:957 #, python-format msgid "Zugangseinladung für %(username)s" msgstr "" -#: workflows/views.py:854 +#: workflows/views.py:959 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4757,12 +5148,12 @@ msgid "" "Ihrem Administrator." msgstr "" -#: workflows/views.py:865 +#: workflows/views.py:970 #, python-format msgid "Passwort zurücksetzen für %(username)s" msgstr "" -#: workflows/views.py:867 +#: workflows/views.py:972 #, python-format msgid "" "Hallo %(name)s,\n" @@ -4775,7 +5166,7 @@ msgid "" "ignorieren." msgstr "" -#: workflows/views.py:918 +#: workflows/views.py:1023 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4783,69 +5174,69 @@ msgid "" "Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:946 +#: workflows/views.py:1051 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Portal-Branding wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:963 +#: workflows/views.py:1068 msgid "Identität" msgstr "" -#: workflows/views.py:964 +#: workflows/views.py:1069 msgid "Titel, Firmenname und zentrale Spracheinstellungen." msgstr "" -#: workflows/views.py:968 +#: workflows/views.py:1073 msgid "" "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. " "B. workdock.de." msgstr "" -#: workflows/views.py:973 +#: workflows/views.py:1078 msgid "Farben & Erscheinungsbild" msgstr "" -#: workflows/views.py:974 +#: workflows/views.py:1079 msgid "Zentrale visuelle Markenwerte und Browser-Icon." msgstr "" -#: workflows/views.py:978 +#: workflows/views.py:1083 msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." msgstr "" -#: workflows/views.py:979 +#: workflows/views.py:1084 msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." msgstr "" -#: workflows/views.py:984 +#: workflows/views.py:1089 #, fuzzy #| msgid "Produktion" msgid "Kommunikation" msgstr "Production" -#: workflows/views.py:985 +#: workflows/views.py:1090 msgid "Absender, Support und PDF-Branding für ausgehende Kommunikation." msgstr "" -#: workflows/views.py:989 +#: workflows/views.py:1094 msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet." msgstr "" -#: workflows/views.py:990 +#: workflows/views.py:1095 msgid "Erlaubtes Format: PDF. Maximal 10 MB." msgstr "" -#: workflows/views.py:995 +#: workflows/views.py:1100 msgid "Footer & Rechtliches" msgstr "" -#: workflows/views.py:996 +#: workflows/views.py:1101 msgid "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." msgstr "" -#: workflows/views.py:1050 +#: workflows/views.py:1155 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4854,53 +5245,53 @@ msgid "" "Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:1079 +#: workflows/views.py:1184 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Firmenkonfiguration wurde gespeichert." msgstr "Save offboarding request" -#: workflows/views.py:1096 +#: workflows/views.py:1201 #, fuzzy #| msgid "Firmenname" msgid "Firmenprofil" msgstr "Company name" -#: workflows/views.py:1097 +#: workflows/views.py:1202 msgid "Rechtlicher Name und zentrale Stammdaten der Firma." msgstr "" -#: workflows/views.py:1102 +#: workflows/views.py:1207 msgid "Adresse & Register" msgstr "" -#: workflows/views.py:1103 +#: workflows/views.py:1208 msgid "Anschrift sowie optionale Register- und Steuerangaben." msgstr "" -#: workflows/views.py:1108 +#: workflows/views.py:1213 msgid "Kontaktpunkte" msgstr "" -#: workflows/views.py:1109 +#: workflows/views.py:1214 msgid "Zentrale Ansprechpartner für HR, IT und Operations." msgstr "" -#: workflows/views.py:1114 +#: workflows/views.py:1219 msgid "Recht & Öffentlichkeit" msgstr "" -#: workflows/views.py:1115 +#: workflows/views.py:1220 msgid "Öffentliche Links für Website, Impressum und Datenschutz." msgstr "" -#: workflows/views.py:1117 +#: workflows/views.py:1222 msgid "" "Diese Links können später im Portal-Footer oder in öffentlichen Seiten " "verwendet werden." msgstr "" -#: workflows/views.py:1157 +#: workflows/views.py:1262 #, fuzzy #| msgid "" #| "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." @@ -4909,54 +5300,54 @@ msgid "" "Eingaben." msgstr "Trial configuration could not be saved. Please check the input." -#: workflows/views.py:1189 +#: workflows/views.py:1294 #, fuzzy #| msgid "Trial abgelaufen" msgid "Trial ist abgelaufen" msgstr "Trial expired" -#: workflows/views.py:1190 +#: workflows/views.py:1295 msgid "" "Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt " "blockiert." msgstr "" -#: workflows/views.py:1198 +#: workflows/views.py:1303 msgid "Trial läuft bald ab" msgstr "" -#: workflows/views.py:1199 +#: workflows/views.py:1304 #, python-format msgid "Der Trial endet am %(date)s." msgstr "" -#: workflows/views.py:1207 +#: workflows/views.py:1312 #, fuzzy #| msgid "Trial-Modus" msgid "Trial-Modus deaktiviert" msgstr "Trial mode" -#: workflows/views.py:1208 +#: workflows/views.py:1313 #, fuzzy #| msgid "Nextcloud schalten" msgid "Der Trial-Modus wurde ausgeschaltet." msgstr "Toggle Nextcloud" -#: workflows/views.py:1213 +#: workflows/views.py:1318 msgid "Trial-Konfiguration wurde gespeichert." msgstr "Trial configuration was saved." -#: workflows/views.py:1230 +#: workflows/views.py:1335 msgid "Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben." msgstr "User could not be created. Please check the input." -#: workflows/views.py:1243 +#: workflows/views.py:1348 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde erstellt und eingeladen: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1265 +#: workflows/views.py:1370 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4967,14 +5358,14 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1268 +#: workflows/views.py:1373 msgid "" "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder " "herabstufen." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1271 +#: workflows/views.py:1376 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4985,7 +5376,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1274 +#: workflows/views.py:1379 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -4996,18 +5387,18 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1291 +#: workflows/views.py:1396 #, python-format msgid "Benutzer wurde aktualisiert: %(username)s" msgstr "User updated: %(username)s" -#: workflows/views.py:1313 +#: workflows/views.py:1418 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Passwort-Reset-Link wurde versendet: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1325 +#: workflows/views.py:1430 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -5017,7 +5408,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1328 +#: workflows/views.py:1433 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -5027,7 +5418,7 @@ msgid "" msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1331 +#: workflows/views.py:1436 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -5036,7 +5427,7 @@ msgid "Der letzte aktive Platform Owner kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1334 +#: workflows/views.py:1439 #, fuzzy #| msgid "" #| "Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren " @@ -5045,264 +5436,443 @@ msgid "Der letzte aktive Super Admin kann nicht gelöscht werden." msgstr "" "The currently signed-in super admin cannot lock or downgrade themselves here." -#: workflows/views.py:1347 +#: workflows/views.py:1452 #, fuzzy, python-format #| msgid "Benutzer wurde erstellt: %(username)s" msgid "Benutzer wurde gelöscht: %(username)s" msgstr "User created: %(username)s" -#: workflows/views.py:1438 +#: workflows/views.py:1543 #, fuzzy, python-format #| msgid "Anfrage gespeichert" msgid "Backup erstellt: %(name)s" msgstr "Request saved" -#: workflows/views.py:1439 +#: workflows/views.py:1544 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Das Backup-Bundle wurde erfolgreich erstellt." msgstr "Save offboarding request" -#: workflows/views.py:1444 +#: workflows/views.py:1549 #, python-format msgid "Backup wurde erstellt: %(name)s" msgstr "" -#: workflows/views.py:1454 +#: workflows/views.py:1559 #, python-format msgid "Backup konnte nicht erstellt werden: %(error)s" msgstr "" -#: workflows/views.py:1472 +#: workflows/views.py:1577 #, fuzzy, python-format #| msgid "Backup wird verifiziert" msgid "Backup verifiziert: %(name)s" msgstr "Backup is being verified" -#: workflows/views.py:1473 +#: workflows/views.py:1578 #, fuzzy #| msgid "Backup wird verifiziert" msgid "Das Backup wurde erfolgreich verifiziert." msgstr "Backup is being verified" -#: workflows/views.py:1478 +#: workflows/views.py:1583 #, python-format msgid "Backup wurde verifiziert: %(name)s" msgstr "" -#: workflows/views.py:1482 +#: workflows/views.py:1587 #, fuzzy #| msgid "Fehlgeschlagen" msgid "Backup-Verifikation fehlgeschlagen" msgstr "Failed" -#: workflows/views.py:1488 +#: workflows/views.py:1593 #, python-format msgid "Backup-Verifikation fehlgeschlagen: %(error)s" msgstr "" -#: workflows/views.py:1504 +#: workflows/views.py:1609 #, python-format msgid "Backup wurde gelöscht: %(name)s" msgstr "" -#: workflows/views.py:1506 +#: workflows/views.py:1611 #, python-format msgid "Backup konnte nicht gelöscht werden: %(error)s" msgstr "" -#: workflows/views.py:1532 +#: workflows/views.py:1638 #, fuzzy #| msgid "Anfrage gespeichert" msgid "Anfrage erstellt" msgstr "Request saved" -#: workflows/views.py:1534 +#: workflows/views.py:1640 #, fuzzy, python-format #| msgid "Sitzungsstatus" msgid "Status: %(status)s" msgstr "Session status" -#: workflows/views.py:1546 +#: workflows/views.py:1653 #, fuzzy #| msgid "Geplant für" msgid "Geplanter Start" msgstr "Scheduled for" -#: workflows/views.py:1556 +#: workflows/views.py:1663 msgid "Geräteübergabe / Hardware-Abholung" msgstr "" -#: workflows/views.py:1558 +#: workflows/views.py:1665 msgid "Geplanter Hardware-Termin" msgstr "" -#: workflows/views.py:1567 +#: workflows/views.py:1674 #, fuzzy #| msgid "Noch nicht verfügbar" msgid "PDF verfügbar" msgstr "Not available yet" -#: workflows/views.py:1593 +#: workflows/views.py:1700 #, fuzzy #| msgid "Einweisung" msgid "Einweisungssitzung" msgstr "Introduction" -#: workflows/views.py:1644 +#: workflows/views.py:1752 msgid "Keine Einträge ausgewählt." msgstr "No entries selected." -#: workflows/views.py:1687 +#: workflows/views.py:1795 #, python-format msgid "%(count)s Eintrag/Einträge gelöscht." msgstr "%(count)s entry/entries deleted." -#: workflows/views.py:1689 +#: workflows/views.py:1797 #, python-format msgid "%(count)s Auswahl(en) konnten nicht verarbeitet werden." msgstr "%(count)s selection(s) could not be processed." -#: workflows/views.py:1691 +#: workflows/views.py:1799 msgid "Keine passenden Einträge gefunden." msgstr "No matching entries found." -#: workflows/views.py:1926 +#: workflows/views.py:2038 msgid "Einweisungs- und Übergabeprotokoll wurde erzeugt." msgstr "Introduction and handover protocol was generated." -#: workflows/views.py:1943 +#: workflows/views.py:2055 msgid "Einweisungsprotokoll aus Live-Status wurde erzeugt." msgstr "Introduction protocol from live status was generated." -#: workflows/views.py:1972 +#: workflows/views.py:2084 msgid "Einweisung wurde zurückgesetzt." msgstr "Introduction was reset." -#: workflows/views.py:1986 +#: workflows/views.py:2098 msgid "Einweisung wurde als abgeschlossen gespeichert." msgstr "Introduction was saved as completed." -#: workflows/views.py:1999 +#: workflows/views.py:2111 msgid "Einweisung wurde als Entwurf gespeichert." msgstr "Introduction was saved as draft." -#: workflows/views.py:2560 +#: workflows/views.py:2282 +#, fuzzy +#| msgid "Optionen speichern" +msgid "Option nicht gefunden." +msgstr "Save options" + +#: workflows/views.py:2289 +#, fuzzy +#| msgid "Option wirklich löschen?" +msgid "Option wurde gelöscht." +msgstr "Delete this option?" + +#: workflows/views.py:2294 +msgid "Benutzerdefiniertes Feld nicht gefunden." +msgstr "" + +#: workflows/views.py:2300 +#, fuzzy +#| msgid "Benutzerübersicht" +msgid "Benutzerdefiniertes Feld wurde gelöscht." +msgstr "User overview" + +#: workflows/views.py:2305 +msgid "Benutzerdefinierter Abschnitt nicht gefunden." +msgstr "" + +#: workflows/views.py:2328 +msgid "Benutzerdefinierter Abschnitt wurde gelöscht." +msgstr "" + +#: workflows/views.py:2338 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültige Kategorie." +msgstr "Invalid role." + +#: workflows/views.py:2340 +msgid "Bitte einen Namen für die Option angeben." +msgstr "" + +#: workflows/views.py:2361 +#, fuzzy +#| msgid "Option hinzufügen" +msgid "Option wurde hinzugefügt." +msgstr "Add option" + +#: workflows/views.py:2379 +#, python-format +msgid "Doppelte Bezeichnung in Kategorie: %(label)s" +msgstr "" + +#: workflows/views.py:2383 +#, fuzzy +#| msgid "Optionen speichern" +msgid "Optionen wurden gespeichert." +msgstr "Save options" + +#: workflows/views.py:2397 +#, fuzzy +#| msgid "Feldtexte speichern" +msgid "Feldtexte wurden gespeichert." +msgstr "Save field text" + +#: workflows/views.py:2404 +msgid "Bitte einen Titel für den benutzerdefinierten Abschnitt angeben." +msgstr "" + +#: workflows/views.py:2425 +msgid "Benutzerdefinierter Abschnitt wurde hinzugefügt." +msgstr "" + +#: workflows/views.py:2445 +msgid "Benutzerdefinierte Abschnitte wurden gespeichert." +msgstr "" + +#: workflows/views.py:2460 +msgid "Bitte eine Bezeichnung für das benutzerdefinierte Feld angeben." +msgstr "" + +#: workflows/views.py:2462 +msgid "Ungültiger Abschnitt für das benutzerdefinierte Feld." +msgstr "" + +#: workflows/views.py:2464 +#, fuzzy +#| msgid "Ungültige Rolle." +msgid "Ungültiger Feldtyp." +msgstr "Invalid role." + +#: workflows/views.py:2466 +msgid "Auswahlfelder benötigen mindestens eine Option." +msgstr "" + +#: workflows/views.py:2494 +msgid "Benutzerdefiniertes Feld wurde hinzugefügt." +msgstr "" + +#: workflows/views.py:2525 +#, python-format +msgid "Auswahlfeld \"%(label)s\" benötigt mindestens eine Option." +msgstr "" + +#: workflows/views.py:2530 +#, fuzzy +#| msgid "Keine konfigurierten Felder in diesem Schritt." +msgid "Benutzerdefinierte Felder wurden gespeichert." +msgstr "No configured fields in this step." + +#: workflows/views.py:2550 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Feldregeln wurden gespeichert." +msgstr "Save rules" + +#: workflows/views.py:2589 +#, fuzzy +#| msgid "Regeln speichern" +msgid "Abschnittsregeln wurden gespeichert." +msgstr "Save rules" + +#: workflows/views.py:2610 +#, fuzzy +#| msgid "Branding speichern" +msgid "Bedingte Logik wurde gespeichert." +msgstr "Save branding" + +#: workflows/views.py:2617 +msgid "Preset wurde angewendet." +msgstr "" + +#: workflows/views.py:2619 +#, fuzzy +#| msgid "Passwort konnte nicht gespeichert werden" +msgid "Preset konnte nicht angewendet werden." +msgstr "Password could not be saved" + +#: workflows/views.py:2904 msgid "Visitenkarten-Details" msgstr "" -#: workflows/views.py:2561 +#: workflows/views.py:2905 #, fuzzy #| msgid "Vertragsbeginn" msgid "Vertragsende" msgstr "Contract start" -#: workflows/views.py:2562 +#: workflows/views.py:2906 #, fuzzy #| msgid "Gruppenpostfach erklärt: %(item)s" msgid "Gruppenpostfächer" msgstr "Group mailbox explained: %(item)s" -#: workflows/views.py:2563 +#: workflows/views.py:2907 msgid "Zusätzliche Hardware" msgstr "" -#: workflows/views.py:2564 +#: workflows/views.py:2908 msgid "Zusätzliche Software" msgstr "" -#: workflows/views.py:2565 +#: workflows/views.py:2909 #, fuzzy #| msgid "Zusätzlicher Zugang besprochen: %(item)s" msgid "Zusätzliche Zugänge" msgstr "Additional access discussed: %(item)s" -#: workflows/views.py:2566 +#: workflows/views.py:2910 #, fuzzy #| msgid "Reihenfolge speichern" msgid "Nachfolge" msgstr "Save order" -#: workflows/views.py:2567 -msgid "Direktwahl" -msgstr "" - -#: workflows/views.py:2570 +#: workflows/views.py:2913 msgid "Steuert die Detailfelder für Visitenkarten." msgstr "" -#: workflows/views.py:2571 +#: workflows/views.py:2914 msgid "Steuert das Enddatum bei befristeter Beschäftigung." msgstr "" -#: workflows/views.py:2572 +#: workflows/views.py:2915 msgid "Steuert das Freitextfeld für Gruppenpostfächer." msgstr "" -#: workflows/views.py:2573 +#: workflows/views.py:2916 msgid "Steuert zusätzliche Hardware-Felder." msgstr "" -#: workflows/views.py:2574 +#: workflows/views.py:2917 msgid "Steuert zusätzliche Software-Felder." msgstr "" -#: workflows/views.py:2575 +#: workflows/views.py:2918 msgid "Steuert zusätzliche Zugangsangaben." msgstr "" -#: workflows/views.py:2576 +#: workflows/views.py:2919 msgid "Steuert Nachfolge- und Übernahmefelder." msgstr "" -#: workflows/views.py:2577 -msgid "Steuert die manuelle Direktwahl." +#: workflows/views.py:2929 +msgid "Steuert die Sichtbarkeit dieses benutzerdefinierten Feldes." msgstr "" -#: workflows/views.py:3171 +#: workflows/views.py:3015 +#, fuzzy +#| msgid "Abschnitt" +msgid "Alle Abschnitte" +msgstr "Section" + +#: workflows/views.py:3585 #, fuzzy #| msgid "SMTP-Test starten" msgid "SMTP-Test erfolgreich" msgstr "Run SMTP test" -#: workflows/views.py:3172 +#: workflows/views.py:3586 #, fuzzy #| msgid "Offboarding-Anfrage speichern" msgid "Die SMTP-Testmail wurde erfolgreich gesendet." msgstr "Save offboarding request" -#: workflows/views.py:3181 +#: workflows/views.py:3595 #, fuzzy #| msgid "SMTP-Test" msgid "SMTP-Test fehlgeschlagen" msgstr "SMTP test" -#: workflows/views.py:3187 +#: workflows/views.py:3601 #, fuzzy, python-format #| msgid "Passwort konnte nicht gespeichert werden" msgid "SMTP-Testmail konnte nicht gesendet werden: %(error)s" msgstr "Password could not be saved" -#: workflows/views.py:3212 +#: workflows/views.py:3626 #, fuzzy #| msgid "Nextcloud-Test starten" msgid "Nextcloud-Test erfolgreich" msgstr "Run Nextcloud test" -#: workflows/views.py:3213 +#: workflows/views.py:3627 msgid "Der Testupload nach Nextcloud war erfolgreich." msgstr "" -#: workflows/views.py:3223 workflows/views.py:3233 +#: workflows/views.py:3637 workflows/views.py:3647 #, fuzzy #| msgid "Nextcloud-Test starten" msgid "Nextcloud-Test fehlgeschlagen" msgstr "Run Nextcloud test" -#: workflows/views.py:3224 +#: workflows/views.py:3638 msgid "Der Testupload nach Nextcloud ist fehlgeschlagen." msgstr "" +#, fuzzy +#~| msgid "Feld-Bedingung" +#~ msgid "Keine Bedingung" +#~ msgstr "Field condition" + +#, fuzzy +#~| msgid "Bundle" +#~ msgid "und" +#~ msgstr "Bundle" + +#, fuzzy +#~| msgid "Eingereicht" +#~ msgid "Bereiche" +#~ msgstr "Submitted" + +#, fuzzy +#~| msgid "Ausgeblendet" +#~ msgid "Aktuell ausgeblendet" +#~ msgstr "Hidden" + +#~ msgid "Feld" +#~ msgstr "Field" + +#~ msgid "Optionen verwalten" +#~ msgstr "Manage options" + +#, fuzzy +#~| msgid "Hilfetext (DE)" +#~ msgid "Hilfetext (DE, optional)" +#~ msgstr "Help text (DE)" + +#, fuzzy +#~| msgid "Hilfetext (EN)" +#~ msgid "Hilfetext (EN, optional)" +#~ msgstr "Help text (EN)" + +#, fuzzy +#~| msgid "Onboarding starten" +#~ msgid "Schlüssel" +#~ msgstr "Start onboarding" + #~ msgid "Felder per Drag-and-Drop sortieren und pro Schritt gruppieren." #~ msgstr "Sort fields by drag and drop and group them by step." @@ -5336,9 +5906,6 @@ msgstr "" #~ msgid "Die Passwörter stimmen nicht überein." #~ msgstr "The passwords do not match." -#~ msgid "Benutzer erstellen" -#~ msgstr "Create user" - #~ msgid "Backup läuft" #~ msgstr "Backup in progress" diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index 1906a90..32c9e1d 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -116,6 +116,34 @@ body { gap: 12px; } +.builder-sidebar-context { + gap: 12px; +} + +.builder-context-stack { + display: grid; + gap: 10px; +} + +.builder-context-row { + display: grid; + gap: 3px; + padding-top: 2px; +} + +.builder-context-label { + color: #607086; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.builder-context-row strong { + color: #142033; + font-size: 14px; +} + .builder-side-stat { display: grid; gap: 2px; @@ -147,7 +175,7 @@ body { align-items: flex-end; justify-content: space-between; gap: 20px; - padding: 8px 0 6px; + padding: 4px 0 6px; } .builder-hero-copy { @@ -155,10 +183,10 @@ body { } .builder-hero-sub { - margin: 10px 0 0; - max-width: 640px; + margin: 8px 0 0; + max-width: 620px; color: #5c6d87; - font-size: 15px; + font-size: 14px; line-height: 1.6; } @@ -191,6 +219,88 @@ body { justify-content: flex-end; } +.builder-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin: 10px 0 14px; + padding: 12px 14px; + border: 1px solid #d7e0ec; + border-radius: 16px; + background: linear-gradient(180deg, #fbfdff, #f7fbff); + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04); +} + +.builder-toolbar-main { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.builder-toolbar-chip { + display: inline-grid; + gap: 1px; + min-height: 40px; + padding: 7px 12px; + border: 1px solid #dbe5f1; + border-radius: 14px; + background: #fff; +} + +.builder-toolbar-chip-label { + color: #607086; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.builder-toolbar-chip strong { + font-size: 13px; + color: #142033; +} + +.builder-toolbar-note { + color: #5f7089; + font-size: 12px; + line-height: 1.5; + max-width: 360px; + text-align: right; +} + +.builder-lang-switch { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid #c6d1e1; + border-radius: 999px; + background: #f8fbff; +} + +.builder-lang-btn { + min-width: 38px; + min-height: 34px; + border: 0; + border-radius: 999px; + background: transparent; + color: #304159; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: background-color 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.builder-lang-btn:hover { + transform: translateY(-1px); +} + +.builder-lang-btn.active { + background: linear-gradient(135deg, #0f3b7a 0%, #1759b8 100%); + color: #fff; +} + .tab { border: 1px solid #c6d1e1; border-radius: 999px; @@ -575,9 +685,9 @@ body { .field-rule-row { display: grid; grid-template-columns: minmax(240px, 1.5fr) minmax(120px, 0.55fr) minmax(170px, 0.7fr) auto; - gap: 14px; + gap: 12px; align-items: center; - padding: 14px; + padding: 12px; border: 1px solid #e7edf6; border-radius: 14px; background: rgba(255, 255, 255, 0.96); @@ -598,8 +708,15 @@ body { font-size: 14px; } +.field-rule-summary { + margin-top: 4px; + color: #526379; + font-size: 12px; + line-height: 1.5; +} + .field-rule-meta { - margin-top: 8px; + margin-top: 6px; display: flex; align-items: center; justify-content: space-between; @@ -619,7 +736,7 @@ body { display: flex; align-items: end; justify-content: flex-end; - gap: 12px; + gap: 10px; flex-wrap: wrap; } @@ -673,10 +790,19 @@ body { .conditional-rule-head-main { min-width: 0; + display: grid; + gap: 8px; +} + +.conditional-rule-title-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } .conditional-rule-head h3 { - margin: 2px 0 2px; + margin: 0; font-size: 15px; color: #142033; } @@ -710,6 +836,11 @@ body { height: 15px; } +.conditional-rule-target-inline { + display: grid; + gap: 6px; +} + .conditional-meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -734,6 +865,27 @@ body { background: linear-gradient(180deg, #f6faff, #ffffff); } +.conditional-summary-text { + color: #21354f; + font-size: 13px; + line-height: 1.55; +} + +.conditional-rule-state { + display: grid; + align-content: start; + gap: 4px; + padding: 10px 12px; + border: 1px solid #e7edf6; + border-radius: 12px; + background: #f9fbff; +} + +.conditional-rule-state strong { + color: #142033; + font-size: 14px; +} + .conditional-summary-prefix { color: #294567; font-size: 11px; @@ -759,12 +911,7 @@ body { .conditional-target-chips { display: flex; flex-wrap: wrap; - gap: 8px; -} - -.conditional-clause-list { - display: grid; - gap: 10px; + gap: 6px; } .conditional-sentence-builder { @@ -788,8 +935,7 @@ body { font-size: 11px; font-weight: 800; line-height: 1.45; - text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.01em; } .conditional-sentence-row select, @@ -1807,6 +1953,16 @@ body { justify-content: flex-start; } + .builder-toolbar { + flex-direction: column; + align-items: flex-start; + } + + .builder-toolbar-note { + max-width: none; + text-align: left; + } + .builder-panel-meta { justify-content: flex-start; } @@ -1819,6 +1975,10 @@ body { grid-template-columns: 1fr; } + .conditional-rule-title-row { + align-items: flex-start; + } + .builder-entity-card-head { flex-direction: column; align-items: flex-start; diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 25829d9..5d072cc 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -1,5 +1,6 @@ {% extends 'workflows/base_shell.html' %} {% load static i18n %} +{% get_current_language as CURRENT_LANGUAGE %} {% block title %}{% trans "Form Builder" %}{% endblock %} @@ -33,6 +34,26 @@ +
+ {% trans "Aktive Ansicht" %} +
+
+ {% trans "Workflow" %} + {{ active_form_type_label }} +
+
+ {% trans "Modul" %} + {{ active_module_label }} +
+ {% if active_focus_label %} +
+ {% trans "Fokus" %} + {{ active_focus_label }} +
+ {% endif %} +
+
+
{{ builder_summary.configurable_field_count }} @@ -57,6 +78,12 @@

{% trans "Steuern Sie Struktur, Regeln und Inhalte Ihrer Standard-Workflows an einem Ort." %}

+ + {% csrf_token %} + + + + {% for key, label in form_types %} +
+
+ + {% trans "Workflow" %} + {{ active_form_type_label }} + + + {% trans "Modul" %} + {{ active_module_label }} + + {% if active_focus_label %} + + {% trans "Aktiv" %} + {{ active_focus_label }} + + {% endif %} +
+
+ {% trans "Arbeiten Sie jeweils nur in einem Bereich und speichern Sie Änderungen abschnittsweise." %} +
+
+ {% include 'workflows/includes/messages.html' %}
@@ -281,6 +330,7 @@
{{ item.label }} +
{{ item.summary }}
{{ item.field_name }}
@@ -357,11 +407,20 @@
- {% trans "Sichtbarkeit" %} -

{{ item.title }}

- {% if item.description %} -

{{ item.description }}

- {% endif %} +
+ {% trans "Sichtbarkeit" %} +

{{ item.title }}

+
+
+ {% trans "Steuert" %} +
+ {% for field_name in item.target_fields %} + {{ field_name }} + {% empty %} + {% trans "Keine Ziel-Felder." %} + {% endfor %} +
+
{% include 'workflows/includes/messages.html' %} @@ -140,7 +135,6 @@
{% trans "Arbeitsmodus" %}

{% trans "Direkte Anordnung" %}

-

{% trans "Verschieben Sie Felder direkt zwischen Abschnitten. Die Reihenfolge wird erst mit dem globalen Speichern oben übernommen." %}

From 930f28522bed796f5b0af940c0eedff538b405d7 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Fri, 27 Mar 2026 23:15:35 +0100 Subject: [PATCH 29/45] snapshot: preserve builder before card-style redesign --- .../static/workflows/css/form_builder.css | 164 +++++++----------- .../templates/workflows/form_builder.html | 77 ++------ 2 files changed, 69 insertions(+), 172 deletions(-) diff --git a/backend/workflows/static/workflows/css/form_builder.css b/backend/workflows/static/workflows/css/form_builder.css index e52ceaa..9a178e5 100644 --- a/backend/workflows/static/workflows/css/form_builder.css +++ b/backend/workflows/static/workflows/css/form_builder.css @@ -116,34 +116,6 @@ body { gap: 12px; } -.builder-sidebar-context { - gap: 12px; -} - -.builder-context-stack { - display: grid; - gap: 10px; -} - -.builder-context-row { - display: grid; - gap: 3px; - padding-top: 2px; -} - -.builder-context-label { - color: #607086; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.builder-context-row strong { - color: #142033; - font-size: 14px; -} - .builder-side-stat { display: grid; gap: 2px; @@ -219,48 +191,6 @@ body { justify-content: flex-end; } -.builder-toolbar { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - margin: 8px 0 14px; - padding: 10px 12px; - border: 1px solid #d7e0ec; - border-radius: 16px; - background: linear-gradient(180deg, #fbfdff, #f7fbff); - box-shadow: 0 10px 22px rgba(15, 23, 42, 0.04); -} - -.builder-toolbar-main { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.builder-toolbar-chip { - display: inline-grid; - gap: 1px; - min-height: 40px; - padding: 7px 12px; - border: 1px solid #dbe5f1; - border-radius: 14px; - background: #fff; -} - -.builder-toolbar-chip-label { - color: #607086; - font-size: 10px; - font-weight: 800; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.builder-toolbar-chip strong { - font-size: 13px; - color: #142033; -} - .builder-lang-switch { display: inline-flex; align-items: center; @@ -294,7 +224,6 @@ body { } .builder-main .btn, -.builder-main .tab, .builder-main .builder-module-link, .builder-main .builder-lang-btn { min-height: 38px; @@ -310,9 +239,13 @@ body { } .tab { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 36px; + padding: 0 14px; border: 1px solid #c6d1e1; border-radius: 999px; - padding: 9px 15px; text-decoration: none; color: #1c2a41; background: #f8fbff; @@ -1036,25 +969,18 @@ body { .structure-workspace { display: grid; - grid-template-columns: 260px minmax(0, 1fr); - gap: 16px; + grid-template-columns: minmax(0, 1fr); + gap: 12px; align-items: start; min-width: 0; } -.structure-sidebar { - display: grid; - gap: 12px; - position: sticky; - top: 18px; -} - .structure-canvas { min-width: 0; overflow: visible; - padding-bottom: 4px; + padding-bottom: 0; display: grid; - gap: 14px; + gap: 12px; } .structure-section-nav { @@ -1418,10 +1344,10 @@ body { .option-card { display: grid; - gap: 14px; - padding: 16px; + gap: 12px; + padding: 14px; border: 1px solid #d7e0ec; - border-radius: 18px; + border-radius: 16px; background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); cursor: grab; @@ -1450,7 +1376,7 @@ body { .option-card-order { display: flex; align-items: flex-start; - gap: 12px; + gap: 10px; min-width: 0; } @@ -1462,7 +1388,7 @@ body { .option-card-title-block strong { color: #142033; - font-size: 15px; + font-size: 14px; line-height: 1.35; } @@ -1497,7 +1423,7 @@ body { .option-card-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 10px; } .option-empty-state { @@ -1569,7 +1495,7 @@ body { } .custom-fields-surface .builder-group-card { - border-radius: 14px; + border-radius: 16px; } .custom-fields-surface .builder-group-head { @@ -1577,13 +1503,16 @@ body { } .custom-fields-surface .builder-entity-card { - padding: 11px 12px; - gap: 8px; - border-radius: 12px; + padding: 14px; + gap: 10px; + border-radius: 16px; + border: 1px solid #d7e0ec; + background: linear-gradient(180deg, #fbfdff 0%, #ffffff 100%); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.04); } .custom-fields-surface .builder-entity-card-head strong { - font-size: 13px; + font-size: 14px; } .custom-fields-surface .builder-entity-card-head { @@ -1606,6 +1535,16 @@ body { padding: 7px 9px; } +.custom-fields-surface .builder-entity-grid { + gap: 10px; +} + +.custom-fields-surface .builder-entity-card:hover { + transform: translateY(-1px); + border-color: #c4d4e7; + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.06); +} + .builder-entity-form { display: grid; gap: 12px; @@ -1720,13 +1659,26 @@ body { } .builder-inline-meta { - margin-top: 8px; + margin-top: 6px; display: flex; align-items: center; - gap: 8px; + gap: 6px; flex-wrap: wrap; } +.builder-meta-chip { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 0 8px; + border: 1px solid #dbe5f1; + border-radius: 999px; + background: #f8fbff; + color: #526379; + font-size: 11px; + font-weight: 700; +} + .builder-switch, .builder-switch-inline { display: inline-flex; @@ -1774,10 +1726,10 @@ body { display: grid; grid-template-columns: 30px auto minmax(0, 1fr) auto; align-items: center; - gap: 10px; - padding: 12px 14px; + gap: 8px; + padding: 10px 12px; border: 1px solid #d6e0ec; - border-radius: 14px; + border-radius: 12px; background: linear-gradient(180deg, #fbfdff, #ffffff); transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; width: 100%; @@ -1798,7 +1750,7 @@ body { .section-rule-actions { display: inline-flex; align-items: center; - gap: 5px; + gap: 4px; padding-right: 0; } @@ -1806,8 +1758,8 @@ body { display: inline-flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: 28px; + height: 28px; border-radius: 999px; background: #eaf1ff; color: #23457a; @@ -1817,10 +1769,10 @@ body { } .section-move-btn { - width: 32px; - height: 32px; + width: 30px; + height: 30px; border: 1px solid #cdd9e8; - border-radius: 10px; + border-radius: 9px; background: linear-gradient(180deg, #ffffff, #f5f9ff); color: #274264; font-size: 14px; @@ -1852,7 +1804,7 @@ body { .section-rule-copy strong { color: #0f172a; - font-size: 14px; + font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } diff --git a/backend/workflows/templates/workflows/form_builder.html b/backend/workflows/templates/workflows/form_builder.html index 781da4e..316257c 100644 --- a/backend/workflows/templates/workflows/form_builder.html +++ b/backend/workflows/templates/workflows/form_builder.html @@ -33,26 +33,6 @@ -
- {% trans "Aktive Ansicht" %} -
-
- {% trans "Workflow" %} - {{ active_form_type_label }} -
-
- {% trans "Modul" %} - {{ active_module_label }} -
- {% if active_focus_label %} -
- {% trans "Fokus" %} - {{ active_focus_label }} -
- {% endif %} -
-
-
{{ builder_summary.configurable_field_count }} @@ -94,25 +74,6 @@
-
-
- - {% trans "Workflow" %} - {{ active_form_type_label }} - - - {% trans "Modul" %} - {{ active_module_label }} - - {% if active_focus_label %} - - {% trans "Aktiv" %} - {{ active_focus_label }} - - {% endif %} -
-
- {% include 'workflows/includes/messages.html' %}
@@ -124,35 +85,9 @@

{% trans "Struktur & Reihenfolge" %}

{% trans "Ordnen Sie Abschnitte und Felder in der Reihenfolge, in der sie im Formular erscheinen sollen." %}

-
- {{ columns|length }} {% trans "Abschnitte" %} - {{ builder_summary.configurable_field_count }} {% trans "konfigurierbare Felder" %} -
- -
{{ section.display_title }} - {% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder in diesem Abschnitt.{% endblocktrans %} +
+ {% blocktrans trimmed with count=section.field_count %}{{ count }} Feld/Felder{% endblocktrans %} + {% if section.locked %} + {% trans "Fixer Abschnitt" %} + {% endif %} +