From c5aadd502d9f13a68159295454aef7d5f98542e9 Mon Sep 17 00:00:00 2001 From: foefl Date: Thu, 23 Apr 2026 15:57:39 +0200 Subject: [PATCH] further prototyping, added first DB interactions --- pdm.lock | 283 +++++++--- prototypes/db_access.py | 142 +++++ prototypes/t_qt_2.py | 683 +++++++++++++++++------ prototypes/tests.py | 49 +- pyproject.toml | 2 +- src/wce_crm/__init__.py | 3 + src/wce_crm/backend/__init__.py | 0 src/wce_crm/backend/initial_recording.py | 78 +++ src/wce_crm/db.py | 213 +++++++ src/wce_crm/env.py | 15 + src/wce_crm/env_vars.txt | 2 + src/wce_crm/types.py | 9 + 12 files changed, 1196 insertions(+), 283 deletions(-) create mode 100644 prototypes/db_access.py create mode 100644 src/wce_crm/backend/__init__.py create mode 100644 src/wce_crm/backend/initial_recording.py create mode 100644 src/wce_crm/db.py create mode 100644 src/wce_crm/env.py create mode 100644 src/wce_crm/env_vars.txt create mode 100644 src/wce_crm/types.py diff --git a/pdm.lock b/pdm.lock index dd740fc..385bed5 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "lint", "nb", "tests"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:8bbf5d5c10eaccb24b54658cd427a2e389ec46a845545ccab37e930ea0348598" +content_hash = "sha256:eb4aaeb1bc7efec0c69be5e4cde53b2d92fdacfcc170db0204ef694828f9e99d" [[metadata.targets]] requires_python = ">=3.11,<3.14" @@ -962,6 +962,20 @@ files = [ {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"}, ] +[[package]] +name = "dopt-basics" +version = "0.2.4" +requires_python = ">=3.11" +summary = "basic cross-project tools for Python-based d-opt projects" +groups = ["default"] +dependencies = [ + "tzdata>=2025.1", +] +files = [ + {file = "dopt_basics-0.2.4-py3-none-any.whl", hash = "sha256:b7d05b80dde1f856b352580aeac500fc7505e4513ed162791d8735cdc182ebc1"}, + {file = "dopt_basics-0.2.4.tar.gz", hash = "sha256:c21fbe183bec5eab4cfd1404e10baca670035801596960822d0019e6e885983f"}, +] + [[package]] name = "execnet" version = "2.1.2" @@ -1145,6 +1159,47 @@ files = [ {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] +[[package]] +name = "greenlet" +version = "3.4.0" +requires_python = ">=3.10" +summary = "Lightweight in-process concurrent programming" +groups = ["default"] +marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"" +files = [ + {file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"}, + {file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"}, + {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"}, + {file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"}, + {file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"}, + {file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"}, + {file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"}, + {file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"}, + {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"}, + {file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"}, + {file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"}, + {file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"}, + {file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"}, + {file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"}, + {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"}, + {file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"}, + {file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"}, + {file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"}, + {file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -2397,6 +2452,38 @@ files = [ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] +[[package]] +name = "polars" +version = "1.40.1" +requires_python = ">=3.10" +summary = "Blazingly fast DataFrame library" +groups = ["default"] +dependencies = [ + "polars-runtime-32==1.40.1", +] +files = [ + {file = "polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd"}, + {file = "polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8"}, +] + +[[package]] +name = "polars-runtime-32" +version = "1.40.1" +requires_python = ">=3.10" +summary = "Blazingly fast DataFrame library" +groups = ["default"] +files = [ + {file = "polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030"}, + {file = "polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537"}, + {file = "polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814"}, +] + [[package]] name = "prometheus-client" version = "0.25.0" @@ -2597,24 +2684,24 @@ files = [ [[package]] name = "pydantic" -version = "2.13.0" +version = "2.13.3" requires_python = ">=3.9" summary = "Data validation using Python type hints" groups = ["default", "dev"] dependencies = [ "annotated-types>=0.6.0", - "pydantic-core==2.46.0", + "pydantic-core==2.46.3", "typing-extensions>=4.14.1", "typing-inspection>=0.4.2", ] files = [ - {file = "pydantic-2.13.0-py3-none-any.whl", hash = "sha256:ab0078b90da5f3e2fd2e71e3d9b457ddcb35d0350854fbda93b451e28d56baaf"}, - {file = "pydantic-2.13.0.tar.gz", hash = "sha256:b89b575b6e670ebf6e7448c01b41b244f471edd276cd0b6fe02e7e7aca320070"}, + {file = "pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927"}, + {file = "pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d"}, ] [[package]] name = "pydantic-core" -version = "2.46.0" +version = "2.46.3" requires_python = ">=3.9" summary = "Core functionality for Pydantic validation and serialization" groups = ["default", "dev"] @@ -2622,90 +2709,60 @@ dependencies = [ "typing-extensions>=4.14.1", ] files = [ - {file = "pydantic_core-2.46.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0027da787ae711f7fbd5a76cb0bb8df526acba6c10c1e44581de1b838db10b7b"}, - {file = "pydantic_core-2.46.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:63e288fc18d7eaeef5f16c73e65c4fd0ad95b25e7e21d8a5da144977b35eb997"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:080a3bdc6807089a1fe1fbc076519cea287f1a964725731d80b49d8ecffaa217"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c065f1c3e54c3e79d909927a8cb48ccbc17b68733552161eba3e0628c38e5d19"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e2db58ab46cfe602d4255381cce515585998c3b6699d5b1f909f519bc44a5aa"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c660974890ec1e4c65cff93f5670a5f451039f65463e9f9c03ad49746b49fc78"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3be91482a8db77377c902cca87697388a4fb68addeb3e943ac74f425201a099"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:1c72de82115233112d70d07f26a48cf6996eb86f7e143423ec1a182148455a9d"}, - {file = "pydantic_core-2.46.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7904e58768cd79304b992868d7710bfc85dc6c7ed6163f0f68dbc1dcd72dc231"}, - {file = "pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1af8d88718005f57bb4768f92f4ff16bf31a747d39dfc919b22211b84e72c053"}, - {file = "pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:a5b891301b02770a5852253f4b97f8bd192e5710067bc129e20d43db5403ede2"}, - {file = "pydantic_core-2.46.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:48b671fe59031fd9754c7384ac05b3ed47a0cccb7d4db0ec56121f0e6a541b90"}, - {file = "pydantic_core-2.46.0-cp311-cp311-win32.whl", hash = "sha256:0a52b7262b6cc67033823e9549a41bb77580ac299dc964baae4e9c182b2e335c"}, - {file = "pydantic_core-2.46.0-cp311-cp311-win_amd64.whl", hash = "sha256:4103fea1beeef6b3a9fed8515f27d4fa30c929a1973655adf8f454dc49ee0662"}, - {file = "pydantic_core-2.46.0-cp311-cp311-win_arm64.whl", hash = "sha256:3137cd88938adb8e567c5e938e486adc7e518ffc96b4ae1ec268e6a4275704d7"}, - {file = "pydantic_core-2.46.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:66ccedb02c934622612448489824955838a221b3a35875458970521ef17b2f9c"}, - {file = "pydantic_core-2.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a44f27f4d2788ef9876ec47a43739b118c5904d74f418f53398f6ced3bbcacf2"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26a1032bcce6ca4b4670eb3f7d8195bd0a8b8f255f1307823e217ca3cfa7c27"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b8d1412f725060527e56675904b17a2d421dddcf861eecf7c75b9dda47921a4"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc3d1569edd859cabaa476cabce9eecd05049a7966af7b4a33b541bfd4ca1104"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38108976f2d8afaa8f5067fd1390a8c9f5cc580175407cda636e76bc76e88054"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a5a06d8ed01dad5575056b5187e5959b336793c6047920a3441ee5b03533836"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:04017ace142da9ce27cafd423a480872571b5c7e80382aec22f7d715ca8eb870"}, - {file = "pydantic_core-2.46.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2629ad992ed1b1c012e6067f5ffafd3336fcb9b54569449fabb85621f1444ed3"}, - {file = "pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3068b1e7bd986aebc88f6859f8353e72072538dcf92a7fb9cf511a0f61c5e729"}, - {file = "pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:1e366916ff69ff700aa9326601634e688581bc24c5b6b4f8738d809ec7d72611"}, - {file = "pydantic_core-2.46.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:485a23e8f4618a1b8e23ac744180acde283fffe617f96923d25507d5cade62ec"}, - {file = "pydantic_core-2.46.0-cp312-cp312-win32.whl", hash = "sha256:520940e1b702fe3b33525d0351777f25e9924f1818ca7956447dabacf2d339fd"}, - {file = "pydantic_core-2.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:90d2048e0339fa365e5a66aefe760ddd3b3d0a45501e088bc5bc7f4ed9ff9571"}, - {file = "pydantic_core-2.46.0-cp312-cp312-win_arm64.whl", hash = "sha256:a70247649b7dffe36648e8f34be5ce8c5fa0a27ff07b071ea780c20a738c05ce"}, - {file = "pydantic_core-2.46.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a05900c37264c070c683c650cbca8f83d7cbb549719e645fcd81a24592eac788"}, - {file = "pydantic_core-2.46.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de8e482fd4f1e3f36c50c6aac46d044462615d8f12cfafc6bebeaa0909eea22"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c525ecf8a4cdf198327b65030a7d081867ad8e60acb01a7214fff95cf9832d47"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f14581aeb12e61542ce73b9bfef2bca5439d65d9ab3efe1a4d8e346b61838f9b"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c108067f2f7e190d0dbd81247d789ec41f9ea50ccd9265a3a46710796ac60530"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ac10967e9a7bb1b96697374513f9a1a90a59e2fb41566b5e00ee45392beac59"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7897078fe8a13b73623c0955dfb2b3d2c9acb7177aac25144758c9e5a5265aaa"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e69ce405510a419a082a78faed65bb4249cfb51232293cc675645c12f7379bf7"}, - {file = "pydantic_core-2.46.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd28d13eea0d8cf351dc1fe274b5070cc8e1cca2644381dee5f99de629e77cf3"}, - {file = "pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ee1547a6b8243e73dd10f585555e5a263395e55ce6dea618a078570a1e889aef"}, - {file = "pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c3dc68dcf62db22a18ddfc3ad4960038f72b75908edc48ae014d7ac8b391d57a"}, - {file = "pydantic_core-2.46.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:004a2081c881abfcc6854a4623da6a09090a0d7c1398a6ae7133ca1256cee70b"}, - {file = "pydantic_core-2.46.0-cp313-cp313-win32.whl", hash = "sha256:59d24ec8d5eaabad93097525a69d0f00f2667cb353eb6cda578b1cfff203ceef"}, - {file = "pydantic_core-2.46.0-cp313-cp313-win_amd64.whl", hash = "sha256:71186dad5ac325c64d68fe0e654e15fd79802e7cc42bc6f0ff822d5ad8b1ab25"}, - {file = "pydantic_core-2.46.0-cp313-cp313-win_arm64.whl", hash = "sha256:8e4503f3213f723842c9a3b53955c88a9cfbd0b288cbd1c1ae933aebeec4a1b4"}, - {file = "pydantic_core-2.46.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4fc801c290342350ffc82d77872054a934b2e24163727263362170c1db5416ca"}, - {file = "pydantic_core-2.46.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0a36f2cc88170cc177930afcc633a8c15907ea68b59ac16bd180c2999d714940"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a3912e0c568a1f99d4d6d3e41def40179d61424c0ca1c8c87c4877d7f6fd7fb"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3534c3415ed1a19ab23096b628916a827f7858ec8db49ad5d7d1e44dc13c0d7b"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21067396fc285609323a4db2f63a87570044abe0acddfcca8b135fc7948e3db7"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2afd85b7be186e2fe7cdbb09a3d964bcc2042f65bbcc64ad800b3c7915032655"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67e2c2e171b78db8154da602de72ffdc473c6ee51de8a9d80c0f1cd4051abfc7"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c16ae1f3170267b1a37e16dba5c297bdf60c8b5657b147909ca8774ce7366644"}, - {file = "pydantic_core-2.46.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:133b69e1c1ba34d3702eed73f19f7f966928f9aa16663b55c2ebce0893cca42e"}, - {file = "pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:15ed8e5bde505133d96b41702f31f06829c46b05488211a5b1c7877e11de5eb5"}, - {file = "pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:8cfc29a1c66a7f0fcb36262e92f353dd0b9c4061d558fceb022e698a801cb8ae"}, - {file = "pydantic_core-2.46.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e1155708540f13845bf68d5ac511a55c76cfe2e057ed12b4bf3adac1581fc5c2"}, - {file = "pydantic_core-2.46.0-cp314-cp314-win32.whl", hash = "sha256:de5635a48df6b2eef161d10ea1bc2626153197333662ba4cd700ee7ec1aba7f5"}, - {file = "pydantic_core-2.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:f07a5af60c5e7cf53dd1ff734228bd72d0dc9938e64a75b5bb308ca350d9681e"}, - {file = "pydantic_core-2.46.0-cp314-cp314-win_arm64.whl", hash = "sha256:e7a77eca3c7d5108ff509db20aae6f80d47c7ed7516d8b96c387aacc42f3ce0f"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5e7cdd4398bee1aaeafe049ac366b0f887451d9ae418fd8785219c13fea2f928"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c2c92d82808e27cef3f7ab3ed63d657d0c755e0dbe5b8a58342e37bdf09bd2e"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bab80af91cd7014b45d1089303b5f844a9d91d7da60eabf3d5f9694b32a6655"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e49ffdb714bc990f00b39d1ad1d683033875b5af15582f60c1f34ad3eeccfaa"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca877240e8dbdeef3a66f751dc41e5a74893767d510c22a22fc5c0199844f0ce"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87e6843f89ecd2f596d7294e33196c61343186255b9880c4f1b725fde8b0e20d"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e20bc5add1dd9bc3b9a3600d40632e679376569098345500799a6ad7c5d46c72"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:ee6ff79a5f0289d64a9d6696a3ce1f98f925b803dd538335a118231e26d6d827"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52d35cfb58c26323101c7065508d7bb69bb56338cda9ea47a7b32be581af055d"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d14cc5a6f260fa78e124061eebc5769af6534fc837e9a62a47f09a2c341fa4ea"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:4f7ff859d663b6635f6307a10803d07f0d09487e16c3d36b1744af51dbf948b2"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8ef749be6ed0d69dba31902aaa8255a9bb269ae50c93888c4df242d8bb7acd9e"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-win32.whl", hash = "sha256:d93ca72870133f86360e4bb0c78cd4e6ba2a0f9f3738a6486909ffc031463b32"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ebb2668afd657e2127cb40f2ceb627dd78e74e9dfde14d9bf6cdd532a29ff59"}, - {file = "pydantic_core-2.46.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4864f5bbb7993845baf9209bae1669a8a76769296a018cb569ebda9dcb4241f5"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:be3e04979ba4d68183f247202c7f4f483f35df57690b3f875c06340a1579b47c"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1eae8d7d9b8c2a90b34d3d9014804dca534f7f40180197062634499412ea14e"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a95a2773680dd4b6b999d4eccdd1b577fd71c31739fb4849f6ada47eabb9c56"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25988c3159bb097e06abfdf7b21b1fcaf90f187c74ca6c7bb842c1f72ce74fa8"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:747d89bd691854c719a3381ba46b6124ef916ae85364c79e11db9c84995d8d03"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:909a7327b83ca93b372f7d48df0ebc7a975a5191eb0b6e024f503f4902c24124"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2f7e6a3752378a69fadf3f5ee8bc5fa082f623703eec0f4e854b12c548322de0"}, - {file = "pydantic_core-2.46.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ef47ee0a3ac4c2bb25a083b3acafb171f65be4a0ac1e84edef79dd0016e25eaa"}, - {file = "pydantic_core-2.46.0.tar.gz", hash = "sha256:82d2498c96be47b47e903e1378d1d0f770097ec56ea953322f39936a7cf34977"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5"}, + {file = "pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa"}, + {file = "pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b"}, + {file = "pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346"}, + {file = "pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67"}, + {file = "pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396"}, + {file = "pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976"}, + {file = "pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1"}, + {file = "pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37"}, + {file = "pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687"}, + {file = "pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23"}, + {file = "pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0"}, + {file = "pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8"}, + {file = "pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff"}, + {file = "pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c"}, ] [[package]] @@ -3585,6 +3642,49 @@ files = [ {file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.49" +requires_python = ">=3.7" +summary = "Database Abstraction Library" +groups = ["default"] +dependencies = [ + "greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"", + "importlib-metadata; python_version < \"3.8\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"}, + {file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"}, + {file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"}, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -3716,8 +3816,7 @@ name = "tzdata" version = "2026.1" requires_python = ">=2" summary = "Provider of IANA time zone data" -groups = ["nb"] -marker = "python_version >= \"3.9\"" +groups = ["default", "nb"] files = [ {file = "tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9"}, {file = "tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98"}, diff --git a/prototypes/db_access.py b/prototypes/db_access.py new file mode 100644 index 0000000..d20c4d3 --- /dev/null +++ b/prototypes/db_access.py @@ -0,0 +1,142 @@ +# %% +import importlib +from pathlib import Path + +import polars as pl +import sqlalchemy as sql + +from wce_crm import db + +importlib.reload(db) + +# %% +PTH_DATA_DB = Path.cwd().parent / "data/db" +assert PTH_DATA_DB.exists() +assert PTH_DATA_DB.is_dir() +# %% +DB_KL = PTH_DATA_DB / "wce_kontaktliste.db" +DB_CRM = PTH_DATA_DB / "wce_crm.db" +assert DB_KL.exists() +assert DB_CRM.exists() +# %% +engine = sql.create_engine(f"sqlite:///{DB_CRM}") +# %% +db.df_crm_master +# %% +stmt = sql.select(db.ext_crm_master) +str(stmt.compile(engine)) +df = pl.read_database(stmt, engine, schema_overrides=db.ext_crm_master_schema) +# df = pl.concat([df, df[:2]]) +# %% +df.select("ma_unternehmensname").is_duplicated().sum() +# %% +q = df.lazy() +counter = pl.int_range(0, pl.len()).over(pl.col.ma_unternehmensname) +q = q.with_columns( + ma_unternehmensname_dedupl=pl.when(counter == 0) + .then(pl.col.ma_unternehmensname) + .otherwise(pl.format("{} ({})", pl.col.ma_unternehmensname, counter)) +) +df = q.collect() +df.select("ma_unternehmensname_dedupl").is_duplicated().sum() + +# %% +# mapping dedupl text to idx +df.head() + +# dict(zip(df["ma_unternehmensname_dedupl"], df["ma_id"])) + +# %% +sub = df[0] +sub +# sub.with_columns( +# # pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein")) +# pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein")).name.keep() +# ) +# %% +q = ( + sub.lazy() + .with_columns( + pl.col(pl.Datetime).dt.to_string("%d.%m.%Y"), + pl.col(pl.Date).dt.to_string("%d.%m.%Y"), + pl.when(pl.col(pl.Boolean)).then(pl.lit("Ja")).otherwise(pl.lit("Nein")).name.keep(), + ) + .with_columns(pl.all().cast(pl.String)) +) +sub = q.collect() +sub +# %% +df.row(0, named=True) + +# %% +db.df_crm_master.estimated_size("mb") + + +# %% +# // CRM Nutzer +stmt = sql.select(db.ext_crm_nutzer).limit(20) +str(stmt.compile(engine)) +df = pl.read_database(stmt, engine, schema_overrides=db.ext_crm_nutzer_schema) +# %% +stmt = sql.text("""SELECT ma_unternehmensname, ma_ersteintrag_datum, ma_aktualisierung_datum +FROM Master +WHERE ma_ersteintrag_datum LIKE '%ff' +LIMIT 10;""") + +with engine.connect() as con: + res = con.execute(stmt) + +print(res.fetchall()) + +# %% +# ---------------------------------------------------------------- +engine = sql.create_engine(f"sqlite:///{DB_KL}") +stmt = sql.select(db.ext_kl_unternehmen.c.u_firmenname).limit(20) + +with engine.connect() as con: + res = con.execute(stmt) + +res.scalars().all() +# %% +for _ in res.mappings(): + print(_) +# %% +# %% +stmt = sql.select(db.ext_kl_unternehmen) +df = pl.read_database(stmt, engine, schema_overrides=db.ext_kl_unternehmen_schema) +# %% +df +# %% +df.estimated_size("mb") +# %% +df.height +# %% +db.df_kontaktliste +# %% +sub = db.df_kontaktliste.select(["u_id", "u_firmenname"]).lazy() +# %% +counter = pl.int_range(0, pl.len()).over(pl.col.u_firmenname) +sub = sub.with_columns( + t=pl.when(counter == 0) + .then(pl.col.u_firmenname) + .otherwise(pl.format("{} ({})", pl.col.u_firmenname, counter)) +) +# %% +sub.collect() + +# %% +# 1. Create a sample DataFrame +df = pl.DataFrame({"text_col": ["TEST", "APPLE", "TEST", "TEST", "BANANA", "APPLE"]}) + +# 2. Define the window function to count occurrences +# This generates a sequence [0, 1, 2...] for each unique string +counter = pl.int_range(0, pl.len()).over("text_col") + +# 3. Apply the conditional formatting +df = df.with_columns( + updated_col=pl.when(counter == 0) + .then(pl.col("text_col")) # Keep original for the first occurrence + .otherwise(pl.format("{} ({})", pl.col("text_col"), counter)) # Format duplicates +) +# %% +df diff --git a/prototypes/t_qt_2.py b/prototypes/t_qt_2.py index 3dbd37d..d670ebb 100644 --- a/prototypes/t_qt_2.py +++ b/prototypes/t_qt_2.py @@ -1,24 +1,30 @@ from __future__ import annotations import dataclasses as dc +import enum import sys +import time +from collections.abc import Sequence -from PySide6.QtCore import Qt, Signal # Signal ist wichtig! +from PySide6.QtCore import QDate, Qt, QTimer, Signal # Signal ist wichtig! from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, QComboBox, QCompleter, + QDateEdit, QDialog, QDialogButtonBox, QFormLayout, QFrame, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMainWindow, + QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, @@ -28,6 +34,22 @@ from PySide6.QtWidgets import ( QWidget, ) +from wce_crm.backend import initial_recording as be_init_rec + +QSS = """ +*[styleClass="stempel"] { + background-color: #f1f5f9; + color: #333D4B; + border: 1px dashed #cbd5e1; + border-radius: 4px; + padding: 5px; +} + +*[styleClass="stempel"]:focus { + border: 1px dashed #cbd5e1; +} +""" + @dc.dataclass(slots=True) class Address: @@ -134,16 +156,24 @@ class AddressForm_Search(QWidget): super().__init__() main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) form_layout = QFormLayout() - form_layout.setSpacing(15) + form_layout.setSpacing(10) + + # title + title = QLabel("--- Suche Unternehmen ---") + title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold; + main_layout.addWidget(title) self.search_input = QLineEdit(placeholderText="Tippen zum Suchen...") form_layout.addRow("Suche:", self.search_input) - search_data = [addr.name for addr in ADDRESSES] - self.SEARCH_MAP = {addr.name: addr for addr in ADDRESSES} - self.completer = QCompleter(search_data) - self.completer.setCaseSensitivity(Qt.CaseInsensitive) - self.completer.setFilterMode(Qt.MatchContains) + # search_data = [addr.name for addr in ADDRESSES] + # self.SEARCH_MAP = {addr.name: addr for addr in ADDRESSES} + self.SEARCH_MAP = be_init_rec.comp_search_choice_mapping() + self.search_data = tuple(self.SEARCH_MAP.keys()) + self.completer = QCompleter(self.search_data) + self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self.completer.setFilterMode(Qt.MatchFlag.MatchContains) self.search_input.setCompleter(self.completer) self.completer.activated.connect(self.search_result_selected) @@ -207,15 +237,23 @@ class AddressForm_Search(QWidget): } """) - def fill_out(self, address: Address): - addr_ = address.export() + def fill_out(self, comp_info: be_init_rec.CompanyInfo): + # addr_ = address.export() - for field, value in zip(self.autofilled_fields, addr_.values()): - field.setText(value) + # for field, value in zip(self.autofilled_fields, addr_.values()): + # field.setText(value) + self.company_input.setText(comp_info["ma_unternehmensname"]) + self.street_input.setText(comp_info["ma_strasse"]) + self.number_input.setText(comp_info["ma_hausnummer"]) + self.zip_input.setText(comp_info["ma_plz"]) + self.city_input.setText(comp_info["ma_ort"]) def search_result_selected(self, name): - address = self.SEARCH_MAP[name] - self.fill_out(address) + + comp_info = be_init_rec.comp_search_get_info( + ma_id=self.SEARCH_MAP[name], + ) + self.fill_out(comp_info) class DropdownSearch(QWidget): @@ -242,11 +280,11 @@ class DropdownSearch(QWidget): # --- WICHTIGE EINSTELLUNGEN --- # Ignoriert Groß-/Kleinschreibung (sehr wichtig für eine gute Suche!) - self.completer.setCaseSensitivity(Qt.CaseInsensitive) + self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) # 'MatchContains' sorgt dafür, dass "pha" auch "Projekt Alpha" findet. # Standard ist 'MatchStartsWith' (findet nur Worte am Anfang). - self.completer.setFilterMode(Qt.MatchContains) + self.completer.setFilterMode(Qt.MatchFlag.MatchContains) # 4. Den Completer an das Eingabefeld binden self.search_input.setCompleter(self.completer) @@ -261,78 +299,405 @@ class DropdownSearch(QWidget): # Hier könntest du z.B. deine Detail-Seite für dieses Projekt öffnen +class FormFieldType(enum.StrEnum): + TEXT = enum.auto() + LONGTEXT = enum.auto() + DATE = enum.auto() + DATETIME = enum.auto() + + +@dc.dataclass(slots=True) +class FormField: + key: str + label: str + type: FormFieldType + required: bool + placeholder: str | None = None + fill_value: str | None = None + readonly: bool = False + + def __post_init__(self) -> None: + self.label = self.label.strip() + if not self.label.endswith(":"): + self.label += ":" + if self.required: + self.label += "*" + + +@dc.dataclass(slots=True) +class FormFieldGroup: + key: str + label: str + fields: Sequence[FormField] + + +FORM_FIELD_DEF = [ + FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), + FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False), + FormField( + "ext_data", + "Externe Daten", + FormFieldType.TEXT, + True, + fill_value="Lorem ipsum und so weiter...", + readonly=True, + ), + FormField("init_date", "Auftragsdatum", FormFieldType.DATE, True), + FormField("start_date", "Startdatum", FormFieldType.DATE, True), + FormField( + "ms_date1", + "MS Datum 1", + FormFieldType.DATE, + False, + "", + fill_value="26.07.2026", + readonly=True, + ), + FormField( + "ms_date2", + "MS Datum 2", + FormFieldType.DATE, + False, + "", + fill_value="30.08.2026", + readonly=False, + ), + FormField( + "important:notes", + "Wichtige Notizen", + FormFieldType.LONGTEXT, + True, + "Text eingeben...", + ), +] + +FORM_FIELD_DEF2 = [ + FormField("name", "Projektname", FormFieldType.TEXT, True, "Bitte füllen..."), + FormField("descr", "Beschreibung", FormFieldType.LONGTEXT, False), + FormField( + "ext_data", + "Externe Daten", + FormFieldType.TEXT, + True, + fill_value="Lorem ipsum und so weiter...", + readonly=True, + ), + FormField("init_date", "Auftragsdatum", FormFieldType.DATE, True), + FormField("start_date", "Startdatum", FormFieldType.DATE, True), + FormField( + "ms_date1", + "MS Datum 1", + FormFieldType.DATE, + False, + "", + fill_value="26.07.2026", + readonly=True, + ), + FormField( + "ms_date2", + "MS Datum 2", + FormFieldType.DATE, + False, + "", + fill_value="30.08.2026", + readonly=False, + ), + FormField( + "important:notes", + "Wichtige Notizen", + FormFieldType.LONGTEXT, + True, + "Text eingeben...", + ), +] + +FORM_FIELD_GROUPS = [ + FormFieldGroup("group1", "Test-1", FORM_FIELD_DEF), + FormFieldGroup("group2", "Test-2", FORM_FIELD_DEF2), +] + + class MyForm(QWidget): - def __init__(self): + def __init__( + self, + form_field_groups: Sequence[FormFieldGroup], + add_buttons: bool = True, + ) -> None: super().__init__() + # --- LAYOUT --- + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.form_field_groups = form_field_groups - # Das Herzstück: Das Form-Layout - # Es kümmert sich automatisch darum, dass alle Labels links - # und alle Felder rechts perfekt bündig untereinander stehen. - self.form_layout = QFormLayout(self) - self.form_layout.setSpacing(15) # Abstand zwischen den Zeilen + for fg in form_field_groups: + widget = MyFormPart(fg.fields, fg.label, add_buttons=False) + self.main_layout.addWidget(widget) - # Definition deiner Felder - # 'key' ist der Name, unter dem du die Daten später abrufst - # 'label' ist der Text, der angezeigt wird - # 'type' bestimmt, welches Widget erstellt wird - self.field_definitions = [ - {"key": "name", "label": "Projektname:", "type": "text"}, - {"key": "date", "label": "Datum:", "type": "date", "value": "22.04.2026"}, - { - "key": "status", - "label": "Status:", - "type": "text", - "placeholder": "z.B. Aktiv", - }, - {"key": "desc", "label": "Beschreibung:", "type": "longtext"}, - {"key": "notes", "label": "Interne Notizen:", "type": "longtext"}, - ] + # buttons + # self.add_buttons = add_buttons + # if self.add_buttons: + # self.layout_btn = QHBoxLayout() + # self.main_layout.addLayout(self.layout_btn) + # self.save_btn_txt_enabled = "Speichern (Strg + S)" + # self.save_btn_txt_disabled = "Wird gespeichert..." + # self.save_btn = QPushButton(self.save_btn_txt_enabled) + # self.save_btn.setShortcut("Ctrl+S") + # self.save_btn.setFixedHeight(50) + # self.save_btn.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # self.save_btn.clicked.connect(self.on_save_clicked) + # self.layout_btn.addWidget(self.save_btn) + # self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") + # self.reset_btn.setShortcut("Ctrl+Z") + # self.reset_btn.setFixedHeight(50) + # self.reset_btn.setSizePolicy( + # QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + # ) + # self.reset_btn.clicked.connect(self.reset_form) + # self.layout_btn.addWidget(self.reset_btn) - # Dictionary, um die erstellten Widgets zu speichern (für späteren Zugriff) - self.widgets = {} - # Automatischer Aufbau des Formulars +class MyFormPart(QWidget): + def __init__( + self, + field_definitons: Sequence[FormField], + group_name: str | None = None, + add_buttons: bool = False, + ): + super().__init__() + self.setStyleSheet(""" + QGroupBox { + font-size: 16px; + font-weight: bold; + border: 1px solid #cbd5e1; /* Heller, moderner Rahmen */ + border-radius: 8px; /* Abgerundete Ecken */ + margin-top: 15px; /* Platz für die Überschrift schaffen */ + padding-top: 15px; /* Abstand zwischen Rahmen und erstem Feld */ + } + + QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; /* Überschrift oben links */ + padding: 0 5px; /* Etwas Luft links und rechts vom Text */ + color: #334155; /* Dunkelgraue Schrift */ + } + """) + + # --- LAYOUT --- + self.main_layout = QVBoxLayout(self) + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.form_layout = QFormLayout() + self.group_box: QGroupBox | None = None + self.group_name = group_name + + if self.group_name: + self.group_box = QGroupBox(group_name) + self.main_layout.addWidget(self.group_box) + self.group_box.setLayout(self.form_layout) + else: + self.main_layout.addLayout(self.form_layout) + + self.form_layout.setSpacing(10) # Abstand zwischen den Zeilen + + self.field_definitions = field_definitons + self.widgets: dict[str, QWidget] = {} + # automatic build self.create_form_fields() - def create_form_fields(self): + # buttons + self.add_buttons = add_buttons + if self.add_buttons: + self.layout_btn = QHBoxLayout() + self.main_layout.addLayout(self.layout_btn) + self.save_btn_txt_enabled = "Speichern (Strg + S)" + self.save_btn_txt_disabled = "Wird gespeichert..." + self.save_btn = QPushButton(self.save_btn_txt_enabled) + self.save_btn.setShortcut("Ctrl+S") + self.save_btn.setFixedHeight(50) + self.save_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.save_btn.clicked.connect(self.on_save_clicked) + self.layout_btn.addWidget(self.save_btn) + self.reset_btn = QPushButton("Zurücksetzen (Strg + Z)") + self.reset_btn.setShortcut("Ctrl+Z") + self.reset_btn.setFixedHeight(50) + self.reset_btn.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.reset_btn.clicked.connect(self.reset_form) + self.layout_btn.addWidget(self.reset_btn) + + def create_form_fields(self) -> None: for field in self.field_definitions: widget = None - # Entscheidung: Welches Widget wird benötigt? - if field["type"] == "text": - widget = QLineEdit() - if "placeholder" in field: - widget.setPlaceholderText(field["placeholder"]) - if "value" in field: - widget.setText(field["value"]) - widget.setReadOnly(True) # Falls es ein Fixwert ist + match field.type: + case FormFieldType.TEXT: + widget = QLineEdit() + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.fill_value: + widget.setText(field.fill_value) + if field.readonly: + widget.setReadOnly(True) # Falls es ein Fixwert ist + widget.setProperty("styleClass", "stempel") - elif field["type"] == "longtext": - widget = QPlainTextEdit() - widget.setMaximumHeight(80) # Kompakte Höhe für Formulare + case FormFieldType.LONGTEXT: + widget = QPlainTextEdit() + widget.setMaximumHeight(80) # Kompakte Höhe für Formulare + if field.placeholder: + widget.setPlaceholderText(field.placeholder) + if field.readonly: + widget.setReadOnly(True) # Falls es ein Fixwert ist + widget.setProperty("styleClass", "stempel") - elif field["type"] == "date": - widget = QLineEdit() # Oder QDateEdit - widget.setText(field.get("value", "")) - widget.setReadOnly(True) - widget.setStyleSheet("background-color: #f1f5f9; border: 1px dashed #cbd5e1;") + case FormFieldType.DATE: + widget = QDateEdit() # Oder QDateEdit + widget.setCalendarPopup(True) + cal = widget.calendarWidget() + if cal: + cal.setFirstDayOfWeek(Qt.DayOfWeek.Monday) + cal.setGridVisible(True) + cal.setStyleSheet(""" + QCalendarWidget QAbstractItemView { + selection-background-color: #2980b9; + selection-color: white; + } + """) + if field.fill_value: + set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{field.fill_value}<" + ) + widget.setDate(set_date) + else: + widget.setDate(QDate.currentDate()) + + if field.readonly: + widget = QLineEdit() + widget.setReadOnly(True) # Falls es ein Fixwert ist + widget.setProperty("styleClass", "stempel") + if field.fill_value: + widget.setText(field.fill_value) + + case _: + raise NotImplementedError(f"Not supported field type: {field.type.value}") if widget: - # Widget im Dictionary speichern, um später darauf zuzugreifen - self.widgets[field["key"]] = widget - # Dem Form-Layout hinzufügen (Label links, Widget rechts) - self.form_layout.addRow(field["label"], widget) + self.widgets[field.key] = widget + self.form_layout.addRow(field.label, widget) - def get_form_data(self): + def get_form_data(self) -> ...: """Liest alle Felder automatisch aus""" data = {} for key, widget in self.widgets.items(): - if isinstance(widget, QLineEdit): + if isinstance(widget, (QLineEdit, QDateEdit)): data[key] = widget.text() elif isinstance(widget, QPlainTextEdit): data[key] = widget.toPlainText() + return data + def _disable_save(self) -> None: + self.save_btn.setEnabled(False) + self.save_btn.setText(self.save_btn_txt_disabled) + + def _enable_save( + self, + timeout: int = 3000, + ) -> None: + QTimer.singleShot(timeout, lambda: self.save_btn.setEnabled(True)) + QTimer.singleShot(timeout + 1, lambda: self.save_btn.setShortcut("Ctrl+S")) + self.save_btn.setText(self.save_btn_txt_enabled) + + def on_save_clicked(self) -> None: + self._disable_save() + errors = [] # Hier sammeln wir die Namen der fehlenden Felder + + # time.sleep(0.5) + + # Wir gehen unsere Feld-Definitionen durch + for field in self.field_definitions: + widget = self.widgets[field.key] + + # 1. Zuerst setzen wir das Design des Feldes wieder auf "Normal" zurück. + # Falls der Nutzer den Fehler vorher schon korrigiert hat, muss der rote Rand weg! + if not field.readonly: + widget.setStyleSheet("") + + # 2. Ist es überhaupt ein Pflichtfeld? + if not field.required: + continue + + is_empty = False + if isinstance(widget, (QLineEdit, QDateEdit)): + if not widget.text().strip(): + is_empty = True + + elif isinstance(widget, QPlainTextEdit): + if not widget.toPlainText().strip(): + is_empty = True + + if not is_empty: + continue + + errors.append( + field.label.replace("*", "").replace(":", "") + ) # Sternchen für die Fehlermeldung entfernen + + # Optisches Feedback: Heller roter Hintergrund und roter Rand + widget.setStyleSheet(""" + border: 1px solid #ef4444; + background-color: #ffe9e9; + padding: 4px; + border-radius: 4px; + """) + + # --- ERGEBNIS AUSWERTEN --- + if errors: + # Es gibt Fehler! Speichern abbrechen und Pop-up anzeigen. + error_text = "Bitte fülle die folgenden Pflichtfelder aus:\n\n- " + "\n- ".join( + errors + ) + QMessageBox.warning(self, "Fehlende Angaben", error_text) + self._enable_save() + return + + # Wenn wir hier ankommen, ist die Liste 'errors' leer. Alles ist korrekt ausgefüllt! + # time.sleep(0.5) + print("Erfolg! Alle Daten sind valide.") + self.reset_form() + self._enable_save() + + def reset_form(self) -> None: + for field in self.field_definitions: + widget = self.widgets[field.key] + + if field.readonly: + continue + + if isinstance(widget, QLineEdit): + widget.clear() + if field.fill_value: + widget.setText(field.fill_value) + elif isinstance(widget, QPlainTextEdit): + widget.clear() + elif isinstance(widget, QDateEdit): + if field.fill_value: + set_date = QDate.fromString(field.fill_value, "dd.MM.yyyy") + if not set_date.isValid(): + raise ValueError( + f"Could not parse date field value >{field.fill_value}<" + ) + widget.setDate(set_date) + else: + widget.setDate(QDate.currentDate()) + + widget.setStyleSheet("") + class ClickableCell(QFrame): # Wir definieren ein Signal, das ein Dictionary (die Daten) mitschickt @@ -355,11 +720,11 @@ class ClickableCell(QFrame): layout = QVBoxLayout(self) label = QLabel(text) label.setWordWrap(True) - label.setAlignment(Qt.AlignCenter) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(label) def mousePressEvent(self, event): - if event.button() == Qt.LeftButton: + if event.button() == Qt.MouseButton.LeftButton: # Wenn geklickt wird, senden wir die Daten aus self.clicked.emit(self.data_record) @@ -369,7 +734,7 @@ class HeaderCell(QLabel): super().__init__(text) # Textausrichtung zentrieren - self.setAlignment(Qt.AlignCenter) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) # Styling: Fetter Text, grauer Hintergrund (entspricht slate-200), leicht abgerundet self.setStyleSheet(""" @@ -407,7 +772,9 @@ class NewEntryDialog(QDialog): layout.addRow("Datum:", self.input_date) # Standard-Buttons (OK und Abbrechen) - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) buttons.accepted.connect(self.accept) # Schließt Dialog und meldet "Erfolg" buttons.rejected.connect(self.reject) # Schließt Dialog und meldet "Abbruch" layout.addWidget(buttons) @@ -431,7 +798,7 @@ class DetailView(QWidget): def __init__(self): super().__init__() layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) + layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) # Zurück-Button back_btn = QPushButton("← Zurück zur Tabelle") @@ -470,7 +837,7 @@ class NewEntrySelect_view(QWidget): def __init__(self): super().__init__() layout = QVBoxLayout(self) - layout.setAlignment(Qt.AlignTop | Qt.AlignLeft) + layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) # Zurück-Button back_btn = QPushButton("← Zurück zur Übersicht") @@ -524,38 +891,65 @@ class SearchFormPage(QWidget): def __init__(self): super().__init__() # Hauptlayout der Seite - main_layout = QVBoxLayout(self) + outer_layout = QHBoxLayout(self) + vert_layout = QVBoxLayout() # main_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.addStretch(1) + outer_layout.addLayout(vert_layout, stretch=100) + # outer_layout.addWidget(scroll_area, stretch=100) + outer_layout.addStretch(1) + # Optional: Damit der Container oben am Rand klebt + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # --- 1. HEADER --- - header_layout = QVBoxLayout() - upper_button_group_v = QVBoxLayout() - upper_button_group = QHBoxLayout() - upper_button_group.addLayout(upper_button_group_v) - upper_button_group.addStretch() - # header_layout = QGridLayout() - # header_layout.setColumnStretch(0, 1) - # header_layout.setColumnStretch(1, 1) + # --- HEADER --- + header_container = QWidget() + header_container.setMinimumWidth(700) + header_container.setMaximumWidth(1000) + header_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + header_layout = QVBoxLayout(header_container) + header_layout.setContentsMargins(0, 0, 0, 10) back_btn_main = QPushButton("← Zurück zur Übersicht") back_btn_main.clicked.connect(lambda: self.back_main_requested.emit()) + back_btn_main.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_main.setMinimumWidth(200) + back_btn_main.setMaximumWidth(200) back_btn_step = QPushButton("← Zurück") back_btn_step.clicked.connect(lambda: self.back_requested.emit()) + back_btn_step.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + back_btn_step.setMinimumWidth(200) + back_btn_step.setMaximumWidth(200) title = QLabel("Grunderfassung Unternehmen") title.setStyleSheet("font-size: 20px; font-weight: bold;") - upper_button_group_v.addWidget(back_btn_step) - upper_button_group_v.addWidget(back_btn_main) - - header_layout.addLayout(upper_button_group) - header_layout.addSpacing(15) - # header_layout.addWidget(back_btn_step) + header_layout.setSpacing(5) + header_layout.addWidget(back_btn_step) + header_layout.addWidget(back_btn_main) header_layout.addWidget(title) - # header_layout.addStretch() # Drückt den Titel nach links - main_layout.addLayout(header_layout) + vert_layout.addWidget(header_container) + + # --- HAUPTINHALT --- + container = QWidget() + # SCROLL-BEREICH + scroll_area = QScrollArea() + scroll_area.setWidgetResizable( + True + ) # WICHTIG: Erlaubt dem Grid im Inneren, sich an die Breite anzupassen + scroll_area.setMinimumWidth(700) + scroll_area.setMaximumWidth(1000) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + # Optional: Rahmen der ScrollArea entfernen, damit es "flacher" und moderner aussieht + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + scroll_area.setWidget(container) + + # vert_layout.addSpacing(20) + vert_layout.addWidget(scroll_area) + + # --- KOPF Metadaten --- + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) - # --- KOPF Unternehmen --- inf_block_1 = QHBoxLayout() inhalte = [ "Fall-Nr.:", @@ -565,7 +959,7 @@ class SearchFormPage(QWidget): ] for entry in inhalte: label = QLabel(entry) - label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) field = QLineEdit(placeholderText="...") field.setText("22.04.2026") field.setReadOnly(True) @@ -582,107 +976,47 @@ class SearchFormPage(QWidget): border: 1px #cbd5e1; } """) - field.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + field.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) inf_block_1.addWidget(label) inf_block_1.addWidget(field) - inf_block_1.addStretch() - main_layout.addLayout(inf_block_1) + container_layout.addLayout(inf_block_1) # --- NOTIZEN Unternehmen --- # eventuell später verknüpft inf_block_2 = QHBoxLayout() label = QLabel("Notizen:") - label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) inf_block_2.addWidget(label, alignment=Qt.AlignmentFlag.AlignTop) inf_block_2.addWidget(QPlainTextEdit(placeholderText="Notizen ergänzen...")) - main_layout.addLayout(inf_block_2) - main_layout.addSpacing(10) + container_layout.addLayout(inf_block_2) + container_layout.addSpacing(10) - # --- Suche mit Namen - # inf_block_3 = ( - # QVBoxLayout() - # ) # Horizontal, damit Suchen und Filtern nebeneinander stehen - # name_input = QLineEdit(placeholderText="Tippe zum Suchen...") - # name_input.setMinimumWidth(150) - # name_input.setMaximumWidth(600) - # inf_block_3_1 = QHBoxLayout() - # inf_block_3_1.addWidget(QLabel("Name")) - # inf_block_3_1.addWidget(name_input, stretch=100) - # inf_block_3_1.addStretch() + # --- Test Formularlayout --- + container_layout.addSpacing(30) + title = QLabel("--- Automatische Form ---") + title.setStyleSheet("font-size: 14px; font-style: italic;") # font-weight: bold; + container_layout.addWidget(title) + # container_layout.addWidget(MyFormPart(FORM_FIELD_DEF, "Test-Gruppe")) + container_layout.addWidget(MyForm(FORM_FIELD_GROUPS)) - # inf_block_3_2 = QHBoxLayout() - # inf_block_3_3 = QHBoxLayout() - # demo_data = { - # "name": "Test UG", - # "Straße": "Teststraße", - # "Hausnummer": "12", - # "PLZ": "09111", - # "Ort": "Chemnitz", - # } - - # current_block = inf_block_3_2 - # for entry in ("Straße", "Hausnummer", "PLZ", "Ort"): - # if entry == "PLZ": - # current_block = inf_block_3_3 - - # label = QLabel(entry) - # label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - # field = QLineEdit() - # field.setText(demo_data[entry]) - # field.setReadOnly(True) - # field.setStyleSheet(""" - # QLineEdit { - # background-color: #f1f5f9; /* Helles System-Grau */ - # color: #333D4B; /* Etwas blassere Schrift */ - # border: 1px dashed #cbd5e1; /* Ein gestrichelter Rand wirkt oft wie ein "Stempel" */ - # border-radius: 4px; - # padding: 5px; - # } - # /* Wenn das Feld fokussiert wird, keinen blauen Rand anzeigen */ - # QLineEdit:focus { - # border: 1px dashed #cbd5e1; - # } - # """) - # if entry in ("Hausnummer", "PLZ"): - # field.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - # field.setMinimumWidth(50) - # field.setMaximumWidth(50) - # else: - # field.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - # field.setMinimumWidth(100) - # field.setMaximumWidth(300) - # current_block.addWidget(label) - # current_block.addWidget(field, stretch=100) - - # inf_block_3_2.addStretch() - # inf_block_3_3.addStretch() - - # inf_block_3.addLayout(inf_block_3_1) - # inf_block_3.addLayout(inf_block_3_2) - # inf_block_3.addLayout(inf_block_3_3) - # main_layout.addLayout(inf_block_3) - - main_layout.addSpacing(30) - - main_layout.addWidget(MyForm()) - - main_layout.addSpacing(30) + container_layout.addSpacing(30) + # --- SUCHE MIT NAMEN --- # addr = Address("Test UG", "Teststraße", 202, "09111", "Chemnitz") # addr_widget = AddressForm() # addr_widget.fill_out(addr) addr_widget = AddressForm_Search() - main_layout.addWidget(addr_widget) + container_layout.addWidget(addr_widget) - main_layout.addSpacing(30) + container_layout.addSpacing(30) - main_layout.addWidget(DropdownSearch()) + # container_layout.addWidget(DropdownSearch()) - main_layout.addSpacing(30) + container_layout.addSpacing(30) - # --- 2. SUCH-FORMULAR --- + # --- SUCH-FORMULAR --- form_layout = ( QHBoxLayout() ) # Horizontal, damit Suchen und Filtern nebeneinander stehen @@ -702,12 +1036,12 @@ class SearchFormPage(QWidget): form_layout.addWidget(QLabel("Status:")) form_layout.addWidget(self.status_filter, stretch=1) - main_layout.addLayout(form_layout) + container_layout.addLayout(form_layout) # --- 3. ERGEBNIS-BEREICH --- # Für den Anfang ein einfaches Listen-Widget self.results_list = QListWidget() - main_layout.addWidget( + container_layout.addWidget( self.results_list, stretch=100 ) # Nimmt den restlichen Platz ein @@ -740,7 +1074,7 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Master") - self.resize(1800, 200) + self.resize(1800, 1000) # --- 1. DAS MENÜ ERSTELLEN --- self.create_menu() @@ -801,9 +1135,9 @@ class MainWindow(QMainWindow): scroll_area.setMaximumWidth( 1500 ) # Die Breiten-Begrenzung wandert nun auf die ScrollArea - scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Optional: Rahmen der ScrollArea entfernen, damit es "flacher" und moderner aussieht - scroll_area.setFrameShape(QFrame.NoFrame) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) scroll_area.setWidget(container) vert_layout.addWidget(new_btn) @@ -817,7 +1151,7 @@ class MainWindow(QMainWindow): # outer_layout.addWidget(scroll_area, stretch=100) outer_layout.addStretch(1) # Optional: Damit der Container oben am Rand klebt - outer_layout.setAlignment(Qt.AlignTop) + outer_layout.setAlignment(Qt.AlignmentFlag.AlignTop) # Wir geben dem Container ein vertikales Layout container_layout = QVBoxLayout(container) @@ -961,6 +1295,7 @@ class MainWindow(QMainWindow): if __name__ == "__main__": app = QApplication(sys.argv) + app.setStyleSheet(QSS) window = MainWindow() window.show() sys.exit(app.exec()) diff --git a/prototypes/tests.py b/prototypes/tests.py index e8fbb86..41c09ad 100644 --- a/prototypes/tests.py +++ b/prototypes/tests.py @@ -1,28 +1,45 @@ # %% import dataclasses as dc +import enum + +from PySide6.QtCore import QDate, Qt + + +class FormFieldType(enum.StrEnum): + TEXT = enum.auto() + LONGTEXT = enum.auto() + DATE = enum.auto() + DATETIME = enum.auto() -# %% @dc.dataclass(slots=True) -class Address: - street: str - number: int - postal_code: str - city: str +class FormField: + key: str + label: str + type: FormFieldType + required: bool - def export(self): - data = {} - for f in dc.fields(self): - val = getattr(self, f.name) - if f.type is int: - val = str(val) - data[f.name] = val - - return data + def __post_init__(self) -> None: + self.label = self.label.strip() + if not self.label.endswith(":"): + self.label += ":" + if self.required: + self.label += "*" # %% -addr = Address("Teststraße", 202, "09111", "Chemnitz") +FormField("name", "Projektbeschreibung", FormFieldType.LONGTEXT, required=True) +# %% +FormField("name", "Projektbeschreibung:", FormFieldType.LONGTEXT, required=True) + +# %% +FormField("name", "Projektbeschreibung", FormFieldType.LONGTEXT, required=False) +# %% +FormField("name", "Projektbeschreibung:", FormFieldType.LONGTEXT, required=False) # %% addr.export() # %% +set_date = QDate.fromString("26.07.2026", "dd.MM.yyyy") + +# %% +Qt.Tet diff --git a/pyproject.toml b/pyproject.toml index 472aa9f..2ca2ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "GUI for CRM of NAFKA project with WCE" authors = [ {name = "d-opt GmbH, resp. Florian Förster", email = "f.foerster@d-opt.com"}, ] -dependencies = ["nicegui>=3.10.0", "pyside6>=6.11.0"] +dependencies = ["nicegui>=3.10.0", "pyside6>=6.11.0", "sqlalchemy>=2.0.49", "polars>=1.40.1", "dopt-basics>=0.2.4"] requires-python = "<3.14,>=3.11" readme = "README.md" license = {text = "LicenseRef-Proprietary"} diff --git a/src/wce_crm/__init__.py b/src/wce_crm/__init__.py index e69de29..5100c41 100644 --- a/src/wce_crm/__init__.py +++ b/src/wce_crm/__init__.py @@ -0,0 +1,3 @@ +import wce_crm.env + +wce_crm.env.setup() diff --git a/src/wce_crm/backend/__init__.py b/src/wce_crm/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wce_crm/backend/initial_recording.py b/src/wce_crm/backend/initial_recording.py new file mode 100644 index 0000000..03f5ced --- /dev/null +++ b/src/wce_crm/backend/initial_recording.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import TypedDict, cast + +import polars as pl + +from wce_crm import db + + +class CompanyInfo(TypedDict): + ma_id: str + wce_id: str + ma_unternehmensname: str + ma_branche: str + ma_strasse: str + ma_hausnummer: str + ma_plz: str + ma_ort: str + ma_plz_postfach: str + ma_postfach: str + ma_website: str + ma_mail: str + ma_telefonnummer: str + ma_faxnummer: str + ma_ersteintrag_datum: str + ma_aktualisierung_datum: str + ma_aktualisierung_nutzer: str + ma_sollprozess: str + ma_auslaendische_mitarbeiter: str + ma_quelle_information: str + ma_bemerkung: str + ma_kontakt: str + ma_schlagworte: str + ma_archiviert: str + + +def _transform_for_gui_output( + data: pl.DataFrame, +) -> pl.DataFrame: + q = ( + data.lazy() + .with_columns( + pl.col(pl.Datetime).dt.to_string("%d.%m.%Y"), + pl.col(pl.Date).dt.to_string("%d.%m.%Y"), + pl.when(pl.col(pl.Boolean)) + .then(pl.lit("Ja")) + .otherwise(pl.lit("Nein")) + .name.keep(), + ) + .with_columns(pl.all().cast(pl.String)) + ) + + return q.collect() + + +def comp_search_choice_mapping() -> dict[str, int]: + # TODO no reload functionality + q = db.df_crm_master.lazy() + counter = pl.int_range(0, pl.len()).over(pl.col.ma_unternehmensname) + q = q.with_columns( + ma_unternehmensname_dedupl=pl.when(counter == 0) + .then(pl.col.ma_unternehmensname) + .otherwise(pl.format("{} ({})", pl.col.ma_unternehmensname, counter)) + ) + df = q.collect() + + return dict(zip(df["ma_unternehmensname_dedupl"], df["ma_id"])) + + +def comp_search_get_info( + ma_id: int, +) -> CompanyInfo: + df = db.df_crm_master.filter(pl.col.ma_id == ma_id) + if df.height > 1 or df.height == 0: + raise ValueError(f"Größe des zurückgelieferten Datenpakets ungültig: {df.height}") + + df = _transform_for_gui_output(df) + return cast(CompanyInfo, df.row(0, named=True)) diff --git a/src/wce_crm/db.py b/src/wce_crm/db.py new file mode 100644 index 0000000..1ecb081 --- /dev/null +++ b/src/wce_crm/db.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import os +import re +from datetime import datetime +from pathlib import Path + +import polars as pl +import sqlalchemy as sql +from sqlalchemy import Column, String, Table, TypeDecorator + +from wce_crm import types as t + + +class SafeDateTime(TypeDecorator): + """Cleans non-standard ISO strings before parsing.""" + + impl = String # We treat the underlying data as a String first + + def process_result_value(self, value, dialect): + if value is None: + return None + + # 1. Remove the trailing 'ff' (or any trailing letters) + # 2. Replace comma with dot (SQLAlchemy prefers . over ,) + clean_value = re.sub(r"[a-zA-Z]+$", "", value).replace(",", ".") + + try: + return datetime.fromisoformat(clean_value) + except ValueError: + # Fallback if it's still weird + return None + + +md_kontaktliste = sql.MetaData() +md_crm = sql.MetaData() + +ext_kl_unternehmen: sql.Table = Table( + "Unternehmen", + md_kontaktliste, + Column("u_id", sql.Integer, nullable=False, unique=True), + Column("u_zeitstempel_eintrag", sql.DateTime, nullable=False), + Column("u_rechtsform", sql.Text, nullable=False), + Column("u_firmenname", sql.Text, nullable=False), + Column("u_strasse", sql.Text, nullable=False), + Column("u_hausnummer", sql.Text, nullable=False), + Column("u_adresszusatz", sql.Text, nullable=True), + Column("u_plz", sql.Text, nullable=False), + Column("u_ort", sql.Text, nullable=False), + Column("u_postfach", sql.Text, nullable=True), + Column("u_website", sql.Text, nullable=True), + Column("u_anrede", sql.Text, nullable=False), + Column("u_titel", sql.Text, nullable=True), + Column("u_vorname", sql.Text, nullable=False), + Column("u_nachname", sql.Text, nullable=False), + Column("u_funktion", sql.Text, nullable=False), + Column("u_mail", sql.Text, nullable=False), + Column("u_telefon", sql.Text, nullable=False), + Column("u_plz_postfach", sql.Text, nullable=True), + Column("u_einwilligung_inhaber", sql.Boolean, nullable=True), + Column("u_einwilligung_ansprechpartner", sql.Boolean, nullable=True), + Column("u_aktiv", sql.Boolean, nullable=False, default=1), +) + +ext_kl_unternehmen_schema: t.PolarsSchema = { + "u_id": pl.UInt64, + "u_zeitstempel_eintrag": pl.Datetime, + "u_rechtsform": pl.String, + "u_firmenname": pl.String, + "u_strasse": pl.String, + "u_hausnummer": pl.String, + "u_adresszusatz": pl.String, + "u_plz": pl.String, + "u_ort": pl.String, + "u_postfach": pl.String, + "u_website": pl.String, + "u_anrede": pl.String, + "u_titel": pl.String, + "u_vorname": pl.String, + "u_nachname": pl.String, + "u_funktion": pl.String, + "u_mail": pl.String, + "u_telefon": pl.String, + "u_plz_postfach": pl.String, + "u_einwilligung_inhaber": pl.Boolean, + "u_einwilligung_ansprechpartner": pl.Boolean, + "u_aktiv": pl.Boolean, +} + + +def get_ext_kontaktliste( + db_path: Path | None, +) -> pl.DataFrame: + if db_path is None: + ENV_PTH = os.environ.get("DOPT_DB_KONTAKTLISTE", None) + if ENV_PTH is None: + raise ValueError("No database path provided or found as ENV var.") + db_path = Path(ENV_PTH) + + if not db_path.exists(): + raise FileNotFoundError(f"Database not found under >{db_path}<") + + engine = sql.create_engine(f"sqlite:///{db_path}") + stmt = sql.select(ext_kl_unternehmen) + return pl.read_database(stmt, engine, schema_overrides=ext_kl_unternehmen_schema) + + +df_kontaktliste = get_ext_kontaktliste(None) + +ext_crm_master: sql.Table = Table( + "Master", + md_crm, + Column("ma_id", sql.Integer, nullable=False, unique=True), + Column("wce_id", sql.ForeignKey("Nutzer.wce_id")), + Column("ma_unternehmensname", sql.Text, nullable=True), + Column("ma_branche", sql.Text, nullable=True), + Column("ma_strasse", sql.Text, nullable=True), + Column("ma_hausnummer", sql.Text, nullable=True), + Column("ma_plz", sql.Text, nullable=True), + Column("ma_ort", sql.Text, nullable=True), + Column("ma_plz_postfach", sql.Text, nullable=True), + Column("ma_postfach", sql.Text, nullable=True), + Column("ma_website", sql.Text, nullable=True), + Column("ma_mail", sql.Text, nullable=True), + Column("ma_telefonnummer", sql.Text, nullable=True), + Column("ma_faxnummer", sql.Text, nullable=True), + Column("ma_ersteintrag_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_datum", SafeDateTime, nullable=True), + Column("ma_aktualisierung_nutzer", sql.Text, nullable=True), + Column("ma_sollprozess", sql.Text, nullable=True), + Column("ma_auslaendische_mitarbeiter", sql.Text, nullable=True), + Column("ma_quelle_information", sql.Text, nullable=True), + Column("ma_bemerkung", sql.Text, nullable=True), + Column("ma_kontakt", sql.Boolean, nullable=True), + Column("ma_schlagworte", sql.Text, nullable=True), + Column("ma_archiviert", sql.Boolean, nullable=True, default=False), +) + + +ext_crm_master_schema: t.PolarsSchema = { + "ma_id": pl.UInt64, + "wce_id": pl.UInt64, + "ma_unternehmensname": pl.String, + "ma_branche": pl.String, + "ma_strasse": pl.String, + "ma_hausnummer": pl.String, + "ma_plz": pl.String, + "ma_ort": pl.String, + "ma_plz_postfach": pl.String, + "ma_postfach": pl.String, + "ma_website": pl.String, + "ma_mail": pl.String, + "ma_telefonnummer": pl.String, + "ma_faxnummer": pl.String, + "ma_ersteintrag_datum": pl.Datetime, + "ma_aktualisierung_datum": pl.Datetime, + "ma_aktualisierung_nutzer": pl.String, + "ma_sollprozess": pl.String, + "ma_auslaendische_mitarbeiter": pl.String, + "ma_quelle_information": pl.String, + "ma_bemerkung": pl.String, + "ma_kontakt": pl.Boolean, + "ma_schlagworte": pl.String, + "ma_archiviert": pl.Boolean, +} + + +def get_ext_crm_master( + db_path: Path | None, +) -> pl.DataFrame: + if db_path is None: + ENV_PTH = os.environ.get("DOPT_DB_CRM", None) + if ENV_PTH is None: + raise ValueError("No database path provided or found as ENV var.") + db_path = Path(ENV_PTH) + + if not db_path.exists(): + raise FileNotFoundError(f"Database not found under >{db_path}<") + + engine = sql.create_engine(f"sqlite:///{db_path}") + stmt = sql.select(ext_crm_master) + return pl.read_database(stmt, engine, schema_overrides=ext_crm_master_schema) + + +df_crm_master = get_ext_crm_master(None) + +ext_crm_nutzer: sql.Table = Table( + "Nutzer", + md_crm, + Column("wce_id", sql.Integer, nullable=False, unique=True), + Column("wce_name", sql.Text, nullable=True), + Column("wce_vorname", sql.Text, nullable=True), + Column("wce_kuerzel", sql.Text, nullable=True), + Column("wce_passwort", sql.Text, nullable=True), + Column("wce_angelegt_am", sql.DateTime, nullable=True), + Column("wce_rolle", sql.Text, nullable=True), + Column("wce_angelegt_von", sql.Text, nullable=True), + Column("wce_aktiv", sql.Boolean, nullable=True), + Column("wce_letzter_login", sql.DateTime, nullable=True), +) + +ext_crm_nutzer_schema: t.PolarsSchema = { + "wce_id": pl.UInt64, + "wce_name": pl.String, + "wce_vorname": pl.String, + "wce_kuerzel": pl.String, + "wce_passwort": pl.String, + "wce_angelegt_am": pl.Datetime, + "wce_rolle": pl.String, + "wce_angelegt_von": pl.String, + "wce_aktiv": pl.Boolean, + "wce_letzter_login": pl.Datetime, +} diff --git a/src/wce_crm/env.py b/src/wce_crm/env.py new file mode 100644 index 0000000..750207d --- /dev/null +++ b/src/wce_crm/env.py @@ -0,0 +1,15 @@ +import os +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parents[2] +DB_PATH = PROJECT_ROOT / "data/db" + +DB_KONTAKTLISTE = DB_PATH / "wce_kontaktliste.db" +assert DB_KONTAKTLISTE.exists() +DB_CRM = DB_PATH / "wce_crm.db" +assert DB_CRM.exists() + + +def setup(): + os.environ["DOPT_DB_KONTAKTLISTE"] = str(DB_KONTAKTLISTE) + os.environ["DOPT_DB_CRM"] = str(DB_CRM) diff --git a/src/wce_crm/env_vars.txt b/src/wce_crm/env_vars.txt new file mode 100644 index 0000000..b96df95 --- /dev/null +++ b/src/wce_crm/env_vars.txt @@ -0,0 +1,2 @@ +DOPT_DB_KONTAKTLISTE: Pfad zur Datenbank der Kontaktliste, falls nicht direkt übergeben (Prototypenphase) +DOPT_DB_CRM: Pfad zur CRM-Datenbank, falls nicht direkt übergeben (Prototypenphase) \ No newline at end of file diff --git a/src/wce_crm/types.py b/src/wce_crm/types.py new file mode 100644 index 0000000..ff8d5fa --- /dev/null +++ b/src/wce_crm/types.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeAlias + +if TYPE_CHECKING: + import polars as pl + + +PolarsSchema: TypeAlias = dict[str, type["pl.DataType"]]