From a907ed6f7434b3e25e7fcecbfa634134aa2b0819 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 14 May 2026 11:30:30 +0200 Subject: [PATCH] issue-facade integration --- .gitignore | 1 + pyproject.toml | 1 + uv.lock | 181 ++++++++++++++++++ vergabe_teilnahme/apps/aufgaben/admin.py | 7 +- vergabe_teilnahme/apps/aufgaben/forms.py | 10 +- .../apps/aufgaben/issue_backends.py | 43 +++++ .../apps/aufgaben/issue_facade.py | 127 +++++++++--- .../0006_external_issue_redesign.py | 37 ++++ vergabe_teilnahme/apps/aufgaben/models.py | 48 +++-- vergabe_teilnahme/apps/aufgaben/tests.py | 104 +++++++++- vergabe_teilnahme/apps/aufgaben/urls.py | 2 + vergabe_teilnahme/apps/aufgaben/views.py | 59 ++++++ vergabe_teilnahme/settings/base.py | 13 ++ .../partials/external_issue_card.html | 73 +++++-- .../partials/external_issue_form.html | 13 +- workplans/WP-0016-issue-facade-integration.md | 18 +- 16 files changed, 642 insertions(+), 95 deletions(-) create mode 100644 vergabe_teilnahme/apps/aufgaben/issue_backends.py create mode 100644 vergabe_teilnahme/apps/aufgaben/migrations/0006_external_issue_redesign.py diff --git a/.gitignore b/.gitignore index 8f25469..fc2f985 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ media/ # PyPI configuration file .pypirc +.issue-facade/ diff --git a/pyproject.toml b/pyproject.toml index 92e3db2..3c2ecb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "whitenoise>=6.7", "python-decouple>=3.8", "dj-database-url>=2.1", + "universal-issue-tracker @ file:///home/worsch/issue-facade", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index ec95ad4..32dd833 100644 --- a/uv.lock +++ b/uv.lock @@ -52,6 +52,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165 }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627 }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008 }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303 }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282 }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986 }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711 }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998 }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056 }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537 }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176 }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723 }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085 }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819 }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915 }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234 }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042 }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706 }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727 }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882 }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860 }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564 }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276 }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238 }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189 }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352 }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024 }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869 }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541 }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634 }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384 }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133 }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257 }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851 }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393 }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609 }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014 }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979 }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238 }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110 }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824 }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103 }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194 }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827 }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168 }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -235,6 +329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447 }, ] +[[package]] +name = "idna" +version = "3.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340 }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -493,6 +596,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123 }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + [[package]] name = "python-decouple" version = "3.8" @@ -502,6 +617,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947 }, ] +[[package]] +name = "requests" +version = "2.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/36/7180e7f077c38108945dbbdf60fe04db681c3feb6e96419f8c6dc8723741/requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb", size = 142783 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/5a/4a949d170476de3c04ac036b5466422fbcbf348a917d8042eedf2cac7d1b/requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0", size = 73085 }, +] + [[package]] name = "ruff" version = "0.15.12" @@ -527,6 +657,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821 }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -563,6 +702,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, ] +[[package]] +name = "universal-issue-tracker" +version = "0.1.0" +source = { directory = "../issue-facade" } +dependencies = [ + { name = "click" }, + { name = "python-dateutil" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = ">=22.0" }, + { name = "click", specifier = ">=8.0.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=4.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "jira", marker = "extra == 'jira'", specifier = ">=3.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=0.900" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "pygithub", marker = "extra == 'github'", specifier = ">=1.55" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.0" }, + { name = "python-dateutil", specifier = ">=2.8.0" }, + { name = "requests", specifier = ">=2.25.0" }, + { name = "requests", marker = "extra == 'gitea'", specifier = ">=2.25.0" }, + { name = "sphinx", marker = "extra == 'docs'", specifier = ">=4.0" }, + { name = "sphinx-click", marker = "extra == 'docs'", specifier = ">=3.0" }, + { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.0" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + [[package]] name = "vergabe-teilnahme" version = "0.1.0" @@ -573,6 +752,7 @@ dependencies = [ { name = "django-storages" }, { name = "psycopg", extra = ["binary"] }, { name = "python-decouple" }, + { name = "universal-issue-tracker" }, { name = "whitenoise" }, ] @@ -593,6 +773,7 @@ requires-dist = [ { name = "django-storages", specifier = ">=1.14" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "python-decouple", specifier = ">=3.8" }, + { name = "universal-issue-tracker", directory = "../issue-facade" }, { name = "whitenoise", specifier = ">=6.7" }, ] diff --git a/vergabe_teilnahme/apps/aufgaben/admin.py b/vergabe_teilnahme/apps/aufgaben/admin.py index 1df836c..2965251 100644 --- a/vergabe_teilnahme/apps/aufgaben/admin.py +++ b/vergabe_teilnahme/apps/aufgaben/admin.py @@ -24,8 +24,11 @@ class AufgabenVerknuepfungAdmin(admin.ModelAdmin): @admin.register(ExternalIssue) class ExternalIssueAdmin(admin.ModelAdmin): - list_display = ['aufgabe', 'system', 'issue_key', 'sync_status', 'letzter_sync'] - list_filter = ['system', 'sync_status'] + list_display = ['aufgabe', 'issue_facade_backend', 'issue_key', + 'sync_status', 'letzter_sync'] + list_filter = ['issue_facade_backend', 'sync_status'] + readonly_fields = ['issue_facade_id', 'issue_key', 'issue_url', + 'sync_status', 'letzter_sync', 'erstellt_am'] @admin.register(Bieterfrage) diff --git a/vergabe_teilnahme/apps/aufgaben/forms.py b/vergabe_teilnahme/apps/aufgaben/forms.py index 369bff8..a78f8a3 100644 --- a/vergabe_teilnahme/apps/aufgaben/forms.py +++ b/vergabe_teilnahme/apps/aufgaben/forms.py @@ -83,11 +83,9 @@ class AufgabenVerknuepfungForm(forms.Form): class ExternalIssueForm(forms.ModelForm): class Meta: - model = ExternalIssue - fields = ['system', 'issue_url', 'issue_key', 'notizen'] + model = ExternalIssue + fields = ['notizen'] widgets = { - 'system': forms.Select(attrs={'class': 'form-input'}), - 'issue_url': forms.URLInput(attrs={'class': 'form-input', 'placeholder': 'https://...'}), - 'issue_key': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'z.B. GH-42'}), - 'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2}), + 'notizen': forms.Textarea(attrs={'class': 'form-input', 'rows': 2, + 'placeholder': 'Notizen (optional)'}), } diff --git a/vergabe_teilnahme/apps/aufgaben/issue_backends.py b/vergabe_teilnahme/apps/aufgaben/issue_backends.py new file mode 100644 index 0000000..6da339b --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/issue_backends.py @@ -0,0 +1,43 @@ +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def local_backend(): + from django.conf import settings + from issue_tracker.backends.local import LocalSQLiteBackend + + db_path = str(getattr(settings, 'ISSUE_FACADE_LOCAL_DB', '.issue-facade/issues.db')) + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + b = LocalSQLiteBackend() + b.connect({'db_path': db_path}) + try: + yield b + finally: + b.disconnect() + + +@contextmanager +def remote_backend(): + """Yields GiteaBackend wenn konfiguriert, sonst None.""" + from django.conf import settings + from issue_tracker.backends.gitea import GiteaBackend + + cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None) + if not cfg: + yield None + return + + b = GiteaBackend() + b.connect(cfg) + try: + yield b + finally: + b.disconnect() + + +def gitea_configured() -> bool: + from django.conf import settings + cfg = getattr(settings, 'ISSUE_FACADE_GITEA', None) + return bool(cfg and cfg.get('token')) diff --git a/vergabe_teilnahme/apps/aufgaben/issue_facade.py b/vergabe_teilnahme/apps/aufgaben/issue_facade.py index bd42b78..f08aa95 100644 --- a/vergabe_teilnahme/apps/aufgaben/issue_facade.py +++ b/vergabe_teilnahme/apps/aufgaben/issue_facade.py @@ -1,28 +1,111 @@ -from abc import ABC, abstractmethod +from datetime import datetime, timezone + +from issue_tracker.core.models import Issue, IssueState, Label, Priority + +from .issue_backends import gitea_configured, local_backend, remote_backend -class IssueAdapter(ABC): +def aufgabe_zu_issue(aufgabe) -> Issue: + """Konvertiert eine Aufgabe in ein issue-facade Issue-Objekt.""" + prioritaet_map = {1: Priority.HIGH, 2: Priority.MEDIUM, 3: Priority.LOW} + labels = [ + Label(name='task'), + Label(name=f'priority:{prioritaet_map.get(aufgabe.prioritaet, Priority.MEDIUM).value}'), + ] + if aufgabe.typ: + labels.append(Label(name=aufgabe.typ)) + + now = datetime.now(timezone.utc) + return Issue( + id='', + number=0, + title=aufgabe.titel, + description=aufgabe.beschreibung or '', + state=IssueState.OPEN, + created_at=now, + updated_at=now, + labels=labels, + ) + + +def lokales_issue_erstellen(aufgabe) -> dict: """ - Adapter-Basisklasse. Jeder externe Issue-Tracker implementiert diese - Schnittstelle. Registrierung via ISSUE_ADAPTERS-Dict in settings. + Legt ein Issue im lokalen SQLite-Backend an. + Gibt {'issue_facade_id', 'issue_key', 'sync_status'} zurück. """ - - @abstractmethod - def create_issue(self, aufgabe) -> dict: - """Legt ein Issue im externen System an. Gibt {'url', 'key'} zurück.""" - - @abstractmethod - def fetch_status(self, external_issue) -> str: - """Liest den aktuellen Status aus dem externen System.""" - - @abstractmethod - def close_issue(self, external_issue) -> None: - """Schließt das Issue im externen System.""" + issue = aufgabe_zu_issue(aufgabe) + with local_backend() as b: + created = b.create_issue(issue) + return { + 'issue_facade_id': str(created.id), + 'issue_key': f'#{created.number}', + 'sync_status': created.state.value, + } -def get_adapter(system: str) -> 'IssueAdapter | None': - """Gibt den registrierten Adapter für `system` zurück, oder None.""" - from django.conf import settings - adapters = getattr(settings, 'ISSUE_ADAPTERS', {}) - cls = adapters.get(system) - return cls() if cls else None +def an_gitea_delegieren(aufgabe, external_issue) -> dict: + """ + Schiebt ein lokales Issue nach Gitea. + Gibt {'issue_facade_backend', 'issue_facade_id', 'issue_url', 'issue_key', 'sync_status'} zurück. + Wirft ValueError wenn Gitea nicht konfiguriert ist. + """ + with remote_backend() as b: + if b is None: + raise ValueError('Gitea nicht konfiguriert (ISSUE_FACADE_GITEA fehlt in settings)') + issue = aufgabe_zu_issue(aufgabe) + created = b.create_issue(issue) + url = '' + if created.sync_metadata: + url = created.sync_metadata.get('url', '') + return { + 'issue_facade_backend': 'gitea', + 'issue_facade_id': str(created.number), + 'issue_url': url, + 'issue_key': f'#{created.number}', + 'sync_status': created.state.value, + } + + +def status_synchronisieren(external_issue) -> str: + """ + Liest den aktuellen Status aus dem konfigurierten Backend. + Gibt den neuen sync_status-String zurück. + """ + from django.utils import timezone as dj_tz + + backend_ctx = ( + remote_backend() if external_issue.issue_facade_backend == 'gitea' + else local_backend() + ) + with backend_ctx as b: + if b is None: + raise ValueError('Backend nicht verfügbar') + issue = b.get_issue(external_issue.issue_facade_id) + + neuer_status = issue.state.value if issue else 'error' + external_issue.sync_status = neuer_status + external_issue.letzter_sync = dj_tz.now() + external_issue.save(update_fields=['sync_status', 'letzter_sync']) + return neuer_status + + +def issue_schliessen(external_issue) -> None: + """Setzt das Issue im Backend auf CLOSED.""" + backend_ctx = ( + remote_backend() if external_issue.issue_facade_backend == 'gitea' + else local_backend() + ) + with backend_ctx as b: + if b is None: + return + issue = b.get_issue(external_issue.issue_facade_id) + if issue: + issue.state = IssueState.CLOSED + b.update_issue(issue) + external_issue.sync_status = 'closed' + external_issue.save(update_fields=['sync_status']) + + +def get_adapter(system: str): + """Rückwärtskompatible Stub — gibt None zurück (kein manuelles Adapter-Dict mehr).""" + return None diff --git a/vergabe_teilnahme/apps/aufgaben/migrations/0006_external_issue_redesign.py b/vergabe_teilnahme/apps/aufgaben/migrations/0006_external_issue_redesign.py new file mode 100644 index 0000000..a37227c --- /dev/null +++ b/vergabe_teilnahme/apps/aufgaben/migrations/0006_external_issue_redesign.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.5 on 2026-05-14 09:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('aufgaben', '0005_verknuepfung_externalissue'), + ] + + operations = [ + migrations.RemoveField( + model_name='externalissue', + name='system', + ), + migrations.AddField( + model_name='externalissue', + name='issue_facade_backend', + field=models.CharField(choices=[('local', 'Lokal (SQLite)'), ('gitea', 'Gitea')], default='local', max_length=20), + ), + migrations.AddField( + model_name='externalissue', + name='issue_facade_id', + field=models.CharField(blank=True, help_text='UUID (lokal) oder Issue-Number (Gitea)', max_length=200), + ), + migrations.AlterField( + model_name='externalissue', + name='issue_key', + field=models.CharField(blank=True, help_text='z.B. "#42" oder "PROJ-1234"', max_length=100), + ), + migrations.AlterField( + model_name='externalissue', + name='sync_status', + field=models.CharField(choices=[('open', 'Offen'), ('in_progress', 'In Bearbeitung'), ('blocked', 'Blockiert'), ('closed', 'Geschlossen'), ('error', 'Sync-Fehler')], default='open', max_length=20), + ), + ] diff --git a/vergabe_teilnahme/apps/aufgaben/models.py b/vergabe_teilnahme/apps/aufgaben/models.py index a56d254..fa7e3c3 100644 --- a/vergabe_teilnahme/apps/aufgaben/models.py +++ b/vergabe_teilnahme/apps/aufgaben/models.py @@ -154,36 +154,42 @@ class AufgabenVerknuepfung(models.Model): class ExternalIssue(models.Model): - SYSTEM_CHOICES = [ - ('github', 'GitHub Issues'), - ('jira', 'Jira'), - ('linear', 'Linear'), - ('azure', 'Azure DevOps'), - ('sonstiges', 'Sonstiges'), + BACKEND_CHOICES = [ + ('local', 'Lokal (SQLite)'), + ('gitea', 'Gitea'), ] SYNC_STATUS_CHOICES = [ - ('manuell', 'Manuell'), - ('offen', 'Offen (extern)'), - ('geschlossen', 'Geschlossen (extern)'), - ('fehler', 'Sync-Fehler'), + ('open', 'Offen'), + ('in_progress', 'In Bearbeitung'), + ('blocked', 'Blockiert'), + ('closed', 'Geschlossen'), + ('error', 'Sync-Fehler'), ] aufgabe = models.OneToOneField( Aufgabe, on_delete=models.CASCADE, related_name='external_issue' ) - system = models.CharField(max_length=20, choices=SYSTEM_CHOICES) - issue_url = models.URLField(blank=True) - issue_key = models.CharField(max_length=100, blank=True, - help_text='z.B. "GH-42" oder "PROJ-1234"') - sync_status = models.CharField(max_length=20, choices=SYNC_STATUS_CHOICES, - default='manuell') + issue_facade_backend = models.CharField( + max_length=20, choices=BACKEND_CHOICES, default='local' + ) + issue_facade_id = models.CharField( + max_length=200, blank=True, + help_text='UUID (lokal) oder Issue-Number (Gitea)' + ) + issue_url = models.URLField(blank=True) + issue_key = models.CharField(max_length=100, blank=True, + help_text='z.B. "#42" oder "PROJ-1234"') + sync_status = models.CharField( + max_length=20, choices=SYNC_STATUS_CHOICES, default='open' + ) letzter_sync = models.DateTimeField(null=True, blank=True) - notizen = models.TextField(blank=True) - erstellt_am = models.DateTimeField(auto_now_add=True) + notizen = models.TextField(blank=True) + erstellt_am = models.DateTimeField(auto_now_add=True) class Meta: - verbose_name = 'External Issue' - verbose_name_plural = 'External Issues' + verbose_name = 'External Issue' + verbose_name_plural = 'External Issues' def __str__(self): - return f'{self.get_system_display()} {self.issue_key or self.issue_url}' + ident = self.issue_key or (self.issue_facade_id[:8] if self.issue_facade_id else '?') + return f'{self.get_issue_facade_backend_display()} #{ident}' diff --git a/vergabe_teilnahme/apps/aufgaben/tests.py b/vergabe_teilnahme/apps/aufgaben/tests.py index aae7c96..0a789e0 100644 --- a/vergabe_teilnahme/apps/aufgaben/tests.py +++ b/vergabe_teilnahme/apps/aufgaben/tests.py @@ -168,26 +168,28 @@ def test_aufgaben_verknuepfung_loeschen(client): # ─── ExternalIssue ──────────────────────────────────────────────────────────── +@pytest.fixture +def tmp_issue_db(tmp_path, settings): + settings.ISSUE_FACADE_LOCAL_DB = tmp_path / 'test_issues.db' + settings.ISSUE_FACADE_GITEA = None + return settings.ISSUE_FACADE_LOCAL_DB + + @pytest.mark.django_db -def test_external_issue_erstellen(client): +def test_external_issue_erstellen(client, tmp_issue_db): aufgabe = AufgabeFactory() url = reverse('ausschreibungen:aufgaben:external_issue', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) - response = client.post(url, { - 'system': 'github', - 'issue_url': 'https://github.com/org/repo/issues/42', - 'issue_key': 'GH-42', - 'notizen': '', - }, HTTP_HX_REQUEST='true') + response = client.post(url, {'notizen': ''}, HTTP_HX_REQUEST='true') assert response.status_code == 200 - assert ExternalIssue.objects.filter(aufgabe=aufgabe, system='github').exists() + assert ExternalIssue.objects.filter(aufgabe=aufgabe, issue_facade_backend='local').exists() @pytest.mark.django_db def test_external_issue_loeschen(client): aufgabe = AufgabeFactory() ei = ExternalIssue.objects.create( - aufgabe=aufgabe, system='jira', issue_key='PROJ-1' + aufgabe=aufgabe, issue_facade_backend='local', issue_key='#1' ) url = reverse('ausschreibungen:aufgaben:external_issue_loeschen', kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) @@ -201,3 +203,87 @@ def test_issue_adapter_interface(): from .issue_facade import get_adapter assert get_adapter('github') is None assert get_adapter('nichtexistent') is None + + +# ─── Issue-Facade Integration ───────────────────────────────────────────────── + + +@pytest.mark.django_db +def test_aufgabe_zu_issue_mapping(tmp_issue_db): + from .issue_facade import aufgabe_zu_issue + aufgabe = AufgabeFactory(titel='Test-Aufgabe', beschreibung='Beschreibung', prioritaet=1) + issue = aufgabe_zu_issue(aufgabe) + assert issue.title == 'Test-Aufgabe' + assert issue.description == 'Beschreibung' + label_names = [l.name for l in issue.labels] + assert 'task' in label_names + assert 'priority:high' in label_names + + +@pytest.mark.django_db +def test_lokales_issue_erstellen(tmp_issue_db): + from .issue_facade import lokales_issue_erstellen + aufgabe = AufgabeFactory() + daten = lokales_issue_erstellen(aufgabe) + assert daten['issue_facade_id'] + assert daten['issue_key'].startswith('#') + assert daten['sync_status'] == 'open' + + +@pytest.mark.django_db +def test_status_synchronisieren(tmp_issue_db): + from .issue_facade import lokales_issue_erstellen, status_synchronisieren + aufgabe = AufgabeFactory() + daten = lokales_issue_erstellen(aufgabe) + ei = ExternalIssue.objects.create( + aufgabe=aufgabe, + issue_facade_backend='local', + issue_facade_id=daten['issue_facade_id'], + issue_key=daten['issue_key'], + sync_status=daten['sync_status'], + ) + neuer_status = status_synchronisieren(ei) + assert neuer_status == 'open' + ei.refresh_from_db() + assert ei.sync_status == 'open' + assert ei.letzter_sync is not None + + +@pytest.mark.django_db +def test_external_issue_bearbeiten_view_erstellt_issue(client, tmp_issue_db): + aufgabe = AufgabeFactory() + url = reverse('ausschreibungen:aufgaben:external_issue', + kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) + response = client.post(url, {'notizen': 'Test-Notiz'}, HTTP_HX_REQUEST='true') + assert response.status_code == 200 + ei = ExternalIssue.objects.get(aufgabe=aufgabe) + assert ei.issue_facade_backend == 'local' + assert ei.issue_facade_id + assert ei.notizen == 'Test-Notiz' + + +@pytest.mark.django_db +def test_external_issue_sync_view(client, tmp_issue_db): + from .issue_facade import lokales_issue_erstellen + aufgabe = AufgabeFactory() + daten = lokales_issue_erstellen(aufgabe) + ei = ExternalIssue.objects.create( + aufgabe=aufgabe, + issue_facade_backend='local', + issue_facade_id=daten['issue_facade_id'], + issue_key=daten['issue_key'], + sync_status=daten['sync_status'], + ) + url = reverse('ausschreibungen:aufgaben:external_issue_sync', + kwargs={'ausschreibung_id': aufgabe.ausschreibung_id, 'pk': aufgabe.pk}) + response = client.post(url, {}, HTTP_HX_REQUEST='true') + assert response.status_code == 200 + ei.refresh_from_db() + assert ei.sync_status == 'open' + + +@pytest.mark.django_db +def test_get_adapter_stub(): + from .issue_facade import get_adapter + assert get_adapter('github') is None + assert get_adapter('gitea') is None diff --git a/vergabe_teilnahme/apps/aufgaben/urls.py b/vergabe_teilnahme/apps/aufgaben/urls.py index 6b46ef4..19134a2 100644 --- a/vergabe_teilnahme/apps/aufgaben/urls.py +++ b/vergabe_teilnahme/apps/aufgaben/urls.py @@ -18,4 +18,6 @@ urlpatterns = [ path('/issue/', views.external_issue_bearbeiten, name='external_issue'), path('/issue/panel/', views.external_issue_panel, name='external_issue_panel'), path('/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'), + path('/issue/push/', views.external_issue_push_remote, name='external_issue_push'), + path('/issue/sync/', views.external_issue_sync, name='external_issue_sync'), ] diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py index a029764..50804e9 100644 --- a/vergabe_teilnahme/apps/aufgaben/views.py +++ b/vergabe_teilnahme/apps/aufgaben/views.py @@ -7,6 +7,7 @@ from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung from django.http import HttpResponse +from .issue_backends import gitea_configured as _gitea_configured from .models import Aufgabe, AufgabenVerknuepfung, Bieterfrage, ExternalIssue AKTIVE_STATUS = [ @@ -413,6 +414,7 @@ def verknuepfung_loeschen(request, ausschreibung_id, pk, vk_pk): def external_issue_bearbeiten(request, ausschreibung_id, pk): from .forms import ExternalIssueForm + from .issue_facade import lokales_issue_erstellen ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) @@ -423,11 +425,18 @@ def external_issue_bearbeiten(request, ausschreibung_id, pk): if form.is_valid(): ei = form.save(commit=False) ei.aufgabe = aufgabe + if not issue: + daten = lokales_issue_erstellen(aufgabe) + ei.issue_facade_backend = 'local' + ei.issue_facade_id = daten['issue_facade_id'] + ei.issue_key = daten['issue_key'] + ei.sync_status = daten['sync_status'] ei.save() if _is_htmx(request): return render(request, 'aufgaben/partials/external_issue_panel.html', { 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, + 'gitea_configured': _gitea_configured(), }) return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) @@ -443,12 +452,61 @@ def external_issue_bearbeiten(request, ausschreibung_id, pk): return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) +def external_issue_push_remote(request, ausschreibung_id, pk): + """Schiebt ein lokales Issue nach Gitea.""" + from .issue_facade import an_gitea_delegieren + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe) + push_error = None + if request.method == 'POST': + try: + daten = an_gitea_delegieren(aufgabe, ei) + for k, v in daten.items(): + setattr(ei, k, v) + ei.save() + except ValueError as exc: + push_error = str(exc) + if _is_htmx(request): + return render(request, 'aufgaben/partials/external_issue_panel.html', { + 'aufgabe': aufgabe, + 'ausschreibung': ausschreibung, + 'gitea_configured': _gitea_configured(), + 'push_error': push_error, + }) + return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) + + +def external_issue_sync(request, ausschreibung_id, pk): + """Aktualisiert sync_status aus dem Backend.""" + from .issue_facade import status_synchronisieren + + ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) + aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) + ei = get_object_or_404(ExternalIssue, aufgabe=aufgabe) + if request.method == 'POST': + try: + status_synchronisieren(ei) + except Exception: + ei.sync_status = 'error' + ei.save(update_fields=['sync_status']) + if _is_htmx(request): + return render(request, 'aufgaben/partials/external_issue_panel.html', { + 'aufgabe': aufgabe, + 'ausschreibung': ausschreibung, + 'gitea_configured': _gitea_configured(), + }) + return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) + + def external_issue_panel(request, ausschreibung_id, pk): ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id) aufgabe = get_object_or_404(Aufgabe, pk=pk, ausschreibung=ausschreibung) return render(request, 'aufgaben/partials/external_issue_panel.html', { 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, + 'gitea_configured': _gitea_configured(), }) @@ -461,6 +519,7 @@ def external_issue_loeschen(request, ausschreibung_id, pk): return render(request, 'aufgaben/partials/external_issue_panel.html', { 'aufgabe': aufgabe, 'ausschreibung': ausschreibung, + 'gitea_configured': _gitea_configured(), }) return redirect('ausschreibungen:aufgaben:detail', ausschreibung_id=ausschreibung_id, pk=pk) diff --git a/vergabe_teilnahme/settings/base.py b/vergabe_teilnahme/settings/base.py index b45fcef..f9fc320 100644 --- a/vergabe_teilnahme/settings/base.py +++ b/vergabe_teilnahme/settings/base.py @@ -90,3 +90,16 @@ MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' MAX_UPLOAD_SIZE = config('MAX_UPLOAD_SIZE', default=52428800, cast=int) + +# Issue Facade — lokales SQLite-Backend (immer aktiv) +ISSUE_FACADE_LOCAL_DB = BASE_DIR / '.issue-facade' / 'issues.db' + +# Issue Facade — Gitea-Remote (optional, None = deaktiviert) +ISSUE_FACADE_GITEA: dict | None = None +# Beispiel: +# ISSUE_FACADE_GITEA = { +# 'base_url': 'https://gitea.example.com', +# 'token': env('GITEA_TOKEN', default=''), +# 'owner': 'org', +# 'repo': 'vergabe', +# } diff --git a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html index 32eff77..2bbc630 100644 --- a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html +++ b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_card.html @@ -1,25 +1,70 @@ {% with ei=aufgabe.external_issue %} -
-
- {{ ei.get_system_display }} - {% if ei.issue_key %}{{ ei.issue_key }}{% endif %} - {{ ei.get_sync_status_display }} +
+
+ + {% if ei.issue_facade_backend == 'local' %} + LOKAL + {% else %} + GITEA + {% endif %} + + + {% if ei.issue_url %} + {{ ei.issue_key }} + {% else %} + {{ ei.issue_key }} + {% endif %} + + + + {{ ei.get_sync_status_display }} + + + + {% if ei.letzter_sync %} + Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }} + {% endif %}
- {% if ei.issue_url %} - {{ ei.issue_url }} - {% endif %} + {% if ei.notizen %}

{{ ei.notizen }}

{% endif %} -
+ + {% if push_error %} +

{{ push_error }}

+ {% endif %} + +
+ + hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten + + + {% if ei.issue_facade_backend == 'local' and gitea_configured %} +
+ {% csrf_token %} + +
+ {% endif %} + + +
+ {% csrf_token %} + +
+ +
{% csrf_token %} diff --git a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html index 0fd7ea3..c0ef82f 100644 --- a/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html +++ b/vergabe_teilnahme/templates/aufgaben/partials/external_issue_form.html @@ -3,18 +3,7 @@ hx-swap="innerHTML" class="space-y-2"> {% csrf_token %} -
- - {{ form.system }} -
-
- - {{ form.issue_url }} -
-
- - {{ form.issue_key }} -
+

Issue wird automatisch im lokalen Backend angelegt.

{{ form.notizen }} diff --git a/workplans/WP-0016-issue-facade-integration.md b/workplans/WP-0016-issue-facade-integration.md index 786702c..84a2c08 100644 --- a/workplans/WP-0016-issue-facade-integration.md +++ b/workplans/WP-0016-issue-facade-integration.md @@ -1,7 +1,7 @@ --- id: WP-0016 title: Issue-Facade Integration — lokale Aufgabenverfolgung + Remote-Delegation -status: todo +status: done phase: 16-of-n created: "2026-05-14" depends_on: WP-0015 @@ -34,7 +34,7 @@ Status wird von dort zurückgelesen. ```task id: WP-0016-T01 title: Package-Installation + Django-Settings -status: todo +status: done **`pyproject.toml`** — Dependency ergänzen: @@ -83,7 +83,7 @@ uv run python -c "from issue_tracker.backends.local import LocalSQLiteBackend; p ```task id: WP-0016-T02 title: Backend-Utility — issue_backends.py -status: todo +status: done Neues Modul `vergabe_teilnahme/apps/aufgaben/issue_backends.py`: @@ -140,7 +140,7 @@ pro Request cheap genug für eine Einzelbenutzer-App). ```task id: WP-0016-T03 title: ExternalIssue-Modell erweitern + Migration -status: todo +status: done In `apps/aufgaben/models.py` das Modell `ExternalIssue` anpassen: @@ -202,7 +202,7 @@ uv run python manage.py migrate ```task id: WP-0016-T04 title: Service-Schicht — issue_facade.py ersetzen -status: todo +status: done `vergabe_teilnahme/apps/aufgaben/issue_facade.py` **komplett ersetzen**: @@ -325,7 +325,7 @@ Der ABC `IssueAdapter` aus WP-0015 entfällt damit vollständig — ```task id: WP-0016-T05 title: ExternalIssueForm anpassen + Views verkabeln -status: todo +status: done **Form** (`forms.py`) — vereinfacht, da `system` entfällt: @@ -439,7 +439,7 @@ path('/issue/sync/', views.external_issue_sync, name='external_issue_syn ```task id: WP-0016-T06 title: Admin + ExternalIssueAdmin aktualisieren -status: todo +status: done `apps/aufgaben/admin.py` — `ExternalIssueAdmin` an neue Felder anpassen: @@ -457,7 +457,7 @@ class ExternalIssueAdmin(admin.ModelAdmin): ```task id: WP-0016-T07 title: UI — ExternalIssue-Panel aktualisieren -status: todo +status: done `templates/aufgaben/partials/external_issue_card.html` überarbeiten: @@ -546,7 +546,7 @@ View als Kontext-Variable übergeben werden. Einfachste Lösung: in ```task id: WP-0016-T08 title: Tests + Smoke-Check -status: todo +status: done Alle 76 bestehenden Tests müssen grün bleiben.