issue-facade integration

This commit is contained in:
2026-05-14 11:30:30 +02:00
parent c2b7cc64d6
commit a907ed6f74
16 changed files with 642 additions and 95 deletions

1
.gitignore vendored
View File

@@ -181,3 +181,4 @@ media/
# PyPI configuration file
.pypirc
.issue-facade/

View File

@@ -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]

181
uv.lock generated
View File

@@ -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" },
]

View File

@@ -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)

View File

@@ -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)'}),
}

View File

@@ -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'))

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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}'

View File

@@ -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

View File

@@ -18,4 +18,6 @@ urlpatterns = [
path('<int:pk>/issue/', views.external_issue_bearbeiten, name='external_issue'),
path('<int:pk>/issue/panel/', views.external_issue_panel, name='external_issue_panel'),
path('<int:pk>/issue/loeschen/', views.external_issue_loeschen, name='external_issue_loeschen'),
path('<int:pk>/issue/push/', views.external_issue_push_remote, name='external_issue_push'),
path('<int:pk>/issue/sync/', views.external_issue_sync, name='external_issue_sync'),
]

View File

@@ -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)

View File

@@ -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',
# }

View File

@@ -1,25 +1,70 @@
{% with ei=aufgabe.external_issue %}
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">{{ ei.get_system_display }}</span>
{% if ei.issue_key %}<span class="text-slate-700 font-mono text-xs">{{ ei.issue_key }}</span>{% endif %}
<span class="text-xs text-slate-400 ml-auto">{{ ei.get_sync_status_display }}</span>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2 flex-wrap">
<!-- Backend-Badge -->
{% if ei.issue_facade_backend == 'local' %}
<span class="text-xs bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 font-medium">LOKAL</span>
{% else %}
<span class="text-xs bg-blue-100 text-blue-700 rounded px-1.5 py-0.5 font-medium">GITEA</span>
{% endif %}
<!-- Issue-Key -->
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs font-mono text-blue-600 hover:underline">{{ ei.issue_key }}</a>
{% else %}
<span class="text-xs font-mono text-slate-600">{{ ei.issue_key }}</span>
{% endif %}
<!-- Status-Badge -->
<span class="text-xs rounded px-1.5 py-0.5
{% if ei.sync_status == 'closed' %}bg-green-100 text-green-700
{% elif ei.sync_status == 'in_progress' %}bg-amber-100 text-amber-700
{% elif ei.sync_status == 'blocked' %}bg-red-100 text-red-700
{% elif ei.sync_status == 'error' %}bg-red-200 text-red-800
{% else %}bg-slate-100 text-slate-600{% endif %}">
{{ ei.get_sync_status_display }}
</span>
<!-- Letzter Sync -->
{% if ei.letzter_sync %}
<span class="text-xs text-slate-400 ml-auto">Sync: {{ ei.letzter_sync|date:"d.m.Y H:i" }}</span>
{% endif %}
</div>
{% if ei.issue_url %}
<a href="{{ ei.issue_url }}" target="_blank" rel="noopener"
class="text-xs text-blue-600 hover:underline break-all">{{ ei.issue_url }}</a>
{% endif %}
{% if ei.notizen %}
<p class="text-xs text-slate-500 whitespace-pre-wrap">{{ ei.notizen }}</p>
{% endif %}
<div class="flex gap-2 mt-2">
{% if push_error %}
<p class="text-xs text-red-600">{{ push_error }}</p>
{% endif %}
<div class="flex gap-2 flex-wrap mt-1">
<!-- Bearbeiten -->
<button class="btn-ghost text-xs"
hx-get="{% url 'ausschreibungen:aufgaben:external_issue' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML">Bearbeiten</button>
hx-target="#external-issue-panel" hx-swap="innerHTML">Bearbeiten</button>
<!-- Push to Gitea (nur wenn lokal und Gitea konfiguriert) -->
{% if ei.issue_facade_backend == 'local' and gitea_configured %}
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_push' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-blue-600">↑ Nach Gitea</button>
</form>
{% endif %}
<!-- Status syncen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_sync' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel" hx-swap="innerHTML">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs">⟳ Status syncen</button>
</form>
<!-- Entfernen -->
<form hx-post="{% url 'ausschreibungen:aufgaben:external_issue_loeschen' ausschreibung.pk aufgabe.pk %}"
hx-target="#external-issue-panel"
hx-swap="innerHTML"
hx-target="#external-issue-panel" hx-swap="innerHTML"
hx-confirm="Externe Verknüpfung entfernen?">
{% csrf_token %}
<button type="submit" class="btn-ghost text-xs text-red-500">Entfernen</button>

View File

@@ -3,18 +3,7 @@
hx-swap="innerHTML"
class="space-y-2">
{% csrf_token %}
<div>
<label class="form-label">System</label>
{{ form.system }}
</div>
<div>
<label class="form-label">Issue-URL</label>
{{ form.issue_url }}
</div>
<div>
<label class="form-label">Issue-Key</label>
{{ form.issue_key }}
</div>
<p class="text-xs text-slate-500">Issue wird automatisch im lokalen Backend angelegt.</p>
<div>
<label class="form-label">Notizen</label>
{{ form.notizen }}

View File

@@ -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('<int:pk>/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.