From 38c9474a7e10444a4fbc2333899d007715964112 Mon Sep 17 00:00:00 2001 From: Samuel Goebel Date: Mon, 4 May 2026 15:11:18 +0200 Subject: [PATCH 01/76] Added Algorithm, which returns a sorted list of recipes of an matching Ingridients List --- project/backend/Database.py | 53 +++++++++++++++++++++++++++++++++- project/backend/Recipe.py | 25 ++++++++++------ project/backend/RecipeSucuk.py | 36 +++++++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 project/backend/RecipeSucuk.py diff --git a/project/backend/Database.py b/project/backend/Database.py index 0d469cf..379d999 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -74,6 +74,7 @@ def initDB(): CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, + description nvarchar(MAX), vid INTEGER NOT NULL, FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE ) @@ -203,4 +204,54 @@ def cleanupExpiredTokens() -> None: """Löscht alle abgelaufenen Refresh Tokens.""" with getDB() as con: cur = con.cursor() - cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") \ No newline at end of file + cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") + +def getRecipe(recipeID: int) -> dict | None: + """Gibt eine Liste aller Rezepte zurück.""" + with getDB() as con: + cur = con.cursor() + cur.execute(""" + SELECT name, description + FROM Recipe + WHERE id = ? + """, (recipeID,)) + row = cur.fetchone() + return dict(row) if row else None + +def getAllRecipes()-> list[dict]: + with getDB() as con: + cur = con.cursor() + cur.execute(""" + SELECT id, name, description + FROM Recipe + + """) + rows = cur.fetchall() + return [dict(row) for row in rows] +def getAllIngridientsForRecipe(id: int) -> list[dict]: + """Gibt eine Liste aller Zutaten zurück.""" + with getDB() as con: + cur = con.cursor() + cur.execute(""" + SELECT id, name, Exists_from.amount + FROM Ingridient + Inner Join Exists_from id = zid + Inner Join Recipe rid = id + Where id = ? + """, (id,)) + rows = cur.fetchall() + return [dict(row) for row in rows] + +def getAllocatedRecipes(name: str)-> list[dict]: + + with getDB() as con: + cur = con.cursor() + cur.execute(""" + SELECT rid + FROM Exists_from + Inner Join Ingridient on rid=id, + Where id = ? + """, (name,)) + rows = cur.fetchall() + return [dict(row) for row in rows] + diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index 6578c7b..788664c 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -5,12 +5,13 @@ class Recipe: def __init__(self, name: str, ingridients: list[Ingridient], description: str): self.__name = name - self.__Ingridienten = ingridients + self.__Ingridients = ingridients self.__description = description self.__original = "" self.__duration = "" - self.__rating = 0 + self.__rating = 0.0 self.__countPersons = 1 + self.__matching = 0 self.__database = Database() def saveInDB(self) -> bool: @@ -34,11 +35,17 @@ def getDescription(self) -> str: def setDescription(self, description: str): self.description = description - def getRating(self) -> int: - return self.rating + def getRating(self) -> float: + return self.__rating - def setRating(self, rating: int): - self.rating = rating + def setRating(self, rating: float): + self.__rating = rating + + def getMatching(self) -> int: + return self.__matching + + def incrementMatching(self): + self.__matching+=1 def getDuration(self) -> str: return self.duration @@ -46,9 +53,9 @@ def getDuration(self) -> str: def setDuration(self, duration: str): self.duration = duration - def getIngridient(self) -> list[Ingridient]: - return self.ingridients + def getIngridients(self) -> list[Ingridient]: + return self.__ingridients def setIngridient(self, ingridients: list[Ingridient]): - self.ingridients = ingridients + self.__Ingridients = ingridients diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py new file mode 100644 index 0000000..a43033e --- /dev/null +++ b/project/backend/RecipeSucuk.py @@ -0,0 +1,36 @@ +import Database +from Ingridient import Ingridient +from Recipe import Recipe +from Database import getAllRecipes, getAllIngridientsForRecipe + +def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]: + recipes = __initRecipes() + + for ingridient in ingriedents: + recipes = __filterRecipes(recipes) + + recipes.sort(key=lambda x: x.getRating(), reverse=True) + return recipes + +def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]: + for recipe in recipes: + for recipeIngridient in recipe.getIngridients(): + if recipeIngridient.getName() == ingridient.getName(): + recipe.incrementMatching() + recipe.setRating(recipe.getMatching/ len(recipe.getIngridients)) + return recipes + +def __initRecipes()-> list[Recipe]: + recipesRaw = getAllRecipes + recipes = [] + for recipeRaw in recipesRaw: + recipe = Recipe(recipeRaw["name"], __formatIngridients(recipeRaw["id"]), recipeRaw["description"]) + recipes.append(recipe) + return recipes + +def __formatIngridients(id: int)-> list[Ingridient]: + ingridientsRaw = getAllIngridientsForRecipe(id) + ingridients = [] + for ingridientRaw in ingridientsRaw: + ingridients.append(Ingridient(ingridientRaw["name"],ingridientRaw["amount"])) + return ingridients \ No newline at end of file From ef434217862e589abe4f410977f1d5e66526d8be Mon Sep 17 00:00:00 2001 From: Samuel Goebel Date: Mon, 4 May 2026 15:33:13 +0200 Subject: [PATCH 02/76] Added Filtering --- project/backend/RecipeSucuk.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py index a43033e..25d1e0c 100644 --- a/project/backend/RecipeSucuk.py +++ b/project/backend/RecipeSucuk.py @@ -10,6 +10,12 @@ def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]: recipes = __filterRecipes(recipes) recipes.sort(key=lambda x: x.getRating(), reverse=True) + + for recipe in recipes[::-1]: + if recipe.getMatching < 3: + recipes.remove(recipe) + else: + break return recipes def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]: From e701e833b318f40542b18c947822c353d65b88a9 Mon Sep 17 00:00:00 2001 From: nicla Date: Mon, 4 May 2026 15:44:37 +0200 Subject: [PATCH 03/76] =?UTF-8?q?Tests=20f=C3=BCr=20Database,=20passwort?= =?UTF-8?q?=20=C3=BCberpr=C3=BCfung=20und=20email=20=C3=BCber=C3=BCrufung?= =?UTF-8?q?=20wurden=20geschrieben.=20Die=20dazugeh=C3=B6rigen=20Abh=C3=A4?= =?UTF-8?q?ngigkeiten=20in=20requirements.txt=20angepasst.=20Sie=20werden?= =?UTF-8?q?=20in=20test=5Fmain.py=20aufgerufen.=20Namens=20faktorisierung?= =?UTF-8?q?=20ist=20schwer=20bei=20den=20Klassennamen=20da=20bei=20tests?= =?UTF-8?q?=20der=20pycharm=20Debugger=20eher=20auf=20klassen=20mit=20test?= =?UTF-8?q?=5F=20vorne=20guckt.=20Deswegen=20leider=20test=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- project/backend/Database.py | 7 ++- project/backend/requirements.txt | 3 +- project/backend/tests/__init__.py | 0 project/backend/tests/test_database.py | 72 +++++++++++++++++++++++++ project/backend/tests/test_email.py | 21 ++++++++ project/backend/tests/test_main.py | 25 +++++---- project/backend/tests/test_password.py | 41 ++++++++++++++ project/data/LazyCookDB.sqlite3 | Bin 65536 -> 65536 bytes 8 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 project/backend/tests/__init__.py create mode 100644 project/backend/tests/test_database.py create mode 100644 project/backend/tests/test_email.py create mode 100644 project/backend/tests/test_password.py diff --git a/project/backend/Database.py b/project/backend/Database.py index 0d469cf..a2ab902 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -1,8 +1,13 @@ import sqlite3 from contextlib import contextmanager from pathlib import Path +import os -DB_PATH = Path("/data/LazyCookDB.sqlite3") +# Verwende die Datenbank aus dem data-Ordner +DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3" + +# Stelle sicher, dass das Verzeichnis existiert +DB_PATH.parent.mkdir(parents=True, exist_ok=True) def getConnection() -> sqlite3.Connection: diff --git a/project/backend/requirements.txt b/project/backend/requirements.txt index feac5cf..60137ce 100644 --- a/project/backend/requirements.txt +++ b/project/backend/requirements.txt @@ -4,4 +4,5 @@ uvicorn[standard]==0.38.0 gunicorn==23.0.0 bcrypt python-jose[cryptography] -python-multipart \ No newline at end of file +python-multipart +pytest==9.0.3 \ No newline at end of file diff --git a/project/backend/tests/__init__.py b/project/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/backend/tests/test_database.py b/project/backend/tests/test_database.py new file mode 100644 index 0000000..d6af6f1 --- /dev/null +++ b/project/backend/tests/test_database.py @@ -0,0 +1,72 @@ +import pytest +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +import Database +from Auth import hashPassword, verifyPassword +from Database import createAccount, getAccountByEmail, deleteAccount, updateAccount + +@pytest.fixture(autouse=True) +def useTestDb(): + Database.initDB() + yield + # Tabellen nach jedem Test leeren + con = Database.getConnection() + con.execute("DELETE FROM Account") + con.commit() + con.close() + + +class TestCreateAccount: + def testAccountErstellen(self): + result = createAccount("test@example.com", "Test User", "hashedPW") + assert result is not None + assert result["email"] == "test@example.com" + + def testDuplikatEmail(self): + createAccount("test@example.com", "Test User", "hashedPW") + result = createAccount("test@example.com", "Anderer User", "hashedPW") + assert result is None + + +class TestGetAccountByEmail: + def testAccountGefunden(self): + createAccount("test@example.com", "Test User", "hashedPW") + account = getAccountByEmail("test@example.com") + assert account is not None + assert account["email"] == "test@example.com" + + def testAccountNichtGefunden(self): + result = getAccountByEmail("nichtvorhanden@example.com") + assert result is None + + +class TestDeleteAccount: + def testAccountLoeschen(self): + createAccount("test@example.com", "Test User", "hashedPW") + result = deleteAccount("test@example.com") + assert result is True + assert getAccountByEmail("test@example.com") is None + + def testNichtVorhandenenAccountLoeschen(self): + result = deleteAccount("nichtvorhanden@example.com") + assert result is False + + +class TestUpdateAccount: + def testEmailAendern(self): + account = createAccount("alt@example.com", "Test User", "hashedPW") + updateAccount(account["id"], email="neu@example.com") + assert getAccountByEmail("neu@example.com") is not None + assert getAccountByEmail("alt@example.com") is None + + def testPasswortAendern(self): + account = createAccount("test@example.com", "Test User", hashPassword("Alt1!")) + neuerHash = hashPassword("Neu1!") + updateAccount(account["id"], password_hash=neuerHash) + aktuell = getAccountByEmail("test@example.com") + # Note: The Database has 'hashedPassword' column + assert verifyPassword("Neu1!", aktuell["hashedPassword"]) is True \ No newline at end of file diff --git a/project/backend/tests/test_email.py b/project/backend/tests/test_email.py new file mode 100644 index 0000000..7225231 --- /dev/null +++ b/project/backend/tests/test_email.py @@ -0,0 +1,21 @@ +import pytest +import sys +import os + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from Auth import validateEmail + +class TestValidateEmail: + def testGueltigeEmail(self): + assert validateEmail("test@example.com") is None + + def testFehlendesAt(self): + assert validateEmail("testexample.com") is not None + + def testFehlendeDomain(self): + assert validateEmail("test@") is not None + + def testLeereEmail(self): + assert validateEmail("") is not None \ No newline at end of file diff --git a/project/backend/tests/test_main.py b/project/backend/tests/test_main.py index d5af60a..9370439 100644 --- a/project/backend/tests/test_main.py +++ b/project/backend/tests/test_main.py @@ -1,10 +1,15 @@ -import unittest - - -class MyTestCase(unittest.TestCase): - def test_something(self): - self.assertEqual(True, True) # add assertion here - - -if __name__ == '__main__': - unittest.main() +""" +Test Runner - Führe alle Tests aus +Nutzt pytest zum Sammeln und Ausführen aller Tests im tests-Ordner +""" +import pytest +import sys +import os + +if __name__ == "__main__": + # Suche alle Test-Dateien im aktuellen Verzeichnis und führe sie aus + pytest.main([ + os.path.dirname(__file__), + "-v", + "--tb=short" + ]) diff --git a/project/backend/tests/test_password.py b/project/backend/tests/test_password.py new file mode 100644 index 0000000..65ec47c --- /dev/null +++ b/project/backend/tests/test_password.py @@ -0,0 +1,41 @@ +import pytest +import sys +import os + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from Auth import validatePassword, hashPassword, verifyPassword + +class TestValidatePassword: + def testGueltigesPasswort(self): + assert validatePassword("Sicher1!") is None + + def testZuKurz(self): + assert validatePassword("Ab1!") is not None + + def testKeinGrossbuchstabe(self): + assert validatePassword("sicher1!") is not None + + def testKeinKleinbuchstabe(self): + assert validatePassword("SICHER1!") is not None + + def testKeineZahl(self): + assert validatePassword("SicherAB!") is not None + + def testKeinSonderzeichen(self): + assert validatePassword("Sicher123") is not None + + +class TestPasswortHashing: + def testHashIstVerschieden(self): + hashed = hashPassword("Sicher1!") + assert hashed != "Sicher1!" + + def testVerifyKorrektesPasswort(self): + hashed = hashPassword("Sicher1!") + assert verifyPassword("Sicher1!", hashed) is True + + def testVerifyFalschesPasswort(self): + hashed = hashPassword("Sicher1!") + assert verifyPassword("Falsch1!", hashed) is False \ No newline at end of file diff --git a/project/data/LazyCookDB.sqlite3 b/project/data/LazyCookDB.sqlite3 index 8f8acc3ee212d9a9495fca4da0907069ff828483..aa7b38f1a5172558800258b27978e6ccc2143d12 100644 GIT binary patch delta 486 zcmZo@U}k)5T;g-EVrFB|m6tUx zNi8mMNUccBEyzjLOU};?0rC|>i&Kj-5{olZQv$+`42;Zl4NP?nOcV?atxSxq3@jNK z7@ka4kk6}U$k%5Fn$pOM+ZYw2Bo#v=6_ zl8O@loJdPU&kWmY_#+>W}0jj7dE-x*BB_t#v0)7rAei;V-K7N_af(iosECo#L43ib)^Xdy2 z*%?eF8(EF>GLv%>i}i97ORBO{9m-Ra^ionoQj1Fz+*FK`R1A$&^o$Fg{0j@x-JSK^ z3SCRfJWP@eO8pC5vb@|~$_xTZGb)|(iyRI8jWQGc&2mGlyu-8HgN+P~%ybP*bd5|D z3=OP|EUio|xEL51nE1~!@So*>!LV7#-~d016tgAc^65`CJoY)+)$>1SBBh%zZ z_DVwR+}{}ZbNO!Zx$$n|mE-B+{~-kW@; z{GPnEd~f-e0u4{+om?9m!xGKXXgT?AYy^a185h9{5@DRY*Eft6BK9#3D(M>^!4d_O K+`KYAd;tI|b#)d1 From ef3fd46c1ce80eb915d45a07d9e931fb9a909b7b Mon Sep 17 00:00:00 2001 From: nicla Date: Mon, 4 May 2026 15:50:11 +0200 Subject: [PATCH 04/76] =?UTF-8?q?ci=20pipeline=20abh=C3=A4gigkeit=20mit=20?= =?UTF-8?q?rein=20gemacht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a682cc0..f2e2a00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ on: pull_request: branches: [main, 'blatt*'] push: - branches: [main, 'blatt*'] + branches: [main, 'blatt*', 'feat/*'] permissions: contents: read @@ -50,7 +50,7 @@ jobs: - name: Run backend tests run: | - docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v" + docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest tests/ -v" # tests: # runs-on: ubuntu-latest From d2c72c060761ab910add1fd0de6e509b8f5f6077 Mon Sep 17 00:00:00 2001 From: nicla Date: Tue, 5 May 2026 10:12:41 +0200 Subject: [PATCH 05/76] Feat bei Ci yml entfernt --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2e2a00..20dbc68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ on: pull_request: branches: [main, 'blatt*'] push: - branches: [main, 'blatt*', 'feat/*'] + branches: [main, 'blatt*'] permissions: contents: read From 70b7d97c85b21ffadd03b68f8d21fa8ecfa9364e Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Tue, 5 May 2026 16:09:38 +0200 Subject: [PATCH 06/76] Delete project/data/LazyCookDB.sqlite3 --- project/data/LazyCookDB.sqlite3 | Bin 65536 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 project/data/LazyCookDB.sqlite3 diff --git a/project/data/LazyCookDB.sqlite3 b/project/data/LazyCookDB.sqlite3 deleted file mode 100644 index 8f8acc3ee212d9a9495fca4da0907069ff828483..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 zcmeI)&2Qr99S3m83rsRYNDdX6PSx>fW~8JUsBK~lxpWMe_uybm2=qXCG2n#vcaXMH zI=kCy)mG}c=l%oz6DswvhgA>N9@=A1z4TU9J+x8{gap#i>^4MVcD_gigNMgIe}2E` z!PpNIjf9kzOysMLa=JxWHQO4kR`Zr1G#bs0df!&>lbc>$SeraiAGK4<^A>kBmaNub z{HMmK&1tqw#?KAE-~657=NsSK{DuBskN&OO)c;}i*Xw`O=2naInt1Iea=*_DIqf6vDi6+f>gajuD&;BaYH|Bx=84|)@}>4?)TnIup3-bJQ~5@;|zM=QNLaomfhPt;ih2gIh)L0c+ZFiXpP7U*}QezA{ zlmF$sQkyD~6gBl^>z#8`Z+iMv`)O)aZsU$I{`+{Q$R=H8Zn57zW=$2rv8d)uOpH1{ z7n1~kSl!qWAC~5K=)2pmq{}iP@uw1T*-;lU@O7r5UPOB{<)=12Q+`~#RjfAVl-^wV zz5T~J(+{4`%l$ZWDQf)qA7y;J$?@NfnT$_@nY-P)Got>BDZU3uy1I$E zdA~LCz5R{m~h++V$)A8H*oSZLZioy5D#> zmip@F>Io;he4MyzC9enMTXQk2pgpTzXY#(7SH0JsDe8Lp(I?(Dz3KI9?PueoD^boj z ze-2dVmp{9%GX-DIEC1<|9S-5wWv)A3)tUD9wTaQdba`;ulUXa3o_R%Q;-1Ybp35YuA7z{}fB*y_009U<00Izz00cfjf!c~*asvU?YYG2}yBQDCByBU32WFZjDEjb#KD1IbtzN@CRkv7CvZZv>qF$39oXc;! za>kOACArxmJeOOvSnE!qUMqNA7EhgTcYFs~vMtoyX92IfLmsz_eP^}7Q39=`1zR~b z2*%I6(W@;U5EOYxI}Q)*n`&NHj9+MsUl{-T0j3zc3;_s000Izz00bZa0SG_<0uXrr z1iroM(B6KoV#Tz|Y}|HI!C?Hm`u+b)jqyw4U*5kRM#K<+00bZa0SG_<0uX=z1Rwx` z4_M%_cJ-Nd@?(wdmDShU>7R=Xf5-o1qBETTuYddxjj_D-=Pi%nw}$UN`AmI^8w4N# z0SG_<0uX=z1R(H@5xCgV>owX7$B}iV8!>ZozCgLXX1-jgDm`yIZ*zuXmGgA;EMA}k z_E;n1J&E?ig{~{ujVj_ny&ZEO54=b2UcOHzs>M#U+DW*XXgUz~QGy`L?ZSAbF{4gz zmMH5X6{Fkdk3uG?pf1PrfOxQWZEyX zwMeGn4<*{k)KRV%q`Qeu?$qq5c*Q);M~-TaQofuLj_a=Ed9sksHcGU!7w@?fiqHrv z%|3S{Cid`vu`qUqu~WCkG+ckNvp$Kbl$WE?)AOP)Cb>=$R4c(ApM=ArP*3F?y}Z+U zYz_n*tY^SbzCqD^%1Cap8jy>rPFkcZa;6$Q9SG5UxZ(^s2b`?Ly9eH;c*BWg4oTKx zvyoQXaa&BI^%py9lb9H(+RBk$b2*(WWzRx2m(&kdyh1R+x0rO(9JC9CQcf&(Yra~C z7Ui?EQXn5Z8gzngGn-PX1G?%DIGb)Rau&6@QxPs)76#@SC;E_4r>5I16lJGu%zwvp zx&C5jbrMr|U}MQj)*P*!7NQ{~$(TE|Gn+i2!|rIEVFyy8WVZ>fu%ABb7fupgccMur zLVSR6^~(oT-Q5WWA|9_Cjq{$a=iHvn4+f>ujMI3oJZ+&F)P4~Io z$=i8*JRgx2?#yZPdHcQ?7hr8+-*rfoq)w){QbQ)vMzZ#sA&n;6kJevotMmIuA@%ct zdOH~k_{~BgVzskfGCu zfB*y_009U<00Izz00bZafdvcT`u~DK(HsOI009U<00Izz00bZa0SG`~2?hQG0wt;; From 7b20faf1bf15bcddb1ab94fe18366bc4d963a05a Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 5 May 2026 16:25:22 +0200 Subject: [PATCH 07/76] Feature: Forgot Password link in signin modal. Popup for Email Input. Konto deletion reactivation. --- project/backend/Auth.py | 34 +++- project/backend/Database.py | 65 +++++++- project/backend/Models.py | 12 ++ project/backend/Routes.py | 51 +++++- .../frontend/app/homepage/forgotPassword.tsx | 74 +++++++++ project/frontend/app/homepage/homepage.tsx | 9 +- project/frontend/app/homepage/signin.tsx | 5 +- .../frontend/app/profile/changeEmailPopup.tsx | 53 +++++++ .../app/profile/changePasswordPopup.tsx | 114 ++++++++++++++ project/frontend/app/profile/page.tsx | 148 ++---------------- project/frontend/app/profile/style.css | 10 ++ project/frontend/app/reset-password/page.tsx | 122 +++++++++++++++ 12 files changed, 552 insertions(+), 145 deletions(-) create mode 100644 project/frontend/app/homepage/forgotPassword.tsx create mode 100644 project/frontend/app/profile/changeEmailPopup.tsx create mode 100644 project/frontend/app/profile/changePasswordPopup.tsx create mode 100644 project/frontend/app/profile/style.css create mode 100644 project/frontend/app/reset-password/page.tsx diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 41283b0..1ffde5d 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -13,7 +13,11 @@ import bcrypt from pydantic import BaseModel -from Models import Token, User +import hashlib + +PASSWORD_RESET_EXPIRE_MINUTES = 30 + +from Models import Token, User, ForgotPasswordRequest, ResetPasswordRequest from Database import getAccountByEmail, saveRefreshToken, getRefreshToken @@ -116,3 +120,31 @@ async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User: raise credentials_exception return User(email=konto["email"], name=konto["name"]) + +# --- Reset Password --- # + +def hashResetToken(token: str) -> str: + """SHA-256 Hash – für Reset-Tokens reicht das, sie sind ohnehin hochentropisch.""" + return hashlib.sha256(token.encode()).hexdigest() + + +def createPasswordResetToken(kontoId: int) -> str: + """Generiert Klartext-Token (geht per Mail) und speichert nur den Hash in der DB.""" + from Database import savePasswordResetToken + token = secrets.token_urlsafe(48) + expiresAt = datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_EXPIRE_MINUTES) + savePasswordResetToken(kontoId, hashResetToken(token), expiresAt.isoformat()) + return token + + +def validatePasswordResetToken(token: str) -> dict | None: + from Database import getPasswordResetToken + entry = getPasswordResetToken(hashResetToken(token)) + if entry is None or entry["used_at"] is not None: + return None + expiresAt = datetime.fromisoformat(entry["expires_at"]) + if expiresAt.tzinfo is None: + expiresAt = expiresAt.replace(tzinfo=timezone.utc) + if expiresAt < datetime.now(timezone.utc): + return None + return entry \ No newline at end of file diff --git a/project/backend/Database.py b/project/backend/Database.py index 0d469cf..4601e4c 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -99,6 +99,17 @@ def initDB(): UNIQUE (AccountID, rid) ) """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS PasswordResetToken ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kontoID INTEGER NOT NULL, + tokenHash TEXT NOT NULL UNIQUE, + expiresAt TIMESTAMP NOT NULL, + usedAt TIMESTAMP, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (kontoID) REFERENCES Account (id) ON DELETE CASCADE + ) + """) print("✅ Datenbank-Tabellen erfolgreich initialisiert") @@ -203,4 +214,56 @@ def cleanupExpiredTokens() -> None: """Löscht alle abgelaufenen Refresh Tokens.""" with getDB() as con: cur = con.cursor() - cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") \ No newline at end of file + cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") + + + +# ── Password-Reset-Token-Operationen ─────────────────────────── + +def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None: + """Invalidiert alte Tokens des Kontos und speichert einen neuen.""" + with getDB() as con: + cur = con.cursor() + # Alte, ungenutzte Tokens für dieses Konto invalidieren + cur.execute( + "UPDATE PasswordResetToken SET used_at = CURRENT_TIMESTAMP " + "WHERE kontoID = ? AND usedAt IS NULL", + (kontoID,), + ) + cur.execute( + "INSERT INTO PasswordResetToken (kontoID, tokenHash, expiresAt) VALUES (?, ?, ?)", + (kontoID, tokenHash, expiresAt), + ) + + +def getPasswordResetToken(tokenHash: str) -> dict | None: + con = getConnection() + try: + cur = con.cursor() + cur.execute( + "SELECT id, kontoID, tokenHash, expiresAt, usedAt " + "FROM PasswordResetToken WHERE tokenHash = ?", + (tokenHash,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + con.close() + + +def markResetTokenUsed(tokenID: int) -> None: + with getDB() as con: + cur = con.cursor() + cur.execute( + "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP WHERE id = ?", + (token_id,), + ) + + +def updateKontoPassword(konto_id: int, hashed_password: str) -> None: + with getDB() as con: + cur = con.cursor() + cur.execute( + "UPDATE Account SET hashed_password = ? WHERE id = ?", + (hashed_password, konto_id), + ) \ No newline at end of file diff --git a/project/backend/Models.py b/project/backend/Models.py index 43c573e..86638ac 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -19,3 +19,15 @@ class RefreshRequest(BaseModel): class LogoutRequest(BaseModel): refresh_token: str + +class ForgotPasswordRequest(BaseModel): + email: str + +class ResetPasswordRequest(BaseModel): + token: str + new_password: str + +class UpdateUser(BaseModel): + email: str | None = None + currentPassword: str | None = None + newPassword: str | None = None \ No newline at end of file diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 0289641..1c9a077 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -14,6 +14,8 @@ validateRefreshToken, verifyPassword, createAccessToken, + createPasswordResetToken, + validatePasswordResetToken, ) from Database import ( createAccount, @@ -22,9 +24,13 @@ deleteAllRefreshTokens, deleteAccount, updateAccount, + markResetTokenUsed, + updateKontoPassword, + deleteAllRefreshTokens + ) -from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest +from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser router = APIRouter() @@ -104,10 +110,7 @@ async def deleteCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser) # ── Account aktualisieren ──────────────────────────────────────── -class UpdateUser(_BaseModel): - email: str | None = None - currentPassword: str | None = None - newPassword: str | None = None + @router.patch("/users/me") async def updateCurrentUser( @@ -143,3 +146,41 @@ async def updateCurrentUser( print(f"E-Mail konnte nicht gesendet werden: {e}") return {"success": True} + +# ------------ Passwort vergessen ----------------- # + +@router.post("/auth/forgot-password") +async def forgotPassword(body: ForgotPasswordRequest): + """Schritt 1: User gibt E-Mail ein, bekommt Reset-Link per Mail.""" + konto = getAccountByEmail(body.email) + + # Token nur erzeugen wenn Konto existiert – aber IMMER gleiche Antwort senden! + if konto is not None: + token = createPasswordResetToken(konto["id"]) + resetLink = f"http://localhost:8000/reset-password?token={token}" + # TODO: Mail versenden – siehe Hinweis unten + print(f"[DEV] Reset-Link für {body.email}: {resetLink}") + + return {"detail": "Falls die E-Mail existiert, wurde ein Link versendet."} + + +@router.post("/auth/reset-password") +async def resetPassword(body: ResetPasswordRequest): + """Schritt 2: User setzt mit Token aus Mail das neue Passwort.""" + pwError = validatePassword(body.new_password) + if pwError: + raise HTTPException(status_code=400, detail=pwError) + + entry = validatePasswordResetToken(body.token) + if entry is None: + raise HTTPException( + status_code=400, + detail="Link ungültig oder abgelaufen. Bitte neuen anfordern.", + ) + + updateKontoPassword(entry["konto_id"], hashPassword(body.new_password)) + markResetTokenUsed(entry["id"]) + # Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen + deleteAllRefreshTokens(entry["konto_id"]) + + return {"detail": "Passwort erfolgreich zurückgesetzt."} diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx new file mode 100644 index 0000000..cd778d1 --- /dev/null +++ b/project/frontend/app/homepage/forgotPassword.tsx @@ -0,0 +1,74 @@ +import React, { useState } from "react"; +import Field from "@/app/components/fields"; +import styles from "./page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => void; onBack: () => void; }) { + const [email, setEmail] = useState(""); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [emailBlurred, setEmailBlurred] = useState(false); + + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const handleSubmit = async () => { + setEmailBlurred(true); + if (!emailValid) return; + setBusy(true); + try { + await fetch(`${API_URL}/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + setSubmitted(true); + } finally { + setBusy(false); + } + }; + + if (submitted) { + return ( +
+

E-Mail versendet

+

+ Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen + Link zum Zurücksetzen geschickt. Prüfe auch deinen Spam-Ordner. +

+ +
+ ); + } + + return ( +
+

Passwort vergessen

+

+ Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum + Zurücksetzen. +

+ setEmail(v)} + placeholder="Email" + onBlur={() => setEmailBlurred(true)} + state={ + emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default" + } + /> + +

+ +

+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index 5c04f67..6ad3795 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -7,11 +7,12 @@ import {useCallback, useState} from "react"; import Modal from "@/app/components/modal"; import LoginForm from "@/app/homepage/signin"; import RegisterForm from "@/app/homepage/signup"; +import ForgotPasswordForm from "@/app/homepage/forgotPassword"; export default function Homepage() { - const [modal, setModal] = useState<"login" | "register" | null>(null); + const [modal, setModal] = useState<"login" | "register" | "forgot"| null>(null); const close = useCallback(() => setModal(null), []); @@ -191,12 +192,16 @@ export default function Homepage() { {/* ── Modals ──────────────────────────────────────────── */} - setModal("register")} /> + setModal("register")} onForgot={() => setModal("forgot")} /> setModal("login")} /> + + + setModal("login")} /> + ); } \ No newline at end of file diff --git a/project/frontend/app/homepage/signin.tsx b/project/frontend/app/homepage/signin.tsx index 1e40ca9..04df94a 100644 --- a/project/frontend/app/homepage/signin.tsx +++ b/project/frontend/app/homepage/signin.tsx @@ -5,7 +5,7 @@ import Field from "@/app/components/fields" import styles from "./page.module.css"; import {useRouter} from "next/navigation"; -export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; onSwitch: () => void }) { +export default function LoginForm({ onClose, onSwitch, onForgot}: { onClose: () => void; onSwitch: () => void; onForgot: () => void }) { const { login } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); @@ -47,6 +47,9 @@ export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; {error &&

{error}

} { setEmail(v); if (emailBlurred) setEmailBlurred(false); }} placeholder="Email" onBlur={() => setEmailBlurred(true)} state={emailState()} /> e.key === "Enter" && handleSubmit()} onBlur={() => setPwBlurred(true)} state={pwBlurred && !password ? "error" : "default"} /> +

+ +

diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx new file mode 100644 index 0000000..9b971ea --- /dev/null +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -0,0 +1,53 @@ +import {Button} from "@/app/components/ui/button"; +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +export default function ChangeEmail (){ + + + const [newEmail, setNewEmail] = useState(""); + const [emailMsg, setEmailMsg] = useState(""); + + async function handleEmailChange() { + setEmailMsg(""); + try { + const res = await fetchWithAuth(`${API_URL}/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: newEmail }), + }); + if (!res.ok) { + const err = await res.json(); + setEmailMsg(`${err.detail}`); + return; + } + setEmailMsg("E-Mail erfolgreich geändert."); + setNewEmail(""); + } catch { + setEmailMsg("Unbekannter Fehler."); + } + } + + return ( +
+

E-Mail ändern

+ setNewEmail(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm w-full" + /> + {emailMsg &&

{emailMsg}

} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx new file mode 100644 index 0000000..3c6bce4 --- /dev/null +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -0,0 +1,114 @@ +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; +import {Button} from "@/app/components/ui/button"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +type Modus = "change" | "forgot"; + +interface ChangePasswordProps { + modus: Modus; + onSuccess?: () => void; +} + +export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps) { + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordMsg, setPasswordMsg] = useState(""); + + const isForgot = modus === "forgot"; + + async function handlePasswordChange() { + setPasswordMsg(""); + + // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern) + if (newPassword !== confirmPassword) { + setPasswordMsg("Die Passwörter stimmen nicht überein."); + return; + } + + try { + const endpoint = isForgot + ? `${API_URL}/users/forgot-password` + : `${API_URL}/users/me`; + + const body = isForgot + ? { newPassword } + : { currentPassword, newPassword }; + + const fetcher = isForgot ? fetch : fetchWithAuth; + + const res = await fetcher(endpoint, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.json(); + setPasswordMsg(`❌ ${err.detail}`); + return; + } + + setPasswordMsg( + isForgot + ? "Passwort erfolgreich zurückgesetzt." + : "Passwort erfolgreich geändert." + ); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + onSuccess?.(); + } catch { + setPasswordMsg("Unbekannter Fehler."); + } + } + + return ( +
+

+ {isForgot ? "Passwort zurücksetzen" : "Passwort ändern"} +

+ + {!isForgot && ( + setCurrentPassword(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm w-full" + /> + )} + + setNewPassword(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm w-full" + /> + + setConfirmPassword(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm w-full" + /> + + {passwordMsg &&

{passwordMsg}

} + +
+ +
+
+ + ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index 1f5a269..2147626 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -1,17 +1,18 @@ "use client"; -import { useRef, useState} from "react"; +import {useState} from "react"; import { useAuth, fetchWithAuth } from "@/lib/auth"; import { useRouter } from "next/navigation"; import {ChefHat} from "lucide-react"; import { Button } from '../components/ui/button'; import ProfileDropdown from "@/app/components/profile_dropdown"; +import Modal from "@/app/components/modal"; +import ChangeEmail from "@/app/profile/changeEmailPopup"; +import ChangePassword from "@/app/profile/changePasswordPopup"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { - - const menuRef = useRef(null); const { user, logout } = useAuth(); const router = useRouter(); const [showConfirm, setShowConfirm] = useState(false); @@ -19,58 +20,8 @@ export default function Profile() { const [showEmailModal, setShowEmailModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); - // E-Mail ändern - const [newEmail, setNewEmail] = useState(""); - const [emailMsg, setEmailMsg] = useState(""); - - // Passwort ändern - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [passwordMsg, setPasswordMsg] = useState(""); - if (!user) return null; - async function handleEmailChange() { - setEmailMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: newEmail }), - }); - if (!res.ok) { - const err = await res.json(); - setEmailMsg(`${err.detail}`); - return; - } - setEmailMsg("E-Mail erfolgreich geändert."); - setNewEmail(""); - } catch { - setEmailMsg("Unbekannter Fehler."); - } - } - - async function handlePasswordChange() { - setPasswordMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentPassword, newPassword }), - }); - if (!res.ok) { - const err = await res.json(); - setPasswordMsg(`❌ ${err.detail}`); - return; - } - setPasswordMsg("Passwort erfolgreich geändert."); - setCurrentPassword(""); - setNewPassword(""); - } catch { - setPasswordMsg("Unbekannter Fehler."); - } - } - async function handleAccountDeletion() { const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); @@ -111,13 +62,13 @@ export default function Profile() {

Einstellungen

- {/* E-Mail Modal */} - {showEmailModal && ( -
setShowEmailModal(false)} - > -
e.stopPropagation()} - > -

E-Mail ändern

- setNewEmail(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {emailMsg &&

{emailMsg}

} -
- - -
-
-
- )} + setShowEmailModal(false)}> + + - {/* Passwort Modal */} - {showPasswordModal && ( -
setShowPasswordModal(false)} - > -
e.stopPropagation()} - > -

Passwort ändern

- setCurrentPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - setNewPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {passwordMsg &&

{passwordMsg}

} -
- - -
-
-
- )} + setShowPasswordModal(false)}> + + ); } diff --git a/project/frontend/app/profile/style.css b/project/frontend/app/profile/style.css new file mode 100644 index 0000000..ef2f8da --- /dev/null +++ b/project/frontend/app/profile/style.css @@ -0,0 +1,10 @@ +.popup { + background: #fff; + border-radius: 12px; + padding: 2rem 2rem 1.75rem; + width: auto; + max-width: 520px; + position: relative; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.12); + animation: slideUp 0.2s ease-out; +} \ No newline at end of file diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..a56795c --- /dev/null +++ b/project/frontend/app/reset-password/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import React, { Suspense, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Field from "@/app/components/fields"; +import styles from "@/app/homepage/page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function ResetPasswordContent() { + const params = useSearchParams(); + const router = useRouter(); + const token = params.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + + const pwValid = + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /[0-9]/.test(password) && + /[^A-Za-z0-9]/.test(password); + const pwMatch = password === confirm && confirm.length > 0; + + const handleSubmit = async () => { + setError(""); + if (!pwValid) { + setError("Passwort erfüllt nicht alle Anforderungen."); + return; + } + if (!pwMatch) { + setError("Passwörter stimmen nicht überein."); + return; + } + setBusy(true); + try { + const res = await fetch(`${API_URL}/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, new_password: password }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.detail ?? "Fehler beim Zurücksetzen."); + return; + } + setDone(true); + setTimeout(() => router.push("/"), 2500); + } catch { + setError("Verbindungsfehler."); + } finally { + setBusy(false); + } + }; + + if (!token) { + return ( +
+

Ungültiger Link

+

+ Der Link ist unvollständig. Bitte fordere einen neuen an. +

+
+ ); + } + + if (done) { + return ( +
+

Erledigt!

+

+ Dein Passwort wurde zurückgesetzt. Du wirst gleich weitergeleitet… +

+
+ ); + } + + return ( +
+

Neues Passwort festlegen

+

Wähle ein sicheres neues Passwort.

+ {error &&

{error}

} + + + +
+ ); +} + +export default function ResetPasswordPage() { + return ( + +

Wird geladen…

+ + } + > + +
+ ); +} \ No newline at end of file From 08852138d9175e22a6461f621f34f3f526009e42 Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Tue, 5 May 2026 16:56:43 +0200 Subject: [PATCH 08/76] Feat/ci cd add lint (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename models.py to Models.py * Rename auth.py to Auth.py * Rename Routes.py to routes.py * Added Refactoring Summary * Refactor documentation for code naming conventions * Fix spelling errors in refactoring documentation Corrected spelling of 'Refaktorisierung' and 'Frontend'. * Delete project/data/LazyCookDB.sqlite3 * AI Agent md's changed * Passwort und Email Änderung zu Popups geändert. und neugeordnet untereinander * Update README.md to mark tasks as completed for account management features * Add CI/CD pipeline and Super Linter configuration * Test für lint * Test ci/cd lint.yml * Test ci/cd lint.yml * Test ci/cd lint.yml * Test ci/cd lint.yml regex Filter * Test ci/cd lint.yml * Test ci/cd lint.yml * Test ci/cd lint.yml regex Filter angepasst * Tests für Database, passwort überprüfung und email überürufung wurden geschrieben. Die dazugehörigen Abhängigkeiten in requirements.txt angepasst. Sie werden in test_main.py aufgerufen. Namens faktorisierung ist schwer bei den Klassennamen da bei tests der pycharm Debugger eher auf klassen mit test_ vorne guckt. Deswegen leider test_ * ci pipeline abhägigkeit mit rein gemacht * Feat bei Ci yml entfernt * Update FILTER_REGEX_INCLUDE pattern in lint.yml * Update lint.yml * Enable validation for the entire codebase * Update lint.yml * Update lint.yml * Enable validation for all codebase in lint workflow * Remove Python Black and Markdown validation * Update lint workflow to include additional validations * Update FILTER_REGEX_INCLUDE to support YAML files * Update lint.yml * Update lint.yml * Refactor CI configuration to use Docker Compose v2 syntax and add GitHub Super Linter step * Test ci.yml * Update ci.yml * Enable Flake8 validation for Python files * Test ci.yml * Remove 'feat/*' branch from push trigger in CI configuration * Python black gelöst --------- Co-authored-by: Samuel Goebel Co-authored-by: Eden Tabea Bernhard <105359952+EdenBernhard@users.noreply.github.com> Co-authored-by: Nicoolaus <162422307+Nicoolaus@users.noreply.github.com> Co-authored-by: Hellocrafting Co-authored-by: nicla Co-authored-by: Hellocrafting <75727565+Hellocrafting@users.noreply.github.com> --- .github/workflows/ci.yml | 105 ++++++++-------------- README.md | 4 + project/backend/Admin.py | 1 - project/backend/Auth.py | 28 ++++-- project/backend/Database.py | 22 +++-- project/backend/EmailService.py | 3 +- project/backend/Ingridient.py | 2 +- project/backend/LazyCookAdministration.py | 1 - project/backend/Models.py | 5 ++ project/backend/Person.py | 2 +- project/backend/Recipe.py | 1 - project/backend/Routes.py | 7 +- project/backend/User.py | 1 - project/backend/requirements.txt | 3 +- project/backend/tests/__init__.py | 0 project/backend/tests/test_database.py | 73 +++++++++++++++ project/backend/tests/test_email.py | 21 +++++ project/backend/tests/test_main.py | 22 ++--- project/backend/tests/test_password.py | 41 +++++++++ 19 files changed, 241 insertions(+), 101 deletions(-) create mode 100644 project/backend/tests/__init__.py create mode 100644 project/backend/tests/test_database.py create mode 100644 project/backend/tests/test_email.py create mode 100644 project/backend/tests/test_password.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a682cc0..d4c93fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,29 @@ name: "CI/CD Pipeline" + on: pull_request: branches: [main, 'blatt*'] push: branches: [main, 'blatt*'] + permissions: contents: read jobs: - build: + build-tests: + name: Build stack and run tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Docker Compose (build job) - run: | - sudo apt-get update - sudo apt-get install -y docker-compose - - name: Build the stack - run: docker-compose -f project/compose.yaml build + run: docker compose -f project/compose.yaml build - name: Start services run: | # remove stale containers/volumes from previous runs that can cause compose to read invalid metadata - docker-compose -f project/compose.yaml down --remove-orphans --volumes || true - docker-compose -f project/compose.yaml up -d + docker compose -f project/compose.yaml down --remove-orphans --volumes || true + docker compose -f project/compose.yaml up -d - name: Wait for frontend on :8000 run: | @@ -42,69 +40,42 @@ jobs: sleep $wait_seconds done echo "service not ready" >&2 - docker-compose -f project/compose.yaml ps || true - docker-compose -f project/compose.yaml logs || true + docker compose -f project/compose.yaml ps || true + docker compose -f project/compose.yaml logs || true exit 1 + - name: Run smoke test frontend run: curl -fsSL -o /dev/null http://localhost:8000/ - name: Run backend tests run: | - docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v" + docker compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest tests/ -v" -# tests: -# runs-on: ubuntu-latest -# steps: -# - uses: actions/checkout@v4 -# -# - name: Install Docker Compose (tests job) -# run: | -# sudo apt-get update -# sudo apt-get install -y docker-compose -# -# - name: Pull images (if available) or build -# run: | -# # Versuche remote images zu ziehen; falls nicht vorhanden, baue lokal neu. -# docker-compose -f project/compose.yaml pull || docker-compose -f project/compose.yaml build -# -# - name: Start services -# run: | -# docker-compose -f project/compose.yaml down --remove-orphans --volumes || true -# docker-compose -f project/compose.yaml up -d backend-dev -# -# - name: Wait for backend to be ready -# run: | -# for i in $(seq 1 20); do -# docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -c 'print(\"ok\")'" >/dev/null 2>&1 && break -# sleep 1 -# done -# -# - name: Run backend tests -# run: | -# docker-compose -f project/compose.yaml exec -T backend-dev sh -c "python -m unittest discover -s tests -v" + linter: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 -# lint: -# runs-on: ubuntu-latest -# needs: build -# steps: -# - name: Wait for frontend on :8000 -# run: | -# PORT=8000 -# attempts=30 -# wait_seconds=3 -# for i in $(seq 1 $attempts); do -# if curl -fsSL "http://localhost:${PORT}/" >/dev/null 2>&1; then -# echo "ready" -# exit 0 -# fi -# echo "waiting... ($i)" -# sleep $wait_seconds -# done -# echo "service not ready" >&2 -# docker-compose -f project/compose.yaml ps || true -# docker-compose -f project/compose.yaml logs || true -# exit 1 -# - name: Run linting tests -# run: | -# # startet einen einmaligen Container für den Service 'backend' und führt Linting-Tests aus (z.B. mit flake8) -# docker-compose -f project/compose.yaml run --rm backend sh -c "flake8 ." \ No newline at end of file + - name: Run GitHub Super Linter + uses: super-linter/super-linter/slim@v7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: main + VALIDATE_ALL_CODEBASE: false + # Root ignore file + # GitHub workflows + # Project backend/frontend + Dockerfiles + compose files + FILTER_REGEX_INCLUDE: '^/github/workspace/(\.gitignore|\.github/workflows/.*\.ya?ml|project/(backend/.*\.py|frontend/.*\.(ts|tsx|js|jsx|css|mjs|cjs)|Dockerfile[^/]*|(compose|docker-compose)\.ya?ml))$' + VALIDATE_PYTHON: true + VALIDATE_PYTHON_BLACK: true + VALIDATE_PYTHON_FLAKE8: true + VALIDATE_TYPESCRIPT_ES: true + VALIDATE_JAVASCRIPT_ES: true + VALIDATE_CSS: true + VALIDATE_DOCKERFILE: true + VALIDATE_YAML: true + VALIDATE_GITHUB_ACTIONS: true diff --git a/README.md b/README.md index 65a05fa..5beb5c9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - Containerization: Docker - Projekt Management: GitHub Projects +## Code-Quality / Linting +- Der Workflow `.github/workflows/ci.yml` prüft mit dem Job `linter` das Projekt mit **GitHub Super-Linter**. +- Dabei werden u. a. Python-, TypeScript-, JavaScript-, YAML- und Dockerfile-Dateien validiert. + +Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. + +Für die Funktion [#115](https://github.com/GalacticCodeGambit/LazyCook/issues/115) von Email Versenden/Empfangen muss im Ordner `project/` eine `.env` Datei mit den folgenden Variablen angelegt werden: +``` +EMAIL_HOST= +GMAIL_PASSWORD= +``` + ## Verwendete Technologien - Frontend: HTML, CSS, TypeScript/React - Backend: Python @@ -30,26 +47,29 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - Containerization: Docker - Projekt Management: GitHub Projects -## Code-Quality / Linting -- Der Workflow `.github/workflows/ci.yml` prüft mit dem Job `linter` das Projekt mit **GitHub Super-Linter**. -- Dabei werden u. a. Python-, TypeScript-, JavaScript-, YAML- und Dockerfile-Dateien validiert. +### Code-Quality / Linting +- Der Workflow `.github/workflows/lint.yml` prüft das Projekt mit **GitHub Super-Linter**. +- Dabei werden u.a. Python-, TypeScript-, JavaScript-, YAML- und Dockerfile-Dateien validiert. - ## Installation and Setup -1. Klonen das Repository: - `git clone https://github.com/GalacticCodeGambit/LazyCook.git` -2. Navigieren zum Projektverzeichnis: - `cd LazyCook/Project` -3. Starten der Anwendung mit: Docker Compose: - `docker compose up --build -d` - -Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. +## Entwicklung +### Linter +#### Python `Black` +Automatisch formatieren: +``` +black project/backend/ . +``` + +`Black` installieren: +``` +python -m pip install black +``` -## Probleme beim Entwickeln +### Probleme beim Entwickeln Problem: Code hinzugefügt/geändert aber Änderungen werden nicht übernommen von Docker ``` From 7f7f042042b51fe7e1edb3851735fb5b9e41d9da Mon Sep 17 00:00:00 2001 From: GalacticCodeGambit <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Fri, 8 May 2026 15:21:19 +0200 Subject: [PATCH 14/76] Update Node.js version in Dockerfile-frontend to 24.1.0 --- project/Dockerfile-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/Dockerfile-frontend b/project/Dockerfile-frontend index fc2b0e3..b3ee918 100644 --- a/project/Dockerfile-frontend +++ b/project/Dockerfile-frontend @@ -1,4 +1,4 @@ -FROM node:24-alpine3.20 +FROM node:24.1.0-alpine3.20 WORKDIR /app # Copy package.json and package-lock.json From 64be75daf053de761c57e311535cf34f4b52f6a6 Mon Sep 17 00:00:00 2001 From: PrussianBaron Date: Sat, 9 May 2026 14:52:39 +0200 Subject: [PATCH 15/76] Tested the recipe algorithim --- project/backend/Database.py | 155 ++++++++++++++++-- .../backend/{Ingridient.py => Ingredient.py} | 2 +- project/backend/Recipe.py | 34 ++-- project/backend/RecipeSucuk.py | 48 ++++-- 4 files changed, 187 insertions(+), 52 deletions(-) rename project/backend/{Ingridient.py => Ingredient.py} (95%) diff --git a/project/backend/Database.py b/project/backend/Database.py index 8736849..ae1621b 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -3,6 +3,7 @@ from pathlib import Path import os + # Verwende die Datenbank aus dem data-Ordner DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3" @@ -61,7 +62,7 @@ def initDB(): """) cur.execute(""" - CREATE TABLE IF NOT EXISTS Ingridient ( + CREATE TABLE IF NOT EXISTS Ingredient ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, amountType VARCHAR(30) NOT NULL @@ -79,8 +80,8 @@ def initDB(): CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, - description nvarchar(MAX), - vid INTEGER NOT NULL, + description nvarchar(20000), + vid INTEGER, FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE ) """) @@ -90,7 +91,7 @@ def initDB(): zid INTEGER NOT NULL, rid INTEGER NOT NULL, amount DECIMAL(10,2) NOT NULL, - FOREIGN KEY (zid) REFERENCES Ingridient (id) ON DELETE CASCADE, + FOREIGN KEY (zid) REFERENCES Ingredient (id) ON DELETE CASCADE, FOREIGN KEY (rid) REFERENCES Recipe (id) ON DELETE CASCADE, UNIQUE (zid, rid) ) @@ -211,6 +212,33 @@ def cleanupExpiredTokens() -> None: with getDB() as con: cur = con.cursor() cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") + +def addRecipe(name: str, description: str, vid: int) -> bool: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Recipe (name, description) VALUES (?, ?)", + (name, description), + ) + return cur.lastrowid + +def addIngredientToRecipe(zid: int, rid: int, amount: float) -> bool: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Exists_from (zid, rid, amount) VALUES (?, ?, ?)", + (zid, rid, amount), + ) + +def addIngredient(name: str, amountType: str) -> bool: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Ingredient (name, amountType) VALUES (?, ?)", + (name, amountType), + ) + return cur.lastrowid + def getRecipe(recipeID: int) -> dict | None: """Gibt eine Liste aller Rezepte zurück.""" @@ -220,7 +248,7 @@ def getRecipe(recipeID: int) -> dict | None: SELECT name, description FROM Recipe WHERE id = ? - """, (recipeID,)) + """, (recipeID)) row = cur.fetchone() return dict(row) if row else None @@ -230,21 +258,22 @@ def getAllRecipes()-> list[dict]: cur.execute(""" SELECT id, name, description FROM Recipe - """) rows = cur.fetchall() return [dict(row) for row in rows] -def getAllIngridientsForRecipe(id: int) -> list[dict]: - """Gibt eine Liste aller Zutaten zurück.""" +def getAllIngredientsForRecipe(rid: int): with getDB() as con: cur = con.cursor() cur.execute(""" - SELECT id, name, Exists_from.amount - FROM Ingridient - Inner Join Exists_from id = zid - Inner Join Recipe rid = id - Where id = ? - """, (id,)) + SELECT + Ingredient.name, + Exists_from.amount, + Ingredient.amountType + FROM Ingredient + JOIN Exists_from ON Ingredient.id = Exists_from.zid + JOIN Recipe ON Exists_from.rid = Recipe.id + WHERE Exists_from.rid = ? + """, (rid,)) # Note the comma here! rows = cur.fetchall() return [dict(row) for row in rows] @@ -255,9 +284,103 @@ def getAllocatedRecipes(name: str)-> list[dict]: cur.execute(""" SELECT rid FROM Exists_from - Inner Join Ingridient on rid=id, - Where id = ? + Inner Join Ingredient on rid=id, + Where Exists_from.id = ? """, (name,)) rows = cur.fetchall() return [dict(row) for row in rows] +initDB() +""" +# --- 1. Fresh Pesto Pasta --- +r1 = addRecipe("Fresh Pesto Pasta", "Classic Italian basil pesto with linguine.", 301) +i1 = addIngredient("Linguine", "grams") +i2 = addIngredient("Fresh Basil", "grams") +i3 = addIngredient("Pine Nuts", "grams") +i4 = addIngredient("Parmesan", "grams") +addIngredientToRecipe(i1, r1, 200.0) +addIngredientToRecipe(i2, r1, 50.0) +addIngredientToRecipe(i3, r1, 30.0) +addIngredientToRecipe(i4, r1, 40.0) + +# --- 2. Beef Tacos --- +r2 = addRecipe("Beef Tacos", "Street-style seasoned beef tacos.", 302) +i5 = addIngredient("Corn Tortillas", "pieces") +i6 = addIngredient("Ground Beef", "grams") +i7 = addIngredient("Cumin", "teaspoons") +addIngredientToRecipe(i5, r2, 3.0) +addIngredientToRecipe(i6, r2, 150.0) +addIngredientToRecipe(i7, r2, 1.0) + +# --- 3. Greek Salad --- +r3 = addRecipe("Greek Salad", "Refreshing cucumber and feta salad.", 303) +i8 = addIngredient("Cucumber", "pieces") +i9 = addIngredient("Feta Cheese", "grams") +i10 = addIngredient("Kalamata Olives", "pieces") +addIngredientToRecipe(i8, r3, 1.0) +addIngredientToRecipe(i9, r3, 100.0) +addIngredientToRecipe(i10, r3, 10.0) + +# --- 4. Mushroom Risotto --- +r4 = addRecipe("Mushroom Risotto", "Creamy arborio rice with wild mushrooms.", 304) +i11 = addIngredient("Arborio Rice", "grams") +i12 = addIngredient("Mushrooms", "grams") +i13 = addIngredient("Vegetable Broth", "ml") +addIngredientToRecipe(i11, r4, 150.0) +addIngredientToRecipe(i12, r4, 200.0) +addIngredientToRecipe(i13, r4, 500.0) + +# --- 5. French Toast --- +r5 = addRecipe("French Toast", "Brioche soaked in cinnamon egg wash.", 305) +i14 = addIngredient("Brioche Bread", "slices") +i15 = addIngredient("Cinnamon", "teaspoons") +# Reusing 'Egg' and 'Milk' from previous datasets if they exist +addIngredientToRecipe(14, r5, 2.0) # Brioche +addIngredientToRecipe(2, r5, 2.0) # Egg (zid 2) +addIngredientToRecipe(15, r5, 0.5) # Cinnamon + +# --- 6. Margherita Pizza --- +r6 = addRecipe("Margherita Pizza", "Simple pizza with tomato, mozzarella, and basil.", 306) +i16 = addIngredient("Pizza Dough", "grams") +i17 = addIngredient("Mozzarella", "grams") +addIngredientToRecipe(i16, r6, 250.0) +addIngredientToRecipe(i17, r6, 120.0) +addIngredientToRecipe(i2, r6, 10.0) # Basil + +# --- 7. Salmon with Asparagus --- +r7 = addRecipe("Baked Salmon", "Lemon-butter salmon with roasted asparagus.", 307) +i18 = addIngredient("Salmon Fillet", "grams") +i19 = addIngredient("Asparagus", "grams") +i20 = addIngredient("Lemon", "pieces") +addIngredientToRecipe(i18, r7, 200.0) +addIngredientToRecipe(i19, r7, 150.0) +addIngredientToRecipe(i20, r7, 0.5) + +# --- 8. Chicken Stir-fry --- +r8 = addRecipe("Chicken Stir-fry", "Quick soy-ginger chicken and veggies.", 308) +i21 = addIngredient("Soy Sauce", "ml") +i22 = addIngredient("Ginger", "grams") +i23 = addIngredient("Broccoli", "grams") +addIngredientToRecipe(5, r8, 200.0) # Chicken Breast (zid 5) +addIngredientToRecipe(i21, r8, 30.0) +addIngredientToRecipe(i22, r8, 10.0) +addIngredientToRecipe(i23, r8, 100.0) + +# --- 9. Guacamole --- +r9 = addRecipe("Guacamole", "Chunky avocado dip with lime.", 309) +i24 = addIngredient("Avocado", "pieces") +i25 = addIngredient("Lime Juice", "ml") +addIngredientToRecipe(i24, r9, 3.0) +addIngredientToRecipe(i25, r9, 15.0) +addIngredientToRecipe(4, r9, 0.25) # Salt + +# --- 10. Chocolate Chip Cookies --- +r10 = addRecipe("Chocolate Cookies", "Chewy cookies with dark chocolate chips.", 310) +i26 = addIngredient("Chocolate Chips", "grams") +i27 = addIngredient("Vanilla Extract", "ml") +addIngredientToRecipe(1, r10, 250.0) # Flour +addIngredientToRecipe(14, r10, 150.0) # Butter +addIngredientToRecipe(11, r10, 100.0) # Sugar +addIngredientToRecipe(i26, r10, 100.0) +addIngredientToRecipe(i27, r10, 5.0) +""" \ No newline at end of file diff --git a/project/backend/Ingridient.py b/project/backend/Ingredient.py similarity index 95% rename from project/backend/Ingridient.py rename to project/backend/Ingredient.py index 232b9f3..c0ad485 100644 --- a/project/backend/Ingridient.py +++ b/project/backend/Ingredient.py @@ -1,7 +1,7 @@ from typing import List, Dict, Any -class Ingridient: +class Ingredient: def __init__(self, name: str, amount: float): self.__name = name self.__amount = amount diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index 788664c..14721e4 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -1,39 +1,39 @@ -from Database import Database -from Ingridient import Ingridient + +from Ingredient import Ingredient class Recipe: - def __init__(self, name: str, ingridients: list[Ingridient], description: str): + def __init__(self, name: str, Ingredients: list[Ingredient], description: str): self.__name = name - self.__Ingridients = ingridients + self.__Ingredients = Ingredients self.__description = description self.__original = "" self.__duration = "" self.__rating = 0.0 self.__countPersons = 1 self.__matching = 0 - self.__database = Database() + def saveInDB(self) -> bool: return True def getName(self) -> str: - return self.name + return self.__name def setName(self, name: str): - self.name = name + self.__name = name def getOriginal(self) -> str: - return self.original + return self.__original def setOriginal(self, original: str): - self.original = original + self.__original = original def getDescription(self) -> str: - return self.description + return self.__description def setDescription(self, description: str): - self.description = description + self.__description = description def getRating(self) -> float: return self.__rating @@ -48,14 +48,14 @@ def incrementMatching(self): self.__matching+=1 def getDuration(self) -> str: - return self.duration + return self.__duration def setDuration(self, duration: str): - self.duration = duration + self.__duration = duration - def getIngridients(self) -> list[Ingridient]: - return self.__ingridients + def getIngredients(self) -> list[Ingredient]: + return self.__Ingredients - def setIngridient(self, ingridients: list[Ingridient]): - self.__Ingridients = ingridients + def setIngredient(self, Ingredients: list[Ingredient]): + self.__Ingredients = Ingredients diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py index 25d1e0c..64aafc3 100644 --- a/project/backend/RecipeSucuk.py +++ b/project/backend/RecipeSucuk.py @@ -1,42 +1,54 @@ import Database -from Ingridient import Ingridient +from Ingredient import Ingredient from Recipe import Recipe -from Database import getAllRecipes, getAllIngridientsForRecipe +from Database import getAllRecipes, getAllIngredientsForRecipe -def findRecipes(ingriedents: list[Ingridient])-> list[Recipe]: +def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]: recipes = __initRecipes() - for ingridient in ingriedents: - recipes = __filterRecipes(recipes) + for Ingredient in ingriedents: + recipes = __filterRecipes(recipes, Ingredient) recipes.sort(key=lambda x: x.getRating(), reverse=True) for recipe in recipes[::-1]: - if recipe.getMatching < 3: + if recipe.getRating() < 0.3: recipes.remove(recipe) else: break return recipes -def __filterRecipes(recipes: list[Recipe], ingridient: Ingridient)-> list[Recipe]: +def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient)-> list[Recipe]: for recipe in recipes: - for recipeIngridient in recipe.getIngridients(): - if recipeIngridient.getName() == ingridient.getName(): + for recipeIngredient in recipe.getIngredients(): + if recipeIngredient.getName() == Ingredient.getName(): recipe.incrementMatching() - recipe.setRating(recipe.getMatching/ len(recipe.getIngridients)) + recipe.setRating(recipe.getMatching()/ len(recipe.getIngredients())) return recipes def __initRecipes()-> list[Recipe]: - recipesRaw = getAllRecipes + recipesRaw = getAllRecipes() recipes = [] for recipeRaw in recipesRaw: - recipe = Recipe(recipeRaw["name"], __formatIngridients(recipeRaw["id"]), recipeRaw["description"]) + recipe = Recipe(recipeRaw["name"], __formatIngredients(recipeRaw["id"]), recipeRaw["description"]) recipes.append(recipe) return recipes -def __formatIngridients(id: int)-> list[Ingridient]: - ingridientsRaw = getAllIngridientsForRecipe(id) - ingridients = [] - for ingridientRaw in ingridientsRaw: - ingridients.append(Ingridient(ingridientRaw["name"],ingridientRaw["amount"])) - return ingridients \ No newline at end of file +def __formatIngredients(id: int)-> list[Ingredient]: + IngredientsRaw = getAllIngredientsForRecipe(id) + Ingredients = [] + for IngredientRaw in IngredientsRaw: + Ingredients.append(Ingredient(IngredientRaw["name"],IngredientRaw["amount"])) + return Ingredients + +ini = [] +ini.append(Ingredient("Linguine", 10 )) +ini.append(Ingredient("Fresh Basil", 10 )) +ini.append(Ingredient("Pine Nuts", 10 )) +ini.append(Ingredient("Parmesan", 10 )) +ini.append(Ingredient("Ground Beef", 10 )) +ini.append(Ingredient("Cumin", 10 )) +ini.append(Ingredient("Cucumber", 10 )) +arr = findRecipes(ini) +for i in arr: + print(i.getName()+ " "+ str(i.getRating())) \ No newline at end of file From fb332478664ba1485c5c192df8bc48453d804b1b Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Sat, 9 May 2026 16:02:54 +0200 Subject: [PATCH 16/76] Feature: Fix problem with refresh token and include show Password --- project/backend/Models.py | 1 + project/backend/Routes.py | 2 +- project/frontend/app/components/fields.tsx | 50 ++++++++++++--- .../app/profile/changePasswordPopup.tsx | 62 +++++++++++++++---- project/frontend/lib/auth.tsx | 26 ++++---- 5 files changed, 106 insertions(+), 35 deletions(-) diff --git a/project/backend/Models.py b/project/backend/Models.py index 86638ac..5ccd306 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -3,6 +3,7 @@ class Token(BaseModel): access_token: str + refresh_token: str token_type: str class User(BaseModel): diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 1c9a077..570dda8 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -129,7 +129,7 @@ async def updateCurrentUser( # Passwort ändern if data.currentPassword and data.newPassword: if not verifyPassword(data.currentPassword, Account["hashedPassword"]): - raise HTTPException(status_code=401, detail="Falsches Passwort") + raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch") error = validatePassword(data.newPassword) if error: diff --git a/project/frontend/app/components/fields.tsx b/project/frontend/app/components/fields.tsx index 9e3fdae..52ad3a4 100644 --- a/project/frontend/app/components/fields.tsx +++ b/project/frontend/app/components/fields.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; import styles from "../homepage/page.module.css"; export default function Field({ label, type = "text", value, onChange, placeholder, onKeyDown, onBlur, state = "default" }: { @@ -7,6 +9,10 @@ export default function Field({ label, type = "text", value, onChange, placehold onBlur?: () => void; state?: "default" | "error" | "success"; }) { + const [showPassword, setShowPassword] = useState(false); + const isPassword = type === "password"; + const effectiveType = isPassword && showPassword ? "text" : type; + const inputClass = state === "error" ? styles.inputError : state === "success" ? styles.inputSuccess : @@ -15,15 +21,41 @@ export default function Field({ label, type = "text", value, onChange, placehold return (
- onChange(e.target.value)} - placeholder={placeholder} - onKeyDown={onKeyDown} - onBlur={onBlur} - /> +
+ onChange(e.target.value)} + placeholder={placeholder} + onKeyDown={onKeyDown} + onBlur={onBlur} + style={isPassword ? { paddingRight: 38 } : undefined} + /> + {isPassword && ( + + )} +
); } \ No newline at end of file diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx index 882669b..2e05d2c 100644 --- a/project/frontend/app/profile/changePasswordPopup.tsx +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -1,8 +1,50 @@ import {fetchWithAuth} from "@/lib/auth"; import {useState} from "react"; import {Button} from "@/app/components/ui/button"; +import {Eye, EyeOff} from "lucide-react"; import "../recipeFinder/style.css" +function PasswordInput({ value, onChange, placeholder }: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + className="popup__input" + style={{ width: "100%", paddingRight: 38 }} + /> + +
+ ); +} + const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; type Modus = "change" | "forgot"; @@ -63,7 +105,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps setConfirmPassword(""); onSuccess?.(); } catch { - setPasswordMsg("Unbekannter Fehler."); + setPasswordMsg('❌ Unbekannter Fehler'); } } @@ -75,29 +117,23 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
{!isForgot && ( - setCurrentPassword(e.target.value)} - className="popup__input" + onChange={setCurrentPassword} /> )} - setNewPassword(e.target.value)} - className="popup__input" + onChange={setNewPassword} /> - setConfirmPassword(e.target.value)} - className="popup__input" + onChange={setConfirmPassword} />
diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index cae660e..2f151dd 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -39,6 +39,9 @@ function getRefreshToken(): string | null { } function saveTokens(accessToken: string, refreshToken: string) { + if (!accessToken || !refreshToken) { + throw new Error("Tokens fehlen — Backend-Response unvollständig"); + } sessionStorage.setItem("access_token", accessToken); localStorage.setItem("refresh_token", refreshToken); } @@ -118,23 +121,22 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise Date: Sun, 10 May 2026 14:48:34 +0200 Subject: [PATCH 17/76] Feature: forgot password functionality works with redirection to homepage --- project/backend/Auth.py | 4 +- project/backend/Database.py | 23 +++++++++-- project/backend/EmailService.py | 40 +++++++++++++++++++ project/backend/LazyCookAdministration.py | 6 ++- project/backend/Routes.py | 30 ++++++++++---- project/compose.yaml | 1 + .../frontend/app/homepage/forgotPassword.tsx | 1 + .../frontend/app/profile/changeEmailPopup.tsx | 1 + .../app/profile/changePasswordPopup.tsx | 20 +++++----- project/frontend/app/reset-password/page.tsx | 2 + project/frontend/lib/auth.tsx | 3 +- 11 files changed, 106 insertions(+), 25 deletions(-) diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 1ffde5d..9e89313 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -140,9 +140,9 @@ def createPasswordResetToken(kontoId: int) -> str: def validatePasswordResetToken(token: str) -> dict | None: from Database import getPasswordResetToken entry = getPasswordResetToken(hashResetToken(token)) - if entry is None or entry["used_at"] is not None: + if entry is None or entry["usedAt"] is not None: return None - expiresAt = datetime.fromisoformat(entry["expires_at"]) + expiresAt = datetime.fromisoformat(entry["expiresAt"]) if expiresAt.tzinfo is None: expiresAt = expiresAt.replace(tzinfo=timezone.utc) if expiresAt < datetime.now(timezone.utc): diff --git a/project/backend/Database.py b/project/backend/Database.py index 4601e4c..173b898 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -226,7 +226,7 @@ def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None cur = con.cursor() # Alte, ungenutzte Tokens für dieses Konto invalidieren cur.execute( - "UPDATE PasswordResetToken SET used_at = CURRENT_TIMESTAMP " + "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP " "WHERE kontoID = ? AND usedAt IS NULL", (kontoID,), ) @@ -256,7 +256,7 @@ def markResetTokenUsed(tokenID: int) -> None: cur = con.cursor() cur.execute( "UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP WHERE id = ?", - (token_id,), + (tokenID,), ) @@ -264,6 +264,21 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None: with getDB() as con: cur = con.cursor() cur.execute( - "UPDATE Account SET hashed_password = ? WHERE id = ?", + "UPDATE Account SET hashedPassword = ? WHERE id = ?", (hashed_password, konto_id), - ) \ No newline at end of file + ) + + +def getAccountById(konto_id: int) -> dict | None: + """Gibt Account-Daten anhand der ID zurück, oder None.""" + con = getConnection() + try: + cur = con.cursor() + cur.execute( + "SELECT id, email, name, hashedPassword FROM Account WHERE id = ?", + (konto_id,), + ) + row = cur.fetchone() + return dict(row) if row else None + finally: + con.close() \ No newline at end of file diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py index caa8a0f..67506b5 100644 --- a/project/backend/EmailService.py +++ b/project/backend/EmailService.py @@ -28,6 +28,46 @@ def sendPasswordChangedEmail(to_email: str, name: str) -> None: server.login(gmailUser, gmailPassword) server.sendmail(gmailUser, to_email, msg.as_string()) + except Exception as e: + print(f"E-Mail Fehler: {e}") + raise + + +def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: + try: + msg = MIMEMultipart("alternative") + msg["Subject"] = "Passwort zurücksetzen – Lazy Cook" + msg["From"] = gmailUser + msg["To"] = to_email + + html = f""" +
+

Hallo {name},

+

du hast angefordert, dein Passwort bei Lazy Cook zurückzusetzen.

+

Klicke auf den Button, um ein neues Passwort festzulegen:

+

+ + Passwort zurücksetzen + +

+

+ Oder kopiere diesen Link in deinen Browser:
+ {resetLink} +

+

Der Link ist 30 Minuten gültig.

+

Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.

+
+

– Das Lazy Cook Team

+
+ """ + msg.attach(MIMEText(html, "html")) + + with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: + server.login(gmailUser, gmailPassword) + server.sendmail(gmailUser, to_email, msg.as_string()) + except Exception as e: print(f"E-Mail Fehler: {e}") raise \ No newline at end of file diff --git a/project/backend/LazyCookAdministration.py b/project/backend/LazyCookAdministration.py index 8f7df0d..9750ae0 100644 --- a/project/backend/LazyCookAdministration.py +++ b/project/backend/LazyCookAdministration.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from fastapi import FastAPI @@ -6,6 +7,9 @@ from Database import initDB from Routes import router +# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") + @asynccontextmanager async def lifespan(app: FastAPI): @@ -17,7 +21,7 @@ async def lifespan(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:8000"], + allow_origins=[FRONTEND_URL], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 570dda8..7f28ef7 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -1,7 +1,8 @@ +import os from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm -from EmailService import sendPasswordChangedEmail +from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail from pydantic import BaseModel as _BaseModel @@ -20,6 +21,7 @@ from Database import ( createAccount, getAccountByEmail, + getAccountById, deleteRefreshToken, deleteAllRefreshTokens, deleteAccount, @@ -32,6 +34,9 @@ from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser +# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) +FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") + router = APIRouter() @@ -84,7 +89,7 @@ async def refresh(body: RefreshRequest): deleteRefreshToken(body.refresh_token) # Account-Daten für neues Token-Paar zusammenstellen - Account = {"id": entry["konto_id"], "email": entry["email"]} + Account = {"id": entry["AccountID"], "email": entry["email"]} return createTokenPair(Account) @@ -157,9 +162,12 @@ async def forgotPassword(body: ForgotPasswordRequest): # Token nur erzeugen wenn Konto existiert – aber IMMER gleiche Antwort senden! if konto is not None: token = createPasswordResetToken(konto["id"]) - resetLink = f"http://localhost:8000/reset-password?token={token}" - # TODO: Mail versenden – siehe Hinweis unten - print(f"[DEV] Reset-Link für {body.email}: {resetLink}") + resetLink = f"{FRONTEND_URL}/reset-password?token={token}" + try: + sendPasswordResetEmail(body.email, konto["name"], resetLink) + except Exception as e: + # Mail-Fehler darf das Response nicht beeinflussen → User-Enumeration vermeiden + print(f"Reset-Mail konnte nicht gesendet werden: {e}") return {"detail": "Falls die E-Mail existiert, wurde ein Link versendet."} @@ -178,9 +186,17 @@ async def resetPassword(body: ResetPasswordRequest): detail="Link ungültig oder abgelaufen. Bitte neuen anfordern.", ) - updateKontoPassword(entry["konto_id"], hashPassword(body.new_password)) + updateKontoPassword(entry["kontoID"], hashPassword(body.new_password)) markResetTokenUsed(entry["id"]) # Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen - deleteAllRefreshTokens(entry["konto_id"]) + deleteAllRefreshTokens(entry["kontoID"]) + + # Bestätigungsmail an den Konto-Inhaber + konto = getAccountById(entry["kontoID"]) + if konto is not None: + try: + sendPasswordChangedEmail(konto["email"], konto["name"]) + except Exception as e: + print(f"Bestätigungsmail konnte nicht gesendet werden: {e}") return {"detail": "Passwort erfolgreich zurückgesetzt."} diff --git a/project/compose.yaml b/project/compose.yaml index 6bffc1c..c63814c 100644 --- a/project/compose.yaml +++ b/project/compose.yaml @@ -17,6 +17,7 @@ services: - PYTHONUNBUFFERED=1 - GMAIL_USER=${GMAIL_USER} - GMAIL_PASSWORD=${GMAIL_PASSWORD} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000} volumes: - ./data:/data ports: diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx index cd778d1..0186cac 100644 --- a/project/frontend/app/homepage/forgotPassword.tsx +++ b/project/frontend/app/homepage/forgotPassword.tsx @@ -57,6 +57,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => onChange={(v: string) => setEmail(v)} placeholder="Email" onBlur={() => setEmailBlurred(true)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} state={ emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default" } diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx index cd6045e..1d1dd3b 100644 --- a/project/frontend/app/profile/changeEmailPopup.tsx +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -39,6 +39,7 @@ export default function ChangeEmail (){ placeholder="Neue E-Mail-Adresse" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleEmailChange()} className="popup__input" /> diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx index 2e05d2c..7105c6b 100644 --- a/project/frontend/app/profile/changePasswordPopup.tsx +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -3,11 +3,15 @@ import {useState} from "react"; import {Button} from "@/app/components/ui/button"; import {Eye, EyeOff} from "lucide-react"; import "../recipeFinder/style.css" +import Field from "@/app/components/fields"; -function PasswordInput({ value, onChange, placeholder }: { +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function PasswordInput({ value, onChange, placeholder, onKeyDown }: { value: string; onChange: (v: string) => void; placeholder: string; + onKeyDown?: (e: React.KeyboardEvent) => void; }) { const [show, setShow] = useState(false); return ( @@ -17,6 +21,7 @@ function PasswordInput({ value, onChange, placeholder }: { placeholder={placeholder} value={value} onChange={(e) => onChange(e.target.value)} + onKeyDown={onKeyDown} className="popup__input" style={{ width: "100%", paddingRight: 38 }} /> @@ -45,8 +50,6 @@ function PasswordInput({ value, onChange, placeholder }: { ); } -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; - type Modus = "change" | "forgot"; interface ChangePasswordProps { @@ -60,6 +63,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [passwordMsg, setPasswordMsg] = useState(""); + const [pwBlurred, setPwBlurred] = useState(false); const isForgot = modus === "forgot"; @@ -117,23 +121,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps
{!isForgot && ( - - )} + setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)} + e.key === "Enter" && handlePasswordChange()} /> e.key === "Enter" && handlePasswordChange()} />
diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx index a56795c..aacc4d0 100644 --- a/project/frontend/app/reset-password/page.tsx +++ b/project/frontend/app/reset-password/page.tsx @@ -90,6 +90,7 @@ function ResetPasswordContent() { value={password} onChange={setPassword} placeholder="••••••••" + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} state={password && !pwValid ? "error" : "default"} /> e.key === "Enter" && handleSubmit()} state={confirm && !pwMatch ? "error" : pwMatch ? "success" : "default"} /> + )} + ); -} \ No newline at end of file +} diff --git a/project/frontend/app/global.css b/project/frontend/app/globals.css similarity index 99% rename from project/frontend/app/global.css rename to project/frontend/app/globals.css index fddc17d..83073d3 100644 --- a/project/frontend/app/global.css +++ b/project/frontend/app/globals.css @@ -40,7 +40,7 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); - --text-base: 10 px; + --text-base: 10px; --text-2xl: 30px; --text-lg: 20px; --text-xl:25px; diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx new file mode 100644 index 0000000..0186cac --- /dev/null +++ b/project/frontend/app/homepage/forgotPassword.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import Field from "@/app/components/fields"; +import styles from "./page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => void; onBack: () => void; }) { + const [email, setEmail] = useState(""); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [emailBlurred, setEmailBlurred] = useState(false); + + const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const handleSubmit = async () => { + setEmailBlurred(true); + if (!emailValid) return; + setBusy(true); + try { + await fetch(`${API_URL}/auth/forgot-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + setSubmitted(true); + } finally { + setBusy(false); + } + }; + + if (submitted) { + return ( +
+

E-Mail versendet

+

+ Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen + Link zum Zurücksetzen geschickt. Prüfe auch deinen Spam-Ordner. +

+ +
+ ); + } + + return ( +
+

Passwort vergessen

+

+ Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum + Zurücksetzen. +

+ setEmail(v)} + placeholder="Email" + onBlur={() => setEmailBlurred(true)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + state={ + emailBlurred && !emailValid ? "error" : emailValid ? "success" : "default" + } + /> + +

+ +

+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index 5c04f67..6ad3795 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -7,11 +7,12 @@ import {useCallback, useState} from "react"; import Modal from "@/app/components/modal"; import LoginForm from "@/app/homepage/signin"; import RegisterForm from "@/app/homepage/signup"; +import ForgotPasswordForm from "@/app/homepage/forgotPassword"; export default function Homepage() { - const [modal, setModal] = useState<"login" | "register" | null>(null); + const [modal, setModal] = useState<"login" | "register" | "forgot"| null>(null); const close = useCallback(() => setModal(null), []); @@ -191,12 +192,16 @@ export default function Homepage() { {/* ── Modals ──────────────────────────────────────────── */} - setModal("register")} /> + setModal("register")} onForgot={() => setModal("forgot")} /> setModal("login")} /> + + + setModal("login")} /> + ); } \ No newline at end of file diff --git a/project/frontend/app/homepage/signin.tsx b/project/frontend/app/homepage/signin.tsx index 1e40ca9..04df94a 100644 --- a/project/frontend/app/homepage/signin.tsx +++ b/project/frontend/app/homepage/signin.tsx @@ -5,7 +5,7 @@ import Field from "@/app/components/fields" import styles from "./page.module.css"; import {useRouter} from "next/navigation"; -export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; onSwitch: () => void }) { +export default function LoginForm({ onClose, onSwitch, onForgot}: { onClose: () => void; onSwitch: () => void; onForgot: () => void }) { const { login } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); @@ -47,6 +47,9 @@ export default function LoginForm({ onClose, onSwitch }: { onClose: () => void; {error &&

{error}

} { setEmail(v); if (emailBlurred) setEmailBlurred(false); }} placeholder="Email" onBlur={() => setEmailBlurred(true)} state={emailState()} /> e.key === "Enter" && handleSubmit()} onBlur={() => setPwBlurred(true)} state={pwBlurred && !password ? "error" : "default"} /> +

+ +

diff --git a/project/frontend/app/layout.tsx b/project/frontend/app/layout.tsx index 407b567..4f16d75 100644 --- a/project/frontend/app/layout.tsx +++ b/project/frontend/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import "./global.css"; +import "./globals.css"; import { AuthProvider } from "@/lib/auth"; const geistSans = Geist({ @@ -23,13 +23,13 @@ export default function RootLayout({children,}: Readonly<{ }>) { return ( - - - {children} - - + + + {children} + + ); } \ No newline at end of file diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx new file mode 100644 index 0000000..1d1dd3b --- /dev/null +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -0,0 +1,57 @@ +import {Button} from "@/app/components/ui/button"; +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; +import "../recipeFinder/style.css" + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; +export default function ChangeEmail (){ + + + const [newEmail, setNewEmail] = useState(""); + const [emailMsg, setEmailMsg] = useState(""); + + async function handleEmailChange() { + setEmailMsg(""); + try { + const res = await fetchWithAuth(`${API_URL}/users/me`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: newEmail }), + }); + if (!res.ok) { + const err = await res.json(); + setEmailMsg(`${err.detail}`); + return; + } + setEmailMsg("E-Mail erfolgreich geändert."); + setNewEmail(""); + } catch { + setEmailMsg("Unbekannter Fehler."); + } + } + + return ( +
+

E-Mail ändern

+
+ setNewEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleEmailChange()} + className="popup__input" + /> +
+ {emailMsg &&

{emailMsg}

} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx new file mode 100644 index 0000000..7105c6b --- /dev/null +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -0,0 +1,155 @@ +import {fetchWithAuth} from "@/lib/auth"; +import {useState} from "react"; +import {Button} from "@/app/components/ui/button"; +import {Eye, EyeOff} from "lucide-react"; +import "../recipeFinder/style.css" +import Field from "@/app/components/fields"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function PasswordInput({ value, onChange, placeholder, onKeyDown }: { + value: string; + onChange: (v: string) => void; + placeholder: string; + onKeyDown?: (e: React.KeyboardEvent) => void; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + onKeyDown={onKeyDown} + className="popup__input" + style={{ width: "100%", paddingRight: 38 }} + /> + +
+ ); +} + +type Modus = "change" | "forgot"; + +interface ChangePasswordProps { + modus: Modus; + onSuccess?: () => void; +} + +export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps) { + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordMsg, setPasswordMsg] = useState(""); + const [pwBlurred, setPwBlurred] = useState(false); + + const isForgot = modus === "forgot"; + + async function handlePasswordChange() { + setPasswordMsg(""); + + // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern) + if (newPassword !== confirmPassword) { + setPasswordMsg("Die Passwörter stimmen nicht überein."); + return; + } + + try { + const endpoint = isForgot + ? `${API_URL}/users/forgot-password` + : `${API_URL}/users/me`; + + const body = isForgot + ? { newPassword } + : { currentPassword, newPassword }; + + const fetcher = isForgot ? fetch : fetchWithAuth; + + const res = await fetcher(endpoint, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.json(); + setPasswordMsg(`❌ ${err.detail}`); + return; + } + + setPasswordMsg( + isForgot + ? "Passwort erfolgreich zurückgesetzt." + : "Passwort erfolgreich geändert." + ); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + onSuccess?.(); + } catch { + setPasswordMsg('❌ Unbekannter Fehler'); + } + } + + return ( +
+

+ {isForgot ? "Passwort zurücksetzen" : "Passwort ändern"} +

+ +
+ {!isForgot && ( + setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)} + + + e.key === "Enter" && handlePasswordChange()} + /> + + e.key === "Enter" && handlePasswordChange()} + /> +
+ + {passwordMsg &&

{passwordMsg}

} + +
+ +
+
+ + ); +} \ No newline at end of file diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index 1f5a269..d4ac84f 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -1,76 +1,45 @@ "use client"; -import { useRef, useState} from "react"; +import {useEffect, useRef, useState} from "react"; import { useAuth, fetchWithAuth } from "@/lib/auth"; import { useRouter } from "next/navigation"; import {ChefHat} from "lucide-react"; import { Button } from '../components/ui/button'; import ProfileDropdown from "@/app/components/profile_dropdown"; +import Modal from "@/app/components/modal"; +import ChangeEmail from "@/app/profile/changeEmailPopup"; +import ChangePassword from "@/app/profile/changePasswordPopup"; + +import "../recipeFinder/style.css" const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { - - const menuRef = useRef(null); - const { user, logout } = useAuth(); + const { user, loading, logout } = useAuth(); const router = useRouter(); + const menuRef = useRef(null); + const [showConfirm, setShowConfirm] = useState(false); const [showEmailModal, setShowEmailModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); - // E-Mail ändern - const [newEmail, setNewEmail] = useState(""); - const [emailMsg, setEmailMsg] = useState(""); - - // Passwort ändern - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [passwordMsg, setPasswordMsg] = useState(""); - - if (!user) return null; - - async function handleEmailChange() { - setEmailMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: newEmail }), - }); - if (!res.ok) { - const err = await res.json(); - setEmailMsg(`${err.detail}`); - return; - } - setEmailMsg("E-Mail erfolgreich geändert."); - setNewEmail(""); - } catch { - setEmailMsg("Unbekannter Fehler."); + useEffect(() => { + if (!loading && !user) { + router.replace("/"); } - } + }, [loading, user, router]); - async function handlePasswordChange() { - setPasswordMsg(""); - try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ currentPassword, newPassword }), - }); - if (!res.ok) { - const err = await res.json(); - setPasswordMsg(`❌ ${err.detail}`); - return; - } - setPasswordMsg("Passwort erfolgreich geändert."); - setCurrentPassword(""); - setNewPassword(""); - } catch { - setPasswordMsg("Unbekannter Fehler."); - } + if (loading) { + return ( +
+

Laden…

+
+ ); } + if (!user) return null; + async function handleAccountDeletion() { const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); @@ -111,13 +80,13 @@ export default function Profile() {

Einstellungen

- {/* E-Mail Modal */} - {showEmailModal && ( -
setShowEmailModal(false)} - > -
e.stopPropagation()} - > -

E-Mail ändern

- setNewEmail(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {emailMsg &&

{emailMsg}

} -
- - -
-
-
- )} + setShowConfirm(false)}> +
+

+ Konto wirklich löschen? +

+

+ + +

- {/* Passwort Modal */} - {showPasswordModal && ( -
setShowPasswordModal(false)} - > -
e.stopPropagation()} - > -

Passwort ändern

- setCurrentPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - setNewPassword(e.target.value)} - className="border rounded-lg px-3 py-2 text-sm w-full" - /> - {passwordMsg &&

{passwordMsg}

} -
- - -
-
- )} + + + setShowEmailModal(false)}> + + + + setShowPasswordModal(false)}> + +
); } diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css index fdc8996..f32da98 100644 --- a/project/frontend/app/recipeFinder/style.css +++ b/project/frontend/app/recipeFinder/style.css @@ -94,6 +94,10 @@ gap: 10px; } +.popup__fields--stacked { + flex-direction: column; +} + .popup__input { flex: 1; padding: 10px 12px; diff --git a/project/frontend/app/reset-password/page.tsx b/project/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..aacc4d0 --- /dev/null +++ b/project/frontend/app/reset-password/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import React, { Suspense, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Field from "@/app/components/fields"; +import styles from "@/app/homepage/page.module.css"; + +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; + +function ResetPasswordContent() { + const params = useSearchParams(); + const router = useRouter(); + const token = params.get("token") ?? ""; + + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + + const pwValid = + password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /[0-9]/.test(password) && + /[^A-Za-z0-9]/.test(password); + const pwMatch = password === confirm && confirm.length > 0; + + const handleSubmit = async () => { + setError(""); + if (!pwValid) { + setError("Passwort erfüllt nicht alle Anforderungen."); + return; + } + if (!pwMatch) { + setError("Passwörter stimmen nicht überein."); + return; + } + setBusy(true); + try { + const res = await fetch(`${API_URL}/auth/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, new_password: password }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.detail ?? "Fehler beim Zurücksetzen."); + return; + } + setDone(true); + setTimeout(() => router.push("/"), 2500); + } catch { + setError("Verbindungsfehler."); + } finally { + setBusy(false); + } + }; + + if (!token) { + return ( +
+

Ungültiger Link

+

+ Der Link ist unvollständig. Bitte fordere einen neuen an. +

+
+ ); + } + + if (done) { + return ( +
+

Erledigt!

+

+ Dein Passwort wurde zurückgesetzt. Du wirst gleich weitergeleitet… +

+
+ ); + } + + return ( +
+

Neues Passwort festlegen

+

Wähle ein sicheres neues Passwort.

+ {error &&

{error}

} + e.key === "Enter" && handleSubmit()} + state={password && !pwValid ? "error" : "default"} + /> + e.key === "Enter" && handleSubmit()} + state={confirm && !pwMatch ? "error" : pwMatch ? "success" : "default"} + /> + +
+ ); +} + +export default function ResetPasswordPage() { + return ( + +

Wird geladen…

+ + } + > + +
+ ); +} \ No newline at end of file diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index cae660e..2d6c1ca 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -6,11 +6,10 @@ import { useState, useEffect, useCallback, - useRef, type ReactNode, } from "react"; -const API_URL = "http://localhost:3000"; +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; // ── Typen ───────────────────────────────────────────────────── export interface User { @@ -39,6 +38,9 @@ function getRefreshToken(): string | null { } function saveTokens(accessToken: string, refreshToken: string) { + if (!accessToken || !refreshToken) { + throw new Error("Tokens fehlen — Backend-Response unvollständig"); + } sessionStorage.setItem("access_token", accessToken); localStorage.setItem("refresh_token", refreshToken); } @@ -118,23 +120,22 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise Date: Mon, 11 May 2026 07:34:28 +0200 Subject: [PATCH 27/76] Feat: show top 5 incredients as options (#152) * Feature: Forgot Password link in signin modal. Popup for Email Input. Konto deletion reactivation. * Feature: Email and Password change popup width changed * Feature: Fix problem with refresh token and include show Password * Feature: forgot password functionality works with redirection to homepage * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix: Copilot review suggestion * Fix: Copilot review suggestion * Fix: Copilot review suggestion * Fix: Copilot review suggestion * Feat: Ingredients suggestions. Top 5 most typed shown in popup * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Feat: Ingredients suggestions. Top 5 most typed shown in popup --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- project/backend/Database.py | 65 +++++++++++++++- project/backend/Models.py | 13 +++- project/backend/Routes.py | 65 +++++++++++++++- .../frontend/app/homepage/forgotPassword.tsx | 17 +++- project/frontend/app/homepage/signin.tsx | 14 +++- .../app/profile/changePasswordPopup.tsx | 22 +++++- project/frontend/app/profile/page.tsx | 5 +- project/frontend/app/recipeFinder/page.tsx | 77 ++++++++++++++++++- project/frontend/app/recipeFinder/popup.tsx | 52 ++++++++++++- project/frontend/app/recipeFinder/style.css | 56 ++++++++++++++ project/frontend/lib/auth.tsx | 3 +- 11 files changed, 369 insertions(+), 20 deletions(-) diff --git a/project/backend/Database.py b/project/backend/Database.py index 2da1bb3..a20ad40 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -9,8 +9,8 @@ def getConnection() -> sqlite3.Connection: """Erstellt eine neue SQLite-Connection mit Row-Factory.""" con = sqlite3.connect(str(DB_PATH), check_same_thread=False) con.row_factory = sqlite3.Row - con.execute("PRAGMA foreign_keys = ON") con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA foreign_keys = ON") return con @@ -99,6 +99,19 @@ def initDB(): UNIQUE (AccountID, rid) ) """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS IngredientUsage ( + AccountID INTEGER NOT NULL, + name TEXT NOT NULL, + displayName TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + lastUnit TEXT, + lastUsedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (AccountID, name), + FOREIGN KEY (AccountID) REFERENCES Account (id) ON DELETE CASCADE + ) + """) + cur.execute(""" CREATE TABLE IF NOT EXISTS PasswordResetToken ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -269,6 +282,56 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None: ) +# ── Ingredient-Usage-Operationen ─────────────────────────────── + +def incrementIngredientUsage(AccountID: int, name: str, unit: str | None) -> None: + """Erhöht den Usage-Counter für eine Zutat um 1, aktualisiert lastUnit und lastUsedAt. + Legt einen neuen Eintrag an, falls die Zutat für diesen Account noch nicht existiert. + Der Name wird normalisiert (trim + lowercase) für den Primary Key, displayName behält + die Originalschreibweise der letzten Eingabe. + """ + displayName = (name or "").strip() + if not displayName: + return + normalizedName = displayName.lower() + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + INSERT INTO IngredientUsage (AccountID, name, displayName, count, lastUnit, lastUsedAt) + VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(AccountID, name) DO UPDATE SET + count = count + 1, + displayName = excluded.displayName, + lastUnit = excluded.lastUnit, + lastUsedAt = CURRENT_TIMESTAMP + """, + (AccountID, normalizedName, displayName, unit), + ) + + +def getTopIngredients(AccountID: int, limit: int = 5) -> list[dict]: + """Liefert die meistgenutzten Zutaten des Users. + Sortierung Hybrid: erst nach count DESC, dann nach lastUsedAt DESC als Tie-Breaker. + """ + con = getConnection() + try: + cur = con.cursor() + cur.execute( + """ + SELECT displayName, lastUnit, count, lastUsedAt + FROM IngredientUsage + WHERE AccountID = ? + ORDER BY count DESC, lastUsedAt DESC + LIMIT ? + """, + (AccountID, limit), + ) + return [dict(row) for row in cur.fetchall()] + finally: + con.close() + + def getAccountById(konto_id: int) -> dict | None: """Gibt Account-Daten anhand der ID zurück, oder None.""" con = getConnection() diff --git a/project/backend/Models.py b/project/backend/Models.py index 492d829..12d4943 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -36,4 +36,15 @@ class ResetPasswordRequest(BaseModel): class UpdateUser(BaseModel): email: str | None = None currentPassword: str | None = None - newPassword: str | None = None \ No newline at end of file + newPassword: str | None = None + + +class IngredientSearch(BaseModel): + name: str + amount: float + unit: str + + +class RecipeSearchRequest(BaseModel): + zutaten: list[IngredientSearch] + servings: int \ No newline at end of file diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 924071d..e2107e7 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -1,6 +1,6 @@ import os from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Response from fastapi.security import OAuth2PasswordRequestForm from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail @@ -26,9 +26,11 @@ updateAccount, markResetTokenUsed, updateKontoPassword, + incrementIngredientUsage, + getTopIngredients, ) -from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser +from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser, RecipeSearchRequest # Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") @@ -196,3 +198,62 @@ async def resetPassword(body: ResetPasswordRequest): print(f"Bestätigungsmail konnte nicht gesendet werden: {e}") return {"detail": "Passwort erfolgreich zurückgesetzt."} + + +# ── Recipe-Suche ─────────────────────────────────────────────── + +@router.post("/recipes/search") +async def searchRecipes( + body: RecipeSearchRequest, + currentUser: Annotated[User, Depends(getCurrentUser)], +): + """Sucht Rezepte basierend auf den übergebenen Zutaten. + + Hinweis: Aktuell nur Usage-Tracking implementiert – die eigentliche + Rezept-Suche folgt. Jede gesuchte Zutat erhöht den persönlichen Counter + des Users (IngredientUsage), damit später die Top-5-Vorschläge daraus + abgeleitet werden können. + """ + Account = getAccountByEmail(currentUser.email) + if Account is None: + raise HTTPException(status_code=404, detail="Account nicht gefunden") + + # Usage-Tracking: jede gesuchte Zutat hochzählen (Hybrid: count + lastUsedAt + lastUnit) + for zutat in body.zutaten: + incrementIngredientUsage(Account["id"], zutat.name, zutat.unit) + + # Aktualisierte Top 5 direkt mit zurückgeben → Frontend muss keinen Extra-Request machen + topRows = getTopIngredients(Account["id"], limit=5) + topIngredients = [ + {"name": r["displayName"], "unit": r["lastUnit"]} + for r in topRows + ] + + # TODO: eigentliche Rezept-Suche implementieren + return {"rezepte": [], "topIngredients": topIngredients} + + +# ── Ingredient-Vorschläge ────────────────────────────────────── + +@router.get("/ingredients/top") +async def getTopIngredientsForUser( + response: Response, + currentUser: Annotated[User, Depends(getCurrentUser)], + limit: int = 5, +): + """Liefert die meistgenutzten Zutaten des aktuellen Users für die Vorschlags-Badges.""" + Account = getAccountByEmail(currentUser.email) + if Account is None: + raise HTTPException(status_code=404, detail="Account nicht gefunden") + + # Caching unterbinden – die Liste ändert sich mit jeder Suche + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + + rows = getTopIngredients(Account["id"], limit=limit) + return { + "ingredients": [ + {"name": r["displayName"], "unit": r["lastUnit"]} + for r in rows + ] + } diff --git a/project/frontend/app/homepage/forgotPassword.tsx b/project/frontend/app/homepage/forgotPassword.tsx index 0186cac..cd2ec07 100644 --- a/project/frontend/app/homepage/forgotPassword.tsx +++ b/project/frontend/app/homepage/forgotPassword.tsx @@ -9,20 +9,34 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => const [busy, setBusy] = useState(false); const [submitted, setSubmitted] = useState(false); const [emailBlurred, setEmailBlurred] = useState(false); + const [error, setError] = useState(""); const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const handleSubmit = async () => { setEmailBlurred(true); + setError(""); if (!emailValid) return; setBusy(true); try { - await fetch(`${API_URL}/auth/forgot-password`, { + const res = await fetch(`${API_URL}/auth/forgot-password`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); + if (!res.ok) { + const data = await res.json().catch(() => null); + setError(data?.detail ?? `Fehler beim Anfordern (Status ${res.status}).`); + return; + } setSubmitted(true); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + setError( + msg.includes("Failed to fetch") || msg === "Network Error" + ? "Backend nicht erreichbar." + : (msg || "Unbekannter Fehler.") + ); } finally { setBusy(false); } @@ -50,6 +64,7 @@ export default function ForgotPasswordForm({ onClose, onBack,}: { onClose: () => Gib deine E-Mail-Adresse ein. Wir schicken dir einen Link zum Zurücksetzen.

+ {error &&

{error}

} void; placeholder: string; onKeyDown?: (e: React.KeyboardEvent) => void; + ariaLabel?: string; }) { const [show, setShow] = useState(false); return ( @@ -19,6 +20,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: { onChange(e.target.value)} onKeyDown={onKeyDown} @@ -29,7 +31,7 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown }: { type="button" onClick={() => setShow((s) => !s)} aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"} - tabIndex={-1} + aria-pressed={show} style={{ position: "absolute", right: 10, @@ -70,7 +72,21 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps async function handlePasswordChange() { setPasswordMsg(""); - // Bestätigung prüfen (nur im forgot-Modus relevant, aber sinnvoll auch beim Ändern) + // Pflichtfelder im "change"-Modus + if (!isForgot && !currentPassword) { + setPasswordMsg("Bitte das aktuelle Passwort eingeben."); + return; + } + if (!newPassword) { + setPasswordMsg("Bitte ein neues Passwort eingeben."); + return; + } + if (!confirmPassword) { + setPasswordMsg("Bitte das neue Passwort bestätigen."); + return; + } + + // Bestätigung prüfen if (newPassword !== confirmPassword) { setPasswordMsg("Die Passwörter stimmen nicht überein."); return; diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index d4ac84f..a6be68f 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -1,6 +1,6 @@ "use client"; -import {useEffect, useRef, useState} from "react"; +import {useEffect, useState} from "react"; import { useAuth, fetchWithAuth } from "@/lib/auth"; import { useRouter } from "next/navigation"; import {ChefHat} from "lucide-react"; @@ -17,7 +17,6 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { const { user, loading, logout } = useAuth(); const router = useRouter(); - const menuRef = useRef(null); const [showConfirm, setShowConfirm] = useState(false); @@ -94,7 +93,7 @@ export default function Profile() { diff --git a/project/frontend/app/recipeFinder/page.tsx b/project/frontend/app/recipeFinder/page.tsx index a0a276b..458c09f 100644 --- a/project/frontend/app/recipeFinder/page.tsx +++ b/project/frontend/app/recipeFinder/page.tsx @@ -3,7 +3,7 @@ import {useEffect, useRef, useState} from "react"; import { useRouter } from "next/navigation"; import {fetchWithAuth, useAuth} from "@/lib/auth"; -import {ChefHat, LogOut, X, User, UserCircle, Search, Plus} from "lucide-react"; +import {ChefHat, X, Search, Plus} from "lucide-react"; import "./style.css" import {Button} from "@/app/components/ui/button"; import Modal from "@/app/components/modal"; @@ -18,8 +18,33 @@ interface IngredientInput { unit: string; } +interface Suggestion { + name: string; + unit: string | null; +} + +// Reine Helper-Funktion auf Modulebene – schließt keinen Component-State ein, +// damit useEffect/useCallback keine ändernden Closure-Variablen aufnehmen müssen. +async function loadTopIngredients(): Promise { + try { + const res = await fetchWithAuth(`/ingredients/top?limit=5&_=${Date.now()}`, { + cache: "no-store", + headers: { "Cache-Control": "no-cache" }, + }); + if (!res.ok) return null; + const data = await res.json(); + if (Array.isArray(data.ingredients)) { + localStorage.setItem("ingredientSuggestions", JSON.stringify(data.ingredients)); + return data.ingredients as Suggestion[]; + } + return null; + } catch { + return null; + } +} + export default function RecipeFinder() { - const { user, loading, logout } = useAuth(); + const { user, loading } = useAuth(); const router = useRouter(); const [open, setOpen] = useState(false); @@ -47,12 +72,43 @@ export default function RecipeFinder() { const [modalOpen, setModalOpen] = useState(false); + // Vorschläge: localStorage als sofortiger Initialwert (instant beim Öffnen), + // im Hintergrund per useEffect aktualisiert. + const [suggestions, setSuggestions] = useState(() => { + try { + const saved = localStorage.getItem("ingredientSuggestions"); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + useEffect(() => { if (!loading && !user) { router.replace("/"); } }, [loading, user, router]); + // Initial nach dem Login laden (damit sie beim ersten Popup-Öffnen sofort da sind) + useEffect(() => { + if (!user) return; + let cancelled = false; + loadTopIngredients().then(result => { + if (!cancelled && result) setSuggestions(result); + }); + return () => { cancelled = true; }; + }, [user]); + + // Bei jedem Öffnen des Popups erneut laden – aktuelle Daten nach jeder Suche + useEffect(() => { + if (!modalOpen || !user) return; + let cancelled = false; + loadTopIngredients().then(result => { + if (!cancelled && result) setSuggestions(result); + }); + return () => { cancelled = true; }; + }, [modalOpen, user]); + useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { @@ -115,6 +171,12 @@ export default function RecipeFinder() { }); const data = await res.json(); setResults(data.rezepte ?? []); + // Aktualisierte Top 5 direkt aus der Search-Response übernehmen – + // beim nächsten Popup-Öffnen sind die Vorschläge sofort aktuell, ohne extra Roundtrip + if (Array.isArray(data.topIngredients)) { + setSuggestions(data.topIngredients); + localStorage.setItem("ingredientSuggestions", JSON.stringify(data.topIngredients)); + } } catch { setSearchError("Suche fehlgeschlagen."); } finally { @@ -150,7 +212,15 @@ export default function RecipeFinder() {

Zutaten

- @@ -240,6 +310,7 @@ export default function RecipeFinder() { onAdd={handleAdd} servings={servings} onServingsChange={setServings} + suggestions={suggestions} />
diff --git a/project/frontend/app/recipeFinder/popup.tsx b/project/frontend/app/recipeFinder/popup.tsx index ec2f81b..444545b 100644 --- a/project/frontend/app/recipeFinder/popup.tsx +++ b/project/frontend/app/recipeFinder/popup.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import "./style.css"; const EINHEITEN = ["Stück", "g", "kg", "ml", "l", "EL", "TL", "Prise"]; @@ -9,18 +9,25 @@ export interface IngredientInput { unit: string; } +export interface Suggestion { + name: string; + unit: string | null; +} + interface Props { ingredients: IngredientInput[]; onAdd: (ingredient: IngredientInput) => void; servings: number; onServingsChange: (s: number) => void; + suggestions?: Suggestion[]; } -export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { +export default function AddIngredientsPopup({ ingredients, onAdd, suggestions = [] }: Props) { const [ingredientName, setIngredientName] = useState(""); const [ingredientAmount, setIngredientAmount] = useState(""); const [ingredientUnit, setIngredientUnit] = useState("Stück"); const [inputError, setInputError] = useState(""); + const amountInputRef = useRef(null); const handleAddIngredient = () => { const trimmedName = ingredientName.trim(); @@ -40,6 +47,21 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { setInputError(""); }; + const handleSuggestionClick = (s: Suggestion) => { + setIngredientName(s.name); + if (s.unit && EINHEITEN.includes(s.unit)) { + setIngredientUnit(s.unit); + } + setInputError(""); + // Fokus aufs Mengen-Feld – User muss nur noch die Menge tippen + setTimeout(() => amountInputRef.current?.focus(), 0); + }; + + // Vorschläge bleiben sichtbar – bereits hinzugefügte werden disabled markiert, + // damit das Popup seine Größe behält. + const isAlreadyAdded = (s: Suggestion) => + ingredients.some(z => z.name.toLowerCase() === s.name.toLowerCase()); + return (

Zutaten Hinzufügen

@@ -54,6 +76,7 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) { className="popup__input" /> setIngredientAmount(e.target.value)} @@ -71,6 +94,29 @@ export default function AddIngredientsPopup({ ingredients, onAdd}: Props) {
+ {suggestions.length > 0 && ( +
+

Häufig verwendet

+
+ {suggestions.map(s => { + const added = isAlreadyAdded(s); + return ( + + ); + })} +
+
+ )} + {inputError &&

{inputError}

}
); -} \ No newline at end of file +} diff --git a/project/frontend/app/recipeFinder/style.css b/project/frontend/app/recipeFinder/style.css index f32da98..63041c9 100644 --- a/project/frontend/app/recipeFinder/style.css +++ b/project/frontend/app/recipeFinder/style.css @@ -147,6 +147,62 @@ margin: 0; } +/* ── Zutaten-Vorschläge (Top 5 Badges) ─────────────────────── */ +.popup__suggestions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.popup__suggestions-label { + font-size: 12px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + font-family: system-ui; +} + +.popup__suggestions-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.popup__suggestion-badge { + padding: 5px 12px; + background: #f3f3f5; + border: 1px solid #e5e7eb; + border-radius: 999px; + font-size: 13px; + font-family: system-ui; + color: #111; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.popup__suggestion-badge:hover { + background: #030213; + border-color: #030213; + color: #fff; +} + +.popup__suggestion-badge:focus-visible { + outline: 2px solid #030213; + outline-offset: 2px; +} + +.popup__suggestion-badge--added, +.popup__suggestion-badge--added:hover { + background: #f3f3f5; + border-color: #e5e7eb; + color: #9ca3af; + cursor: default; + text-decoration: line-through; + opacity: 0.6; +} + .popup__btn { display: block; margin: 0 auto; diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 2d6c1ca..64c79d9 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -49,6 +49,7 @@ function clearTokens() { sessionStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); localStorage.removeItem("ingredients"); + localStorage.removeItem("ingredientSuggestions"); } // ── API-Aufrufe ─────────────────────────────────────────────── @@ -107,7 +108,7 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise Date: Mon, 11 May 2026 13:33:16 +0200 Subject: [PATCH 28/76] Black used --- project/backend/Auth.py | 10 ++++-- project/backend/Database.py | 57 +++++++++++++++++++++++----------- project/backend/Ingredient.py | 9 +++--- project/backend/Models.py | 5 ++- project/backend/Recipe.py | 7 ++--- project/backend/RecipeSucuk.py | 45 ++++++++++++++++----------- project/backend/Routes.py | 36 ++++++++++++--------- 7 files changed, 108 insertions(+), 61 deletions(-) diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 696fce6..2447716 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -136,8 +136,10 @@ async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User: return User(email=konto["email"], name=konto["name"]) + # --- Reset Password --- # + def hashResetToken(token: str) -> str: """SHA-256 Hash – für Reset-Tokens reicht das, sie sind ohnehin hochentropisch.""" return hashlib.sha256(token.encode()).hexdigest() @@ -146,14 +148,18 @@ def hashResetToken(token: str) -> str: def createPasswordResetToken(kontoId: int) -> str: """Generiert Klartext-Token (geht per Mail) und speichert nur den Hash in der DB.""" from Database import savePasswordResetToken + token = secrets.token_urlsafe(48) - expiresAt = datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_EXPIRE_MINUTES) + expiresAt = datetime.now(timezone.utc) + timedelta( + minutes=PASSWORD_RESET_EXPIRE_MINUTES + ) savePasswordResetToken(kontoId, hashResetToken(token), expiresAt.isoformat()) return token def validatePasswordResetToken(token: str) -> dict | None: from Database import getPasswordResetToken + entry = getPasswordResetToken(hashResetToken(token)) if entry is None or entry["usedAt"] is not None: return None @@ -162,4 +168,4 @@ def validatePasswordResetToken(token: str) -> dict | None: expiresAt = expiresAt.replace(tzinfo=timezone.utc) if expiresAt < datetime.now(timezone.utc): return None - return entry \ No newline at end of file + return entry diff --git a/project/backend/Database.py b/project/backend/Database.py index 9ad9c44..580ad41 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -3,7 +3,6 @@ from pathlib import Path import os - # Verwende die Datenbank aus dem data-Ordner DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3" @@ -236,9 +235,10 @@ def cleanupExpiredTokens() -> None: with getDB() as con: cur = con.cursor() cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')") - + + def addRecipe(name: str, description: str, vid: int) -> int: - with getDB() as con: + with getDB() as con: cur = con.cursor() cur.execute( "INSERT INTO Recipe (name, description) VALUES (?, ?)", @@ -246,6 +246,7 @@ def addRecipe(name: str, description: str, vid: int) -> int: ) return cur.lastrowid + def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: with getDB() as con: cur = con.cursor() @@ -253,7 +254,8 @@ def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: "INSERT INTO Exists_from (zid, rid, amount) VALUES (?, ?, ?)", (zid, rid, amount), ) - + + def addIngredient(name: str, amountType: str) -> int: with getDB() as con: cur = con.cursor() @@ -262,31 +264,40 @@ def addIngredient(name: str, amountType: str) -> int: (name, amountType), ) return cur.lastrowid - + + def getIngridientByName(name: str): - with getDB() as con: + with getDB() as con: cur = con.cursor() - cur.execute(""" + cur.execute( + """ SELECT id, amountType FROM Recipe WHERE id = ? - """, (name)) + """, + (name), + ) row = cur.fetchone() return dict(row) if row else None + def getRecipe(recipeID: int) -> dict | None: """Gibt eine Liste aller Rezepte zurück.""" with getDB() as con: cur = con.cursor() - cur.execute(""" + cur.execute( + """ SELECT name, description FROM Recipe WHERE id = ? - """, (recipeID)) + """, + (recipeID), + ) row = cur.fetchone() return dict(row) if row else None -def getAllRecipes()-> list[dict]: + +def getAllRecipes() -> list[dict]: with getDB() as con: cur = con.cursor() cur.execute(""" @@ -295,10 +306,13 @@ def getAllRecipes()-> list[dict]: """) rows = cur.fetchall() return [dict(row) for row in rows] + + def getAllIngredientsForRecipe(rid: int): with getDB() as con: cur = con.cursor() - cur.execute(""" + cur.execute( + """ SELECT Ingredient.name, Exists_from.amount, @@ -307,20 +321,26 @@ def getAllIngredientsForRecipe(rid: int): JOIN Exists_from ON Ingredient.id = Exists_from.zid JOIN Recipe ON Exists_from.rid = Recipe.id WHERE Exists_from.rid = ? - """, (rid,)) # Note the comma here! + """, + (rid,), + ) # Note the comma here! rows = cur.fetchall() return [dict(row) for row in rows] -def getAllocatedRecipes(name: str)-> list[dict]: + +def getAllocatedRecipes(name: str) -> list[dict]: with getDB() as con: cur = con.cursor() - cur.execute(""" + cur.execute( + """ SELECT rid FROM Exists_from Inner Join Ingredient on rid=id, Where Exists_from.id = ? - """, (name,)) + """, + (name,), + ) rows = cur.fetchall() return [dict(row) for row in rows] @@ -420,9 +440,9 @@ def getAllocatedRecipes(name: str)-> list[dict]: """ - # ── Password-Reset-Token-Operationen ─────────────────────────── + def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None: """Invalidiert alte Tokens des Kontos und speichert einen neuen.""" with getDB() as con: @@ -474,6 +494,7 @@ def updateKontoPassword(konto_id: int, hashed_password: str) -> None: # ── Ingredient-Usage-Operationen ─────────────────────────────── + def incrementIngredientUsage(AccountID: int, name: str, unit: str | None) -> None: """Erhöht den Usage-Counter für eine Zutat um 1, aktualisiert lastUnit und lastUsedAt. Legt einen neuen Eintrag an, falls die Zutat für diesen Account noch nicht existiert. @@ -534,4 +555,4 @@ def getAccountById(konto_id: int) -> dict | None: row = cur.fetchone() return dict(row) if row else None finally: - con.close() \ No newline at end of file + con.close() diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py index ee9228d..154caba 100644 --- a/project/backend/Ingredient.py +++ b/project/backend/Ingredient.py @@ -1,11 +1,12 @@ from typing import List, Dict, Any from Database import addIngredient + class Ingredient: def __init__(self, name: str, amount: float): self.__name = name self.__amount = amount - + def getName(self) -> str: return self.__name @@ -17,13 +18,13 @@ def getAmount(self) -> float: def setAmount(self, amount: float): self.__amount = amount - + def setAmountType(self, amountType: str): self.__amountType = amountType - + def getAmountType(self): return self.__amountType - + def saveInDB(self): if not self.__amountType: return False diff --git a/project/backend/Models.py b/project/backend/Models.py index 12d4943..462b9db 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -26,13 +26,16 @@ class RefreshRequest(BaseModel): class LogoutRequest(BaseModel): refresh_token: str + class ForgotPasswordRequest(BaseModel): email: str + class ResetPasswordRequest(BaseModel): token: str new_password: str + class UpdateUser(BaseModel): email: str | None = None currentPassword: str | None = None @@ -47,4 +50,4 @@ class IngredientSearch(BaseModel): class RecipeSearchRequest(BaseModel): zutaten: list[IngredientSearch] - servings: int \ No newline at end of file + servings: int diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index 51925e2..b9ce137 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -1,6 +1,7 @@ - from Ingredient import Ingredient from Database import addIngredientToRecipe, addRecipe, getIngridientByName + + class Recipe: def __init__(self, name: str, ingredients: list[Ingredient], description: str): self.__name = name @@ -11,7 +12,6 @@ def __init__(self, name: str, ingredients: list[Ingredient], description: str): self.__rating = 0.0 self.__countPersons = 1 self.__matching = 0 - def saveInDB(self) -> bool: rid = addRecipe(self.__name, self.__description, None) @@ -51,7 +51,7 @@ def getMatching(self) -> int: return self.__matching def incrementMatching(self): - self.__matching+=1 + self.__matching += 1 def getDuration(self) -> str: return self.__duration @@ -64,4 +64,3 @@ def getingredients(self) -> list[Ingredient]: def setIngredient(self, ingredients: list[Ingredient]): self.__ingredients = ingredients - diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py index 64aafc3..56834e3 100644 --- a/project/backend/RecipeSucuk.py +++ b/project/backend/RecipeSucuk.py @@ -1,16 +1,17 @@ import Database from Ingredient import Ingredient from Recipe import Recipe -from Database import getAllRecipes, getAllIngredientsForRecipe +from Database import getAllRecipes, getAllIngredientsForRecipe -def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]: + +def findRecipes(ingriedents: list[Ingredient]) -> list[Recipe]: recipes = __initRecipes() - + for Ingredient in ingriedents: recipes = __filterRecipes(recipes, Ingredient) recipes.sort(key=lambda x: x.getRating(), reverse=True) - + for recipe in recipes[::-1]: if recipe.getRating() < 0.3: recipes.remove(recipe) @@ -18,37 +19,45 @@ def findRecipes(ingriedents: list[Ingredient])-> list[Recipe]: break return recipes -def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient)-> list[Recipe]: + +def __filterRecipes(recipes: list[Recipe], Ingredient: Ingredient) -> list[Recipe]: for recipe in recipes: for recipeIngredient in recipe.getIngredients(): if recipeIngredient.getName() == Ingredient.getName(): recipe.incrementMatching() - recipe.setRating(recipe.getMatching()/ len(recipe.getIngredients())) + recipe.setRating(recipe.getMatching() / len(recipe.getIngredients())) return recipes -def __initRecipes()-> list[Recipe]: + +def __initRecipes() -> list[Recipe]: recipesRaw = getAllRecipes() recipes = [] for recipeRaw in recipesRaw: - recipe = Recipe(recipeRaw["name"], __formatIngredients(recipeRaw["id"]), recipeRaw["description"]) + recipe = Recipe( + recipeRaw["name"], + __formatIngredients(recipeRaw["id"]), + recipeRaw["description"], + ) recipes.append(recipe) return recipes -def __formatIngredients(id: int)-> list[Ingredient]: + +def __formatIngredients(id: int) -> list[Ingredient]: IngredientsRaw = getAllIngredientsForRecipe(id) Ingredients = [] for IngredientRaw in IngredientsRaw: - Ingredients.append(Ingredient(IngredientRaw["name"],IngredientRaw["amount"])) + Ingredients.append(Ingredient(IngredientRaw["name"], IngredientRaw["amount"])) return Ingredients + ini = [] -ini.append(Ingredient("Linguine", 10 )) -ini.append(Ingredient("Fresh Basil", 10 )) -ini.append(Ingredient("Pine Nuts", 10 )) -ini.append(Ingredient("Parmesan", 10 )) -ini.append(Ingredient("Ground Beef", 10 )) -ini.append(Ingredient("Cumin", 10 )) -ini.append(Ingredient("Cucumber", 10 )) +ini.append(Ingredient("Linguine", 10)) +ini.append(Ingredient("Fresh Basil", 10)) +ini.append(Ingredient("Pine Nuts", 10)) +ini.append(Ingredient("Parmesan", 10)) +ini.append(Ingredient("Ground Beef", 10)) +ini.append(Ingredient("Cumin", 10)) +ini.append(Ingredient("Cucumber", 10)) arr = findRecipes(ini) for i in arr: - print(i.getName()+ " "+ str(i.getRating())) \ No newline at end of file + print(i.getName() + " " + str(i.getRating())) diff --git a/project/backend/Routes.py b/project/backend/Routes.py index b02bd65..868108d 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -30,7 +30,17 @@ getTopIngredients, ) -from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest, ForgotPasswordRequest, ResetPasswordRequest, UpdateUser, RecipeSearchRequest +from Models import ( + User, + Token, + UserCreate, + RefreshRequest, + LogoutRequest, + ForgotPasswordRequest, + ResetPasswordRequest, + UpdateUser, + RecipeSearchRequest, +) # Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000) FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000") @@ -116,8 +126,6 @@ async def deleteCurrentUser(currentUser: Annotated[User, Depends(getCurrentUser) # ── Account aktualisieren ──────────────────────────────────────── - - @router.patch("/users/me") async def updateCurrentUser( data: UpdateUser, currentUser: Annotated[User, Depends(getCurrentUser)] @@ -152,8 +160,10 @@ async def updateCurrentUser( return {"success": True} + # ------------ Passwort vergessen ----------------- # + @router.post("/auth/forgot-password") async def forgotPassword(body: ForgotPasswordRequest): """Schritt 1: User gibt E-Mail ein, bekommt Reset-Link per Mail.""" @@ -204,10 +214,11 @@ async def resetPassword(body: ResetPasswordRequest): # ── Recipe-Suche ─────────────────────────────────────────────── + @router.post("/recipes/search") async def searchRecipes( - body: RecipeSearchRequest, - currentUser: Annotated[User, Depends(getCurrentUser)], + body: RecipeSearchRequest, + currentUser: Annotated[User, Depends(getCurrentUser)], ): """Sucht Rezepte basierend auf den übergebenen Zutaten. @@ -227,8 +238,7 @@ async def searchRecipes( # Aktualisierte Top 5 direkt mit zurückgeben → Frontend muss keinen Extra-Request machen topRows = getTopIngredients(Account["id"], limit=5) topIngredients = [ - {"name": r["displayName"], "unit": r["lastUnit"]} - for r in topRows + {"name": r["displayName"], "unit": r["lastUnit"]} for r in topRows ] # TODO: eigentliche Rezept-Suche implementieren @@ -237,11 +247,12 @@ async def searchRecipes( # ── Ingredient-Vorschläge ────────────────────────────────────── + @router.get("/ingredients/top") async def getTopIngredientsForUser( - response: Response, - currentUser: Annotated[User, Depends(getCurrentUser)], - limit: int = 5, + response: Response, + currentUser: Annotated[User, Depends(getCurrentUser)], + limit: int = 5, ): """Liefert die meistgenutzten Zutaten des aktuellen Users für die Vorschlags-Badges.""" Account = getAccountByEmail(currentUser.email) @@ -254,8 +265,5 @@ async def getTopIngredientsForUser( rows = getTopIngredients(Account["id"], limit=limit) return { - "ingredients": [ - {"name": r["displayName"], "unit": r["lastUnit"]} - for r in rows - ] + "ingredients": [{"name": r["displayName"], "unit": r["lastUnit"]} for r in rows] } From 3c02e9f97ac379ddee9d341c56c81f06030ec537 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Mon, 11 May 2026 13:42:41 +0200 Subject: [PATCH 29/76] Info: Standort von Impressum und Datenschutz --- project/frontend/app/homepage/homepage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index 6ad3795..c00423a 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -27,6 +27,7 @@ export default function Homepage() { From a7ceb96941a22a4b774d39548e942594424008c5 Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Tue, 12 May 2026 16:59:55 +0200 Subject: [PATCH 30/76] Added Review-Protokoll-01 --- docs/Review-Protokoll-01.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 docs/Review-Protokoll-01.md diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md new file mode 100644 index 0000000..fe41c5c --- /dev/null +++ b/docs/Review-Protokoll-01.md @@ -0,0 +1,34 @@ +# Review-Protokoll-01 +### **Datum** (Startzeit und Endzeit): +12.05.2026 16.:00 bis 16:20 + +### **Teilnehmende**: +- Moderator: Samuel +- Zeitwächter: Niclas +- Notizen: Frederik +- Teilnehmer: Eden, Alexander + +### **Ziel**/**Schwerpunkt** des Reviews:
+Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern. +Warum: Umfangreiches Verständnis schaffen, da es sich um ein Zentrales Feature handelt und um die Umsetzung der Rezept Anzeige bewerten zu können. + +Planen/Umsetzung von Features die auf den Algorithmus darauf Bauen. + +### **Komponenten für den Review**:
+Datei: `RecipeSucuk.py` + +### **Kriterien für den Review**: +- Codequalität +- Performance soll auf 100 Rezepten in unter 1s laufen +- Testabdeckung +- Wartbarkeit + +### **Review-Methodik**: +Walkthroughs und Code Review von `RecipeSucuk.py` + +### **Ergebnisse**: +- Umsetzung der Anzeigen der Rezepte + Top 100 Matching Rezepte werden an Frontend geschickt von Algorithmus. + Danach ist der Mehr-Button nicht mehr da. +- Müssen Test schreiben für die Datei `RecipeSucuk.py`. +- Akronyme für `RecipeSucuk.py` wurde bestimmt (SUCUK - Search for Uncomplicated Cooking and User-friendly Kitchen recipes) From 42e06bb9fc07a69facf02f12b6bbd7089264068e Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Tue, 12 May 2026 17:01:20 +0200 Subject: [PATCH 31/76] Clear participant roles in review protocol Removed participant names from the review protocol. --- docs/Review-Protokoll-01.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index fe41c5c..741f878 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -3,10 +3,10 @@ 12.05.2026 16.:00 bis 16:20 ### **Teilnehmende**: -- Moderator: Samuel -- Zeitwächter: Niclas -- Notizen: Frederik -- Teilnehmer: Eden, Alexander +- Moderator: +- Zeitwächter: +- Notizen: +- Teilnehmer: ### **Ziel**/**Schwerpunkt** des Reviews:
Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern. From e27f003e8ab661a3d8dc5f146e09062661e1b0c5 Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Tue, 12 May 2026 17:03:10 +0200 Subject: [PATCH 32/76] Update participants and review focus in documentation --- docs/Review-Protokoll-01.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index 741f878..5ce09da 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -3,10 +3,10 @@ 12.05.2026 16.:00 bis 16:20 ### **Teilnehmende**: -- Moderator: -- Zeitwächter: -- Notizen: -- Teilnehmer: +- Moderator: PrussianBaron +- Zeitwächter: Nicoolaus +- Notizen: GalacticCodeGambit +- Teilnehmer: EdenBernhard, Hellocrafting ### **Ziel**/**Schwerpunkt** des Reviews:
Schwerpunkt: Team übergreifenden Verständnis des Algorithmus Rezept Suche verbessern. From 3792403483b39b8988c7434554f18f959f203659 Mon Sep 17 00:00:00 2001 From: Samuel Goebel Date: Mon, 18 May 2026 12:40:40 +0200 Subject: [PATCH 33/76] Made the required RecipeSUCUK changes --- project/backend/RecipeSucuk.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSucuk.py index 56834e3..b41c3e6 100644 --- a/project/backend/RecipeSucuk.py +++ b/project/backend/RecipeSucuk.py @@ -1,3 +1,4 @@ +# SUCUK = Search for Uncomplicated Cooking and User-friendly Kitchen recipes import Database from Ingredient import Ingredient from Recipe import Recipe @@ -12,11 +13,9 @@ def findRecipes(ingriedents: list[Ingredient]) -> list[Recipe]: recipes.sort(key=lambda x: x.getRating(), reverse=True) - for recipe in recipes[::-1]: - if recipe.getRating() < 0.3: - recipes.remove(recipe) - else: - break + if len(recipes) > 98: + return recipes[:98] + return recipes From 5db1e8d89c81bdc6e4287083c83a76e07450adfd Mon Sep 17 00:00:00 2001 From: PrussianBaron Date: Mon, 18 May 2026 12:45:29 +0200 Subject: [PATCH 34/76] Rename RecipeSucuk.py to RecipeSUCUK.py --- project/backend/{RecipeSucuk.py => RecipeSUCUK.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename project/backend/{RecipeSucuk.py => RecipeSUCUK.py} (100%) diff --git a/project/backend/RecipeSucuk.py b/project/backend/RecipeSUCUK.py similarity index 100% rename from project/backend/RecipeSucuk.py rename to project/backend/RecipeSUCUK.py From 74f6e207716e6cc3a61051d0c5ce41ef33e4d6f5 Mon Sep 17 00:00:00 2001 From: Samuel Goebel Date: Mon, 18 May 2026 13:14:52 +0200 Subject: [PATCH 35/76] Added Search Algorithm for Recipe Name search --- project/backend/SearchRecipeNames.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 project/backend/SearchRecipeNames.py diff --git a/project/backend/SearchRecipeNames.py b/project/backend/SearchRecipeNames.py new file mode 100644 index 0000000..b448372 --- /dev/null +++ b/project/backend/SearchRecipeNames.py @@ -0,0 +1,10 @@ +from Database import getAllRecipes + +def getMatchingRecipeNames(searchTerm: str) -> list[str]: + """Gibt eine Liste von Rezeptnamen zurück, die den Suchbegriff enthalten.""" + searchTerm = searchTerm.lower() + matchingRecipes = [] + for recipe in getAllRecipes(): + if searchTerm in recipe["name"].lower(): + matchingRecipes.append(recipe["name"]) + return matchingRecipes \ No newline at end of file From 4f256137e89bda25c689f07ec5a4c629415a107a Mon Sep 17 00:00:00 2001 From: Samuel Goebel Date: Mon, 18 May 2026 14:45:42 +0200 Subject: [PATCH 36/76] SearchRecipeNames now returns a list of recipe Objects --- project/backend/SearchRecipeNames.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/project/backend/SearchRecipeNames.py b/project/backend/SearchRecipeNames.py index b448372..1b51649 100644 --- a/project/backend/SearchRecipeNames.py +++ b/project/backend/SearchRecipeNames.py @@ -1,10 +1,19 @@ -from Database import getAllRecipes +from Database import getAllRecipes, getAllIngredientsForRecipe +from Ingredient import Ingredient +from Recipe import Recipe -def getMatchingRecipeNames(searchTerm: str) -> list[str]: - """Gibt eine Liste von Rezeptnamen zurück, die den Suchbegriff enthalten.""" +def getMatchingRecipeNames(searchTerm: str) -> list[Recipe]: + """Gibt eine Liste von Rezepten zurück, die den Suchbegriff enthalten.""" searchTerm = searchTerm.lower() matchingRecipes = [] for recipe in getAllRecipes(): if searchTerm in recipe["name"].lower(): - matchingRecipes.append(recipe["name"]) - return matchingRecipes \ No newline at end of file + matchingRecipes.append(Recipe(recipe["name"], __formatIngredients(recipe["id"]), recipe["description"])) + return matchingRecipes + +def __formatIngredients(id: int) -> list[Ingredient]: + IngredientsRaw = getAllIngredientsForRecipe(id) + Ingredients = [] + for IngredientRaw in IngredientsRaw: + Ingredients.append(Ingredient(IngredientRaw["name"], IngredientRaw["amount"])) + return Ingredients \ No newline at end of file From 8961073fe002ad1fdb8a3b49df9d0ff755226ec2 Mon Sep 17 00:00:00 2001 From: GalacticCodeGambit <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Mon, 18 May 2026 15:50:32 +0200 Subject: [PATCH 37/76] Add SonarCloud configuration and update requirements for coverage reporting --- .github/workflows/sonarqube.yml | 62 ++++++++++++++++++++++++++++++++ project/backend/requirements.txt | 3 +- sonar-project.properties | 13 +++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sonarqube.yml create mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..cd05426 --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,62 @@ +name: "SonarCloud Analysis" + +on: + pull_request: + branches: [main, 'blatt*', 'feat/*'] + push: + branches: [main, 'blatt*', 'feat/*'] + +permissions: + contents: read + pull-requests: read + +jobs: + analyze: + name: SonarCloud scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run backend tests with coverage + working-directory: project/backend + run: | + python -m pytest --cov=. --cov-report=xml:coverage.xml --cov-report=term-missing + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Cache SonarQube scanner + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Run SonarCloud analysis + uses: SonarSource/sonarqube-scan-action@v6 + with: + args: > + -Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }} + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: https://sonarcloud.io + + diff --git a/project/backend/requirements.txt b/project/backend/requirements.txt index 60137ce..3b3b797 100644 --- a/project/backend/requirements.txt +++ b/project/backend/requirements.txt @@ -5,4 +5,5 @@ gunicorn==23.0.0 bcrypt python-jose[cryptography] python-multipart -pytest==9.0.3 \ No newline at end of file +pytest==9.0.3 +pytest-cov==6.0.0 \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..b4aa963 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=LazyCook +sonar.projectName=LazyCook +sonar.sourceEncoding=UTF-8 + +sonar.sources=project/backend,project/frontend +sonar.tests=project/backend/tests +sonar.test.inclusions=project/backend/tests/**/*.py +sonar.python.coverage.reportPaths=project/backend/coverage.xml +sonar.coverage.exclusions=project/frontend/**,project/backend/tests/** + +sonar.exclusions=**/__pycache__/**,project/data/**,project/frontend/.next/**,project/frontend/node_modules/**,project/backend/coverage.xml +sonar.python.version=3.11 + From fa6277fb04a41ac7152338c96a583d660cb51656 Mon Sep 17 00:00:00 2001 From: GalacticCodeGambit <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Mon, 18 May 2026 15:53:16 +0200 Subject: [PATCH 38/76] Test Commit --- sonar-project.properties | 1 - 1 file changed, 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index b4aa963..1b9f265 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,4 +10,3 @@ sonar.coverage.exclusions=project/frontend/**,project/backend/tests/** sonar.exclusions=**/__pycache__/**,project/data/**,project/frontend/.next/**,project/frontend/node_modules/**,project/backend/coverage.xml sonar.python.version=3.11 - From a686f9b110257abd494ff1305156b6097b33860b Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Mon, 18 May 2026 23:23:08 +0200 Subject: [PATCH 39/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 232 ++++++++++++++++++++++++++++---- sonar-project.properties | 12 -- 2 files changed, 203 insertions(+), 41 deletions(-) delete mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index cd05426..efdb4e8 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,18 +1,15 @@ -name: "SonarCloud Analysis" +name: SonarCloud Analysis on: - pull_request: - branches: [main, 'blatt*', 'feat/*'] push: - branches: [main, 'blatt*', 'feat/*'] - -permissions: - contents: read - pull-requests: read + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: jobs: - analyze: - name: SonarCloud scan + sonarcloud: + name: Build, Test & Sonar Scan runs-on: ubuntu-latest steps: @@ -21,42 +18,219 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.11 + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.10" - name: Install backend dependencies working-directory: project/backend run: | python -m pip install --upgrade pip - pip install -r requirements.txt + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" - name: Run backend tests with coverage - working-directory: project/backend + working-directory: project run: | - python -m pytest --cov=. --cov-report=xml:coverage.xml --cov-report=term-missing + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true - - name: Set up Java 17 - uses: actions/setup-java@v4 + # ===================================================== + # FRONTEND (Next.js / TypeScript) – Tests + Coverage + # ===================================================== + - name: Set up Node.js + uses: actions/setup-node@v4 with: - distribution: temurin - java-version: '17' + node-version: "20" + cache: "npm" + cache-dependency-path: project/frontend/package-lock.json - - name: Cache SonarQube scanner - uses: actions/cache@v4 + - name: Install frontend dependencies + working-directory: project/frontend + run: npm ci + + - name: Run frontend tests with coverage + working-directory: project/frontend + run: npm run test:coverage --if-present + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar + projectBaseDir: project + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - name: Run SonarCloud analysis - uses: SonarSource/sonarqube-scan-action@v6 + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true with: - args: > - -Dsonar.organization=${{ secrets.SONAR_ORGANIZATION }} + scanMetadataReportFile: project/.scannerwork/report-task.txt env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: https://sonarcloud.io + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** \`${HTTP_CODE}\`" + echo "- **Project Key:** \`${PROJECT_KEY}\`" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + get_sub() { + local path_enc sub_resp + path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') + sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + + { + echo "## SonarCloud Analyse – LazyCook" + echo "" + echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + echo "" + echo "### Coverage" + echo "" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" + echo "| Frontend (TS/JS/CSS) | $(get_sub frontend/app coverage)% | $(get_sub frontend/app line_coverage)% | $(get_sub frontend/app branch_coverage)% | $(get_sub frontend/app ncloc) |" + echo "" + + echo "### Duplikate & Komplexität" + echo "" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | . as $c + | { + path: ($c.path // $c.key), + blocks: (($c.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0"), + lines: (($c.measures[]? | select(.metric=="duplicated_lines") | .value) // "0"), + pct: (($c.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0"), + ncloc: (($c.measures[]? | select(.metric=="ncloc") | .value) // "-") + } + | select((.blocks | tonumber) > 0) + | "| `\(.path)` | \(.blocks) | \(.lines) | \(.pct)% | \(.ncloc) |" + ' + echo "" + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" + + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 1b9f265..0000000 --- a/sonar-project.properties +++ /dev/null @@ -1,12 +0,0 @@ -sonar.projectKey=LazyCook -sonar.projectName=LazyCook -sonar.sourceEncoding=UTF-8 - -sonar.sources=project/backend,project/frontend -sonar.tests=project/backend/tests -sonar.test.inclusions=project/backend/tests/**/*.py -sonar.python.coverage.reportPaths=project/backend/coverage.xml -sonar.coverage.exclusions=project/frontend/**,project/backend/tests/** - -sonar.exclusions=**/__pycache__/**,project/data/**,project/frontend/.next/**,project/frontend/node_modules/**,project/backend/coverage.xml -sonar.python.version=3.11 From 34d8b0012324cf7b225f3e216917959f150551be Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 19 May 2026 22:54:55 +0200 Subject: [PATCH 40/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index efdb4e8..e50656b 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -57,11 +57,6 @@ jobs: working-directory: project/frontend run: npm ci - - name: Run frontend tests with coverage - working-directory: project/frontend - run: npm run test:coverage --if-present - continue-on-error: true - # ===================================================== # SONARCLOUD SCAN # ===================================================== From ad4f4cf07d3424f0f5adb5eb925cb4b1c290862c Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 19 May 2026 23:00:25 +0200 Subject: [PATCH 41/76] Test: Sonarqube workflow with result print in CI-Pipeline --- project/sonar-project.properties | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 project/sonar-project.properties diff --git a/project/sonar-project.properties b/project/sonar-project.properties new file mode 100644 index 0000000..142cea1 --- /dev/null +++ b/project/sonar-project.properties @@ -0,0 +1,32 @@ +# SonarCloud / SonarQube Projektkonfiguration +sonar.projectKey=GalacticCodeGambit_LazyCook +sonar.organization=galacticcodegambit +sonar.projectName=LazyCook +sonar.projectVersion=1.0 + +# Quellen + Tests +sonar.sources=backend,frontend/app +sonar.tests=backend/tests +sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/.next/**,frontend/app/components/ui/**,**/*.config.*,**/coverage/**,backend/tests/** + +# Encoding +sonar.sourceEncoding=UTF-8 + +# Python +sonar.python.version=3.10 +sonar.python.coverage.reportPaths=backend/coverage.xml + +# JavaScript/TypeScript +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info +sonar.typescript.lcov.reportPaths=frontend/coverage/lcov.info + +# CPD-Schwellwerte auf das absolute Minimum gestellt: +# auch kleinste Duplikate (>= 3 Zeilen / >= 10 Tokens) sollen gefunden werden +sonar.cpd.python.minimumTokens=10 +sonar.cpd.python.minimumLines=3 +sonar.cpd.css.minimumTokens=10 +sonar.cpd.css.minimumLines=3 +sonar.cpd.javascript.minimumTokens=10 +sonar.cpd.javascript.minimumLines=3 +sonar.cpd.typescript.minimumTokens=10 +sonar.cpd.typescript.minimumLines=3 From 1caad8893df6ca1a889677fb2329469685b549a0 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 19 May 2026 23:05:15 +0200 Subject: [PATCH 42/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index e50656b..d89e266 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -57,6 +57,11 @@ jobs: working-directory: project/frontend run: npm ci + - name: Run frontend tests with coverage + working-directory: project/frontend + run: npm run test:coverage --if-present + continue-on-error: true + # ===================================================== # SONARCLOUD SCAN # ===================================================== @@ -64,6 +69,10 @@ jobs: uses: SonarSource/sonarqube-scan-action@v3 with: projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -202,18 +211,19 @@ jobs: echo "|---|---:|---:|---:|---:|" echo "${TREE_RESP}" | jq -r ' .components[]? - | . as $c - | { - path: ($c.path // $c.key), - blocks: (($c.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0"), - lines: (($c.measures[]? | select(.metric=="duplicated_lines") | .value) // "0"), - pct: (($c.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0"), - ncloc: (($c.measures[]? | select(.metric=="ncloc") | .value) // "-") - } - | select((.blocks | tonumber) > 0) - | "| `\(.path)` | \(.blocks) | \(.lines) | \(.pct)% | \(.ncloc) |" + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" ' echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + echo "### Ratings & Issues" echo "" echo "| Bewertung | Wert | Anzahl |" From 4d2ee9aa9f4d9177a354d17a8d2a258f3e104214 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 19 May 2026 23:07:09 +0200 Subject: [PATCH 43/76] Test: Sonarqube workflow with result print in CI-Pipeline --- project/.coveragerc | 15 ++ project/.github/workflows/sonar.yml | 241 ++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 project/.coveragerc create mode 100644 project/.github/workflows/sonar.yml diff --git a/project/.coveragerc b/project/.coveragerc new file mode 100644 index 0000000..1757c8b --- /dev/null +++ b/project/.coveragerc @@ -0,0 +1,15 @@ +[run] +# Pfade in coverage.xml relativ statt absolut – wichtig für Sonar-Mapping +relative_files = True +source = backend +branch = True +omit = + backend/tests/* + backend/__pycache__/* + +[report] +show_missing = True +skip_empty = True + +[xml] +output = coverage.xml diff --git a/project/.github/workflows/sonar.yml b/project/.github/workflows/sonar.yml new file mode 100644 index 0000000..4c46886 --- /dev/null +++ b/project/.github/workflows/sonar.yml @@ -0,0 +1,241 @@ +name: SonarCloud Analysis + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + +jobs: + sonarcloud: + name: Build, Test & Sonar Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # FRONTEND (Next.js / TypeScript) – Tests + Coverage + # ===================================================== + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: project/frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: project/frontend + run: npm ci + + - name: Run frontend tests with coverage + working-directory: project/frontend + run: npm run test:coverage --if-present + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** \`${HTTP_CODE}\`" + echo "- **Project Key:** \`${PROJECT_KEY}\`" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + get_sub() { + local path_enc sub_resp + path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') + sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + + { + echo "## SonarCloud Analyse – LazyCook" + echo "" + echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + echo "" + echo "### Coverage" + echo "" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" + echo "| Frontend (TS/JS/CSS) | $(get_sub frontend/app coverage)% | $(get_sub frontend/app line_coverage)% | $(get_sub frontend/app branch_coverage)% | $(get_sub frontend/app ncloc) |" + echo "" + + echo "### Duplikate & Komplexität" + echo "" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" + + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 From 641cf9cebbebc9eca97bb9560a4df22ee85a1922 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:06:07 +0200 Subject: [PATCH 44/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index d89e266..1733a2c 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -57,11 +57,6 @@ jobs: working-directory: project/frontend run: npm ci - - name: Run frontend tests with coverage - working-directory: project/frontend - run: npm run test:coverage --if-present - continue-on-error: true - # ===================================================== # SONARCLOUD SCAN # ===================================================== @@ -220,10 +215,6 @@ jobs: | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" ' echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" - echo "" - echo "### Ratings & Issues" echo "" echo "| Bewertung | Wert | Anzahl |" From bd34f2b91ff781b9130adc7eb775f93b25045643 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:10:46 +0200 Subject: [PATCH 45/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 24 ++- project/.github/workflows/sonar.yml | 241 ---------------------------- 2 files changed, 17 insertions(+), 248 deletions(-) delete mode 100644 project/.github/workflows/sonar.yml diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 1733a2c..27e5e20 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -57,6 +57,11 @@ jobs: working-directory: project/frontend run: npm ci + - name: Run frontend tests with coverage + working-directory: project/frontend + run: npm run test:coverage --if-present + continue-on-error: true + # ===================================================== # SONARCLOUD SCAN # ===================================================== @@ -114,8 +119,8 @@ jobs: echo "" echo ":warning: Konnte die Metriken nicht abrufen." echo "" - echo "- **HTTP Status:** \`${HTTP_CODE}\`" - echo "- **Project Key:** \`${PROJECT_KEY}\`" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" } >> "$SUMMARY_FILE" exit 0 @@ -129,10 +134,11 @@ jobs: } get_sub() { - local path_enc sub_resp - path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') - sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") + local sub_resp + sub_resp=$(curl -s -u "${SONAR_TOKEN}:" -G \ + --data-urlencode "component=${PROJECT_KEY}:$1" \ + --data-urlencode "metricKeys=$2" \ + "${SONAR_HOST}/api/measures/component") echo "${sub_resp}" | jq -r --arg k "$2" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") @@ -163,7 +169,7 @@ jobs: { echo "## SonarCloud Analyse – LazyCook" echo "" - echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" echo "### Coverage" @@ -215,6 +221,10 @@ jobs: | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" ' echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + echo "### Ratings & Issues" echo "" echo "| Bewertung | Wert | Anzahl |" diff --git a/project/.github/workflows/sonar.yml b/project/.github/workflows/sonar.yml deleted file mode 100644 index 4c46886..0000000 --- a/project/.github/workflows/sonar.yml +++ /dev/null @@ -1,241 +0,0 @@ -name: SonarCloud Analysis - -on: - push: - branches: [main, master, develop] - pull_request: - branches: [main, master, develop] - workflow_dispatch: - -jobs: - sonarcloud: - name: Build, Test & Sonar Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ===================================================== - # BACKEND (Python / FastAPI) – Tests + Coverage - # ===================================================== - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install backend dependencies - working-directory: project/backend - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install "pytest-cov>=5,<7" "coverage>=7" - - - name: Run backend tests with coverage - working-directory: project - run: | - python -m pytest \ - --cov=backend \ - --cov-report=xml:backend/coverage.xml \ - --cov-report=term-missing \ - backend/tests/ - continue-on-error: true - - # ===================================================== - # FRONTEND (Next.js / TypeScript) – Tests + Coverage - # ===================================================== - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: project/frontend/package-lock.json - - - name: Install frontend dependencies - working-directory: project/frontend - run: npm ci - - - name: Run frontend tests with coverage - working-directory: project/frontend - run: npm run test:coverage --if-present - continue-on-error: true - - # ===================================================== - # SONARCLOUD SCAN - # ===================================================== - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v3 - with: - projectBaseDir: project - args: > - -Dsonar.projectKey=GalacticCodeGambit_LazyCook - -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: SonarCloud Quality Gate check - id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master - timeout-minutes: 5 - continue-on-error: true - with: - scanMetadataReportFile: project/.scannerwork/report-task.txt - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - # ===================================================== - # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN - # ===================================================== - - name: Render SonarCloud metrics to Job Summary - if: always() - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST: https://sonarcloud.io - QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} - run: | - set +e - - PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ - | head -1 | cut -d'=' -f2 | tr -d ' \r\n') - - METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" - HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ - -u "${SONAR_TOKEN}:" "${API_URL}") - RESP=$(cat /tmp/sonar.json) - - HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" - - if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then - { - echo "## SonarCloud Analyse" - echo "" - echo ":warning: Konnte die Metriken nicht abrufen." - echo "" - echo "- **HTTP Status:** \`${HTTP_CODE}\`" - echo "- **Project Key:** \`${PROJECT_KEY}\`" - echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - } >> "$SUMMARY_FILE" - exit 0 - fi - - get() { - echo "${RESP}" | jq -r --arg k "$1" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' - } - - get_sub() { - local path_enc sub_resp - path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') - sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") - echo "${sub_resp}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" - } - - rating() { - case "$(get "$1")" in - 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; - 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; - esac - } - - DEBT_MIN=$(get sqale_index) - if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then - DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) - DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" - else - DEBT_FMT="-" - fi - - if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then - QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" - else - QG_ICON="FAILED"; QG_EMOJI=":x:" - fi - - { - echo "## SonarCloud Analyse – LazyCook" - echo "" - echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " - echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - echo "" - echo "### Coverage" - echo "" - echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" - echo "|---|---|---|---|---|" - echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" - echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "| Frontend (TS/JS/CSS) | $(get_sub frontend/app coverage)% | $(get_sub frontend/app line_coverage)% | $(get_sub frontend/app branch_coverage)% | $(get_sub frontend/app ncloc) |" - echo "" - - echo "### Duplikate & Komplexität" - echo "" - echo "| Bereich | Metrik | Wert |" - echo "|---|---|---|" - echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" - echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" - echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" - echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" - echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" - echo "" - - echo "### Alle Dateien mit Duplikaten" - echo "" - } >> "$SUMMARY_FILE" - - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false" - TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") - - FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") - - { - if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then - echo "_Keine Datei hat aktuell Duplikate._" - echo "" - else - echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" - echo "" - echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" - echo "|---|---:|---:|---:|---:|" - echo "${TREE_RESP}" | jq -r ' - .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" - ' - echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" - echo "" - - echo "### Ratings & Issues" - echo "" - echo "| Bewertung | Wert | Anzahl |" - echo "|---|---|---|" - echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" - echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" - echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" - echo "| Technische Schuld | ${DEBT_FMT} | – |" - } >> "$SUMMARY_FILE" - - - name: Fail job on Quality Gate failure - if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' - run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." - exit 1 From a749a1ed5e5a5e863b851d52aab19008819d2270 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:21:27 +0200 Subject: [PATCH 46/76] Test: Sonarqube workflow with result print in CI-Pipeline --- project/.coveragerc | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/project/.coveragerc b/project/.coveragerc index 1757c8b..033ec32 100644 --- a/project/.coveragerc +++ b/project/.coveragerc @@ -1,7 +1,7 @@ [run] -# Pfade in coverage.xml relativ statt absolut – wichtig für Sonar-Mapping +# Pfade in coverage.xml relativ zum Working-Dir (= project/) +# damit Sonar "backend/Auth.py" matcht und nicht nur "Auth.py" relative_files = True -source = backend branch = True omit = backend/tests/* @@ -10,6 +10,3 @@ omit = [report] show_missing = True skip_empty = True - -[xml] -output = coverage.xml From 491a4183dd6ee3822a812e850f6068aee672ded7 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:28:59 +0200 Subject: [PATCH 47/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 27e5e20..06dc9e8 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -73,9 +73,13 @@ jobs: -Dsonar.projectKey=GalacticCodeGambit_LazyCook -Dsonar.organization=galacticcodegambit -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info + -Dsonar.typescript.lcov.reportPaths=frontend/coverage/lcov.info + -Dsonar.python.version=3.10 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: SonarCloud Quality Gate check id: sonar-qg From 9237ab7be252506421e81ac968b1603e33603110 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:33:24 +0200 Subject: [PATCH 48/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 42 ++++++++++++++------------------ project/sonar-project.properties | 4 --- 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 06dc9e8..717110d 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -69,17 +69,9 @@ jobs: uses: SonarSource/sonarqube-scan-action@v3 with: projectBaseDir: project - args: > - -Dsonar.projectKey=GalacticCodeGambit_LazyCook - -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties - -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info - -Dsonar.typescript.lcov.reportPaths=frontend/coverage/lcov.info - -Dsonar.python.version=3.10 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: SonarCloud Quality Gate check id: sonar-qg @@ -123,8 +115,8 @@ jobs: echo "" echo ":warning: Konnte die Metriken nicht abrufen." echo "" - echo "- **HTTP Status:** ${HTTP_CODE}" - echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **HTTP Status:** \`${HTTP_CODE}\`" + echo "- **Project Key:** \`${PROJECT_KEY}\`" echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" } >> "$SUMMARY_FILE" exit 0 @@ -138,11 +130,10 @@ jobs: } get_sub() { - local sub_resp - sub_resp=$(curl -s -u "${SONAR_TOKEN}:" -G \ - --data-urlencode "component=${PROJECT_KEY}:$1" \ - --data-urlencode "metricKeys=$2" \ - "${SONAR_HOST}/api/measures/component") + local path_enc sub_resp + path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') + sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") echo "${sub_resp}" | jq -r --arg k "$2" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") @@ -173,7 +164,7 @@ jobs: { echo "## SonarCloud Analyse – LazyCook" echo "" - echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" echo "### Coverage" @@ -216,13 +207,16 @@ jobs: echo "|---|---:|---:|---:|---:|" echo "${TREE_RESP}" | jq -r ' .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + | . as $c + | { + path: ($c.path // $c.key), + blocks: (($c.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0"), + lines: (($c.measures[]? | select(.metric=="duplicated_lines") | .value) // "0"), + pct: (($c.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0"), + ncloc: (($c.measures[]? | select(.metric=="ncloc") | .value) // "-") + } + | select((.blocks | tonumber) > 0) + | "| `\(.path)` | \(.blocks) | \(.lines) | \(.pct)% | \(.ncloc) |" ' echo "" fi diff --git a/project/sonar-project.properties b/project/sonar-project.properties index 142cea1..78c8d4b 100644 --- a/project/sonar-project.properties +++ b/project/sonar-project.properties @@ -16,10 +16,6 @@ sonar.sourceEncoding=UTF-8 sonar.python.version=3.10 sonar.python.coverage.reportPaths=backend/coverage.xml -# JavaScript/TypeScript -sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info -sonar.typescript.lcov.reportPaths=frontend/coverage/lcov.info - # CPD-Schwellwerte auf das absolute Minimum gestellt: # auch kleinste Duplikate (>= 3 Zeilen / >= 10 Tokens) sollen gefunden werden sonar.cpd.python.minimumTokens=10 From 6b0310e54e56c5ea630b9f39adf2a63a4b84850d Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:37:46 +0200 Subject: [PATCH 49/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 717110d..5844e07 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -43,25 +43,6 @@ jobs: backend/tests/ continue-on-error: true - # ===================================================== - # FRONTEND (Next.js / TypeScript) – Tests + Coverage - # ===================================================== - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: project/frontend/package-lock.json - - - name: Install frontend dependencies - working-directory: project/frontend - run: npm ci - - - name: Run frontend tests with coverage - working-directory: project/frontend - run: npm run test:coverage --if-present - continue-on-error: true - # ===================================================== # SONARCLOUD SCAN # ===================================================== @@ -173,7 +154,6 @@ jobs: echo "|---|---|---|---|---|" echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "| Frontend (TS/JS/CSS) | $(get_sub frontend/app coverage)% | $(get_sub frontend/app line_coverage)% | $(get_sub frontend/app branch_coverage)% | $(get_sub frontend/app ncloc) |" echo "" echo "### Duplikate & Komplexität" From a88dbe468fb90fa00a65d77343dbe3323bab13d2 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:43:27 +0200 Subject: [PATCH 50/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 38 ++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 5844e07..fb8456a 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -50,6 +50,12 @@ jobs: uses: SonarSource/sonarqube-scan-action@v3 with: projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=3.10 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -96,8 +102,8 @@ jobs: echo "" echo ":warning: Konnte die Metriken nicht abrufen." echo "" - echo "- **HTTP Status:** \`${HTTP_CODE}\`" - echo "- **Project Key:** \`${PROJECT_KEY}\`" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" } >> "$SUMMARY_FILE" exit 0 @@ -111,10 +117,11 @@ jobs: } get_sub() { - local path_enc sub_resp - path_enc=$(printf '%s' "$1" | sed 's|/|%2F|g') - sub_resp=$(curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}:${path_enc}&metricKeys=$2") + local sub_resp + sub_resp=$(curl -s -u "${SONAR_TOKEN}:" -G \ + --data-urlencode "component=${PROJECT_KEY}:$1" \ + --data-urlencode "metricKeys=$2" \ + "${SONAR_HOST}/api/measures/component") echo "${sub_resp}" | jq -r --arg k "$2" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") @@ -145,7 +152,7 @@ jobs: { echo "## SonarCloud Analyse – LazyCook" echo "" - echo "**Quality Gate:** ${QG_EMOJI} \`${QG_ICON}\` " + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" echo "### Coverage" @@ -187,16 +194,13 @@ jobs: echo "|---|---:|---:|---:|---:|" echo "${TREE_RESP}" | jq -r ' .components[]? - | . as $c - | { - path: ($c.path // $c.key), - blocks: (($c.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0"), - lines: (($c.measures[]? | select(.metric=="duplicated_lines") | .value) // "0"), - pct: (($c.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0"), - ncloc: (($c.measures[]? | select(.metric=="ncloc") | .value) // "-") - } - | select((.blocks | tonumber) > 0) - | "| `\(.path)` | \(.blocks) | \(.lines) | \(.pct)% | \(.ncloc) |" + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" ' echo "" fi From 34b75f7cee84289f727f7756b94a25720e783917 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:51:44 +0200 Subject: [PATCH 51/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 49 ++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index fb8456a..932cf02 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -79,21 +79,42 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST: https://sonarcloud.io QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set +e PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" + echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" + else + echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" + fi + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ -u "${SONAR_TOKEN}:" "${API_URL}") RESP=$(cat /tmp/sonar.json) HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + # DIAGNOSE – wir wollen sehen, was der Script konkret sieht + echo "===== DEBUG =====" + echo "PROJECT_KEY = '${PROJECT_KEY}'" + echo "API_URL = ${API_URL}" + echo "HTTP_CODE = ${HTTP_CODE}" + echo "HAS_MEASURES= ${HAS_MEASURES}" + echo "RESP first 800 chars:" + echo "${RESP}" | head -c 800 + echo "" + echo "===== /DEBUG =====" + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then @@ -117,11 +138,14 @@ jobs: } get_sub() { - local sub_resp - sub_resp=$(curl -s -u "${SONAR_TOKEN}:" -G \ - --data-urlencode "component=${PROJECT_KEY}:$1" \ - --data-urlencode "metricKeys=$2" \ - "${SONAR_HOST}/api/measures/component") + local sub_resp curl_args + curl_args=(-s -u "${SONAR_TOKEN}:" -G + --data-urlencode "component=${PROJECT_KEY}:$1" + --data-urlencode "metricKeys=$2") + if [ -n "${PR_NUMBER}" ]; then + curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + fi + sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") echo "${sub_resp}" | jq -r --arg k "$2" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") @@ -155,6 +179,17 @@ jobs: echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" + echo "
Debug-Info (kann später raus)" + echo "" + echo "- PROJECT_KEY: ${PROJECT_KEY}" + echo "- HTTP_CODE: ${HTTP_CODE}" + echo "- HAS_MEASURES: ${HAS_MEASURES}" + echo "- coverage value direkt aus get: '$(get coverage)'" + echo "- ncloc direkt aus get: '$(get ncloc)'" + echo "- duplicated_lines_density direkt aus get: '$(get duplicated_lines_density)'" + echo "" + echo "
" + echo "" echo "### Coverage" echo "" echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" @@ -178,7 +213,7 @@ jobs: echo "" } >> "$SUMMARY_FILE" - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false" + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") From c7647ced34c2d0d37108e44425184fb420d560d5 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Wed, 20 May 2026 13:55:25 +0200 Subject: [PATCH 52/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 932cf02..bf6704a 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -104,17 +104,6 @@ jobs: HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - # DIAGNOSE – wir wollen sehen, was der Script konkret sieht - echo "===== DEBUG =====" - echo "PROJECT_KEY = '${PROJECT_KEY}'" - echo "API_URL = ${API_URL}" - echo "HTTP_CODE = ${HTTP_CODE}" - echo "HAS_MEASURES= ${HAS_MEASURES}" - echo "RESP first 800 chars:" - echo "${RESP}" | head -c 800 - echo "" - echo "===== /DEBUG =====" - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then @@ -179,17 +168,6 @@ jobs: echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" - echo "
Debug-Info (kann später raus)" - echo "" - echo "- PROJECT_KEY: ${PROJECT_KEY}" - echo "- HTTP_CODE: ${HTTP_CODE}" - echo "- HAS_MEASURES: ${HAS_MEASURES}" - echo "- coverage value direkt aus get: '$(get coverage)'" - echo "- ncloc direkt aus get: '$(get ncloc)'" - echo "- duplicated_lines_density direkt aus get: '$(get duplicated_lines_density)'" - echo "" - echo "
" - echo "" echo "### Coverage" echo "" echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" From 0e2bc05bad1490a105cb842ae8f67079ecad896a Mon Sep 17 00:00:00 2001 From: F <150372421+GalacticCodeGambit@users.noreply.github.com> Date: Wed, 20 May 2026 18:12:17 +0200 Subject: [PATCH 53/76] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/sonarqube.yml | 3 +++ docs/Review-Protokoll-01.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index bf6704a..b08c7cd 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -7,6 +7,9 @@ on: branches: [main, master, develop] workflow_dispatch: +permissions: + contents: read + jobs: sonarcloud: name: Build, Test & Sonar Scan diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index 5ce09da..d49bd59 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -1,6 +1,6 @@ # Review-Protokoll-01 ### **Datum** (Startzeit und Endzeit): -12.05.2026 16.:00 bis 16:20 +12.05.2026 16:00 bis 16:20 ### **Teilnehmende**: - Moderator: PrussianBaron From 154afd38dfe76e03eeef8feb16cd5073c18f4faf Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 08:42:45 +0200 Subject: [PATCH 54/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 215 +++----------------------------- 1 file changed, 15 insertions(+), 200 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index bf6704a..5494497 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,43 +9,27 @@ on: jobs: sonarcloud: - name: Build, Test & Sonar Scan + name: Sonar Scan runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - # ===================================================== - # BACKEND (Python / FastAPI) – Tests + Coverage - # ===================================================== - - name: Set up Python - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.10" - - name: Install backend dependencies - working-directory: project/backend - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install "pytest-cov>=5,<7" "coverage>=7" - - - name: Run backend tests with coverage + - name: Install deps & run tests with coverage working-directory: project run: | - python -m pytest \ - --cov=backend \ - --cov-report=xml:backend/coverage.xml \ - --cov-report=term-missing \ - backend/tests/ + python -m pip install --upgrade pip + python -m pip install -r backend/requirements.txt + python -m pip install "pytest-cov>=5,<7" + python -m pytest --cov=backend --cov-report=xml:backend/coverage.xml backend/tests/ continue-on-error: true - # ===================================================== - # SONARCLOUD SCAN - # ===================================================== - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v3 with: @@ -53,185 +37,16 @@ jobs: args: > -Dsonar.projectKey=GalacticCodeGambit_LazyCook -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties -Dsonar.python.coverage.reportPaths=backend/coverage.xml -Dsonar.python.version=3.10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: SonarCloud Quality Gate check - id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master - timeout-minutes: 5 - continue-on-error: true - with: - scanMetadataReportFile: project/.scannerwork/report-task.txt env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # ===================================================== - # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN - # ===================================================== - - name: Render SonarCloud metrics to Job Summary + - name: Quality Gate + Summary-Tabelle if: always() - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST: https://sonarcloud.io - QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set +e - - PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ - | head -1 | cut -d'=' -f2 | tr -d ' \r\n') - - # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch - PR_PARAM="" - if [ -n "${PR_NUMBER}" ]; then - PR_PARAM="&pullRequest=${PR_NUMBER}" - echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" - else - echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" - fi - - METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" - HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ - -u "${SONAR_TOKEN}:" "${API_URL}") - RESP=$(cat /tmp/sonar.json) - - HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" - - if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then - { - echo "## SonarCloud Analyse" - echo "" - echo ":warning: Konnte die Metriken nicht abrufen." - echo "" - echo "- **HTTP Status:** ${HTTP_CODE}" - echo "- **Project Key:** ${PROJECT_KEY}" - echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - } >> "$SUMMARY_FILE" - exit 0 - fi - - get() { - echo "${RESP}" | jq -r --arg k "$1" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' - } - - get_sub() { - local sub_resp curl_args - curl_args=(-s -u "${SONAR_TOKEN}:" -G - --data-urlencode "component=${PROJECT_KEY}:$1" - --data-urlencode "metricKeys=$2") - if [ -n "${PR_NUMBER}" ]; then - curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") - fi - sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") - echo "${sub_resp}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" - } - - rating() { - case "$(get "$1")" in - 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; - 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; - esac - } - - DEBT_MIN=$(get sqale_index) - if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then - DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) - DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" - else - DEBT_FMT="-" - fi - - if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then - QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" - else - QG_ICON="FAILED"; QG_EMOJI=":x:" - fi - - { - echo "## SonarCloud Analyse – LazyCook" - echo "" - echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" - echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - echo "" - echo "### Coverage" - echo "" - echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" - echo "|---|---|---|---|---|" - echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" - echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "" - - echo "### Duplikate & Komplexität" - echo "" - echo "| Bereich | Metrik | Wert |" - echo "|---|---|---|" - echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" - echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" - echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" - echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" - echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" - echo "" - - echo "### Alle Dateien mit Duplikaten" - echo "" - } >> "$SUMMARY_FILE" - - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" - TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") - - FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") - - { - if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then - echo "_Keine Datei hat aktuell Duplikate._" - echo "" - else - echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" - echo "" - echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" - echo "|---|---:|---:|---:|---:|" - echo "${TREE_RESP}" | jq -r ' - .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" - ' - echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" - echo "" - - echo "### Ratings & Issues" - echo "" - echo "| Bewertung | Wert | Anzahl |" - echo "|---|---|---|" - echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" - echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" - echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" - echo "| Technische Schuld | ${DEBT_FMT} | – |" - } >> "$SUMMARY_FILE" - - - name: Fail job on Quality Gate failure - if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' - run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." - exit 1 \ No newline at end of file + uses: phwt/sonarqube-quality-gate-action@v1 + with: + sonar-host-url: https://sonarcloud.io + sonar-project-key: GalacticCodeGambit_LazyCook + sonar-token: ${{ secrets.SONAR_TOKEN }} + fail-on-quality-gate-error: "true" From 36a4e6ad84e8bbaedb457fdd10f3776f52c8446d Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 08:49:00 +0200 Subject: [PATCH 55/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c961455..332ca69 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,6 +9,8 @@ on: permissions: contents: read + pull-requests: write + issues: write jobs: sonarcloud: @@ -52,4 +54,5 @@ jobs: sonar-host-url: https://sonarcloud.io sonar-project-key: GalacticCodeGambit_LazyCook sonar-token: ${{ secrets.SONAR_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} fail-on-quality-gate-error: "true" From 521c7cdbf5d26eb2cac637a8628aaf0b9d4b949e Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 08:53:19 +0200 Subject: [PATCH 56/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 332ca69..6396def 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -47,6 +47,16 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + # 1. Warten bis SonarCloud die Analyse fertig verarbeitet hat + - name: Wait for SonarCloud analysis + uses: SonarSource/sonarqube-quality-gate-action@master + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # 2. Tabelle ins Job-Summary + PR-Kommentar schreiben - name: Quality Gate + Summary-Tabelle if: always() uses: phwt/sonarqube-quality-gate-action@v1 @@ -55,4 +65,5 @@ jobs: sonar-project-key: GalacticCodeGambit_LazyCook sonar-token: ${{ secrets.SONAR_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} + working-dir: project fail-on-quality-gate-error: "true" From 27c7583c6f67ef57d32c653d2ce544c084b7fe07 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 08:57:21 +0200 Subject: [PATCH 57/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 6396def..118a3a8 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -66,4 +66,7 @@ jobs: sonar-token: ${{ secrets.SONAR_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} working-dir: project + # Explizit Branch ODER PR mitgeben, damit der API-Query den richtigen Scope trifft + branch: ${{ github.event_name != 'pull_request' && github.ref_name || '' }} + pull-request: ${{ github.event.pull_request.number }} fail-on-quality-gate-error: "true" From 5d2f75c9b5fe3f2437322578249a625fb825c52b Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:05:19 +0200 Subject: [PATCH 58/76] Test: Sonarqube workflow with result print in CI-Pipeline --- .github/workflows/sonarqube.yml | 58 +++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 118a3a8..28eb8f4 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -56,7 +56,7 @@ jobs: env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # 2. Tabelle ins Job-Summary + PR-Kommentar schreiben + # 2. Quality-Gate-Tabelle ins Job-Summary + PR-Kommentar - name: Quality Gate + Summary-Tabelle if: always() uses: phwt/sonarqube-quality-gate-action@v1 @@ -66,7 +66,61 @@ jobs: sonar-token: ${{ secrets.SONAR_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} working-dir: project - # Explizit Branch ODER PR mitgeben, damit der API-Query den richtigen Scope trifft branch: ${{ github.event_name != 'pull_request' && github.ref_name || '' }} pull-request: ${{ github.event.pull_request.number }} fail-on-quality-gate-error: "true" + + # 3. Zusatz-Metriken (Coverage, Duplication, Komplexität) ans Summary anhängen + - name: Zusatz-Metriken + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + PR: ${{ github.event.pull_request.number }} + run: | + set +e + KEY="GalacticCodeGambit_LazyCook" + HOST="https://sonarcloud.io" + METRICS="coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc" + + # API-Aufruf mit korrektem Scope (PR vs Branch) + if [ -n "$PR" ]; then + URL="$HOST/api/measures/component?component=$KEY&metricKeys=$METRICS&pullRequest=$PR" + else + URL="$HOST/api/measures/component?component=$KEY&metricKeys=$METRICS" + fi + RESP=$(curl -s -u "$SONAR_TOKEN:" "$URL") + + # Helper: Metrik-Wert ziehen, "-" als Default + val() { + echo "$RESP" | jq -r --arg k "$1" \ + '(.component.measures[]? | select(.metric==$k) | .value) // "-"' 2>/dev/null + } + + { + echo "" + echo "## Zusatz-Metriken" + echo "" + echo "### Coverage" + echo "" + echo "| Metrik | Wert |" + echo "|---|---|" + echo "| Gesamt-Coverage | $(val coverage)% |" + echo "| Line Coverage | $(val line_coverage)% |" + echo "| Branch Coverage | $(val branch_coverage)% |" + echo "| Lines of Code | $(val ncloc) |" + echo "" + echo "### Duplikate" + echo "" + echo "| Metrik | Wert |" + echo "|---|---|" + echo "| Duplizierte Zeilen (Rate) | $(val duplicated_lines_density)% |" + echo "| Duplikat-Blöcke | $(val duplicated_blocks) |" + echo "| Betroffene Dateien | $(val duplicated_files) |" + echo "" + echo "### Komplexität" + echo "" + echo "| Metrik | Wert |" + echo "|---|---|" + echo "| Zyklomatische Komplexität | $(val complexity) |" + echo "| Cognitive Complexity | $(val cognitive_complexity) |" + } >> "$GITHUB_STEP_SUMMARY" From 1eefcae144b8fea595663721aa1db66727b6b794 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:14:38 +0200 Subject: [PATCH 59/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 241 ++++++++++++++++++++++++-------- 1 file changed, 179 insertions(+), 62 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 28eb8f4..a16eea5 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,32 +9,46 @@ on: permissions: contents: read - pull-requests: write - issues: write jobs: sonarcloud: - name: Sonar Scan + name: Build, Test & Sonar Scan runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: "3.10" - - name: Install deps & run tests with coverage - working-directory: project + - name: Install backend dependencies + working-directory: project/backend run: | python -m pip install --upgrade pip - python -m pip install -r backend/requirements.txt - python -m pip install "pytest-cov>=5,<7" - python -m pytest --cov=backend --cov-report=xml:backend/coverage.xml backend/tests/ + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ continue-on-error: true + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== - name: SonarQube Scan uses: SonarSource/sonarqube-scan-action@v3 with: @@ -42,85 +56,188 @@ jobs: args: > -Dsonar.projectKey=GalacticCodeGambit_LazyCook -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties -Dsonar.python.coverage.reportPaths=backend/coverage.xml -Dsonar.python.version=3.10 env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # 1. Warten bis SonarCloud die Analyse fertig verarbeitet hat - - name: Wait for SonarCloud analysis + - name: SonarCloud Quality Gate check + id: sonar-qg uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 continue-on-error: true with: scanMetadataReportFile: project/.scannerwork/report-task.txt env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # 2. Quality-Gate-Tabelle ins Job-Summary + PR-Kommentar - - name: Quality Gate + Summary-Tabelle - if: always() - uses: phwt/sonarqube-quality-gate-action@v1 - with: - sonar-host-url: https://sonarcloud.io - sonar-project-key: GalacticCodeGambit_LazyCook - sonar-token: ${{ secrets.SONAR_TOKEN }} - github-token: ${{ secrets.GITHUB_TOKEN }} - working-dir: project - branch: ${{ github.event_name != 'pull_request' && github.ref_name || '' }} - pull-request: ${{ github.event.pull_request.number }} - fail-on-quality-gate-error: "true" - - # 3. Zusatz-Metriken (Coverage, Duplication, Komplexität) ans Summary anhängen - - name: Zusatz-Metriken + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary if: always() env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - PR: ${{ github.event.pull_request.number }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set +e - KEY="GalacticCodeGambit_LazyCook" - HOST="https://sonarcloud.io" - METRICS="coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc" - # API-Aufruf mit korrektem Scope (PR vs Branch) - if [ -n "$PR" ]; then - URL="$HOST/api/measures/component?component=$KEY&metricKeys=$METRICS&pullRequest=$PR" - else - URL="$HOST/api/measures/component?component=$KEY&metricKeys=$METRICS" + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + # PR-Kontext erkennen: Daten der PR-Analyse statt der Main-Branch holen + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" fi - RESP=$(curl -s -u "$SONAR_TOKEN:" "$URL") - # Helper: Metrik-Wert ziehen, "-" als Default - val() { - echo "$RESP" | jq -r --arg k "$1" \ - '(.component.measures[]? | select(.metric==$k) | .value) // "-"' 2>/dev/null + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + # Hilfsfunktion: Metrik-Wert ziehen, "-" als Default + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + # Sub-Komponente abfragen (z.B. backend, frontend/app) + get_sub() { + local sub_resp curl_args + curl_args=(-s -u "${SONAR_TOKEN}:" -G + --data-urlencode "component=${PROJECT_KEY}:$1" + --data-urlencode "metricKeys=$2") + if [ -n "${PR_NUMBER}" ]; then + curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + fi + sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + # Rating-Buchstaben (1.0=A ... 5.0=E) + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac } + # Technical Debt in Stunden + Minuten formatieren + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + # Quality-Gate-Status + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + { + echo "## SonarCloud Analyse – LazyCook" echo "" - echo "## Zusatz-Metriken" + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" echo "### Coverage" echo "" - echo "| Metrik | Wert |" - echo "|---|---|" - echo "| Gesamt-Coverage | $(val coverage)% |" - echo "| Line Coverage | $(val line_coverage)% |" - echo "| Branch Coverage | $(val branch_coverage)% |" - echo "| Lines of Code | $(val ncloc) |" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" + echo "" + + echo "### Duplikate & Komplexität" + echo "" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" echo "" - echo "### Duplikate" + + echo "### Alle Dateien mit Duplikaten" echo "" - echo "| Metrik | Wert |" - echo "|---|---|" - echo "| Duplizierte Zeilen (Rate) | $(val duplicated_lines_density)% |" - echo "| Duplikat-Blöcke | $(val duplicated_blocks) |" - echo "| Betroffene Dateien | $(val duplicated_files) |" + } >> "$SUMMARY_FILE" + + # Datei-Tree mit Duplikaten holen + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" echo "" - echo "### Komplexität" + + echo "### Ratings & Issues" echo "" - echo "| Metrik | Wert |" - echo "|---|---|" - echo "| Zyklomatische Komplexität | $(val complexity) |" - echo "| Cognitive Complexity | $(val cognitive_complexity) |" - } >> "$GITHUB_STEP_SUMMARY" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" + + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 From 2de83d168ed9bebaeecae33c110f38a8f1417dee Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:24:06 +0200 Subject: [PATCH 60/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 468 ++++++++++++++++---------------- 1 file changed, 232 insertions(+), 236 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index a16eea5..194ea0f 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,243 +1,239 @@ -name: SonarCloud Analysis - -on: - push: - branches: [main, master, develop] - pull_request: - branches: [main, master, develop] - workflow_dispatch: - -permissions: - contents: read - -jobs: - sonarcloud: - name: Build, Test & Sonar Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ===================================================== - # BACKEND (Python / FastAPI) – Tests + Coverage - # ===================================================== - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install backend dependencies - working-directory: project/backend - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install "pytest-cov>=5,<7" "coverage>=7" - - - name: Run backend tests with coverage - working-directory: project - run: | - python -m pytest \ - --cov=backend \ - --cov-report=xml:backend/coverage.xml \ - --cov-report=term-missing \ - backend/tests/ - continue-on-error: true - - # ===================================================== - # SONARCLOUD SCAN - # ===================================================== - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v3 - with: - projectBaseDir: project - args: > - -Dsonar.projectKey=GalacticCodeGambit_LazyCook - -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties - -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.python.version=3.10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: SonarCloud Quality Gate check - id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master - timeout-minutes: 5 - continue-on-error: true - with: - scanMetadataReportFile: project/.scannerwork/report-task.txt - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - # ===================================================== - # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN - # ===================================================== - - name: Render SonarCloud metrics to Job Summary - if: always() - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST: https://sonarcloud.io - QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set +e - - PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ - | head -1 | cut -d'=' -f2 | tr -d ' \r\n') - - # PR-Kontext erkennen: Daten der PR-Analyse statt der Main-Branch holen - PR_PARAM="" - if [ -n "${PR_NUMBER}" ]; then - PR_PARAM="&pullRequest=${PR_NUMBER}" - fi - - METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" - HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ - -u "${SONAR_TOKEN}:" "${API_URL}") - RESP=$(cat /tmp/sonar.json) - - HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" - - if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + name: SonarCloud Analysis + + on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + + permissions: + contents: read + + jobs: + sonarcloud: + name: Build, Test & Sonar Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=3.10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" + echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" + else + echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" + fi + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + get_sub() { + local sub_resp curl_args + curl_args=(-s -u "${SONAR_TOKEN}:" -G + --data-urlencode "component=${PROJECT_KEY}:$1" + --data-urlencode "metricKeys=$2") + if [ -n "${PR_NUMBER}" ]; then + curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + fi + sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + { - echo "## SonarCloud Analyse" + echo "## SonarCloud Analyse – LazyCook" echo "" - echo ":warning: Konnte die Metriken nicht abrufen." + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" - echo "- **HTTP Status:** ${HTTP_CODE}" - echo "- **Project Key:** ${PROJECT_KEY}" - echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - } >> "$SUMMARY_FILE" - exit 0 - fi - - # Hilfsfunktion: Metrik-Wert ziehen, "-" als Default - get() { - echo "${RESP}" | jq -r --arg k "$1" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' - } - - # Sub-Komponente abfragen (z.B. backend, frontend/app) - get_sub() { - local sub_resp curl_args - curl_args=(-s -u "${SONAR_TOKEN}:" -G - --data-urlencode "component=${PROJECT_KEY}:$1" - --data-urlencode "metricKeys=$2") - if [ -n "${PR_NUMBER}" ]; then - curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") - fi - sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") - echo "${sub_resp}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" - } - - # Rating-Buchstaben (1.0=A ... 5.0=E) - rating() { - case "$(get "$1")" in - 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; - 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; - esac - } - - # Technical Debt in Stunden + Minuten formatieren - DEBT_MIN=$(get sqale_index) - if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then - DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) - DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" - else - DEBT_FMT="-" - fi - - # Quality-Gate-Status - if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then - QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" - else - QG_ICON="FAILED"; QG_EMOJI=":x:" - fi - - { - echo "## SonarCloud Analyse – LazyCook" - echo "" - echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" - echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - echo "" - echo "### Coverage" - echo "" - echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" - echo "|---|---|---|---|---|" - echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" - echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "" - - echo "### Duplikate & Komplexität" - echo "" - echo "| Bereich | Metrik | Wert |" - echo "|---|---|---|" - echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" - echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" - echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" - echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" - echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" - echo "" - - echo "### Alle Dateien mit Duplikaten" - echo "" - } >> "$SUMMARY_FILE" - - # Datei-Tree mit Duplikaten holen - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" - TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") - - FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") - - { - if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then - echo "_Keine Datei hat aktuell Duplikate._" + echo "### Coverage" echo "" - else - echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" echo "" - echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" - echo "|---|---:|---:|---:|---:|" - echo "${TREE_RESP}" | jq -r ' - .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" - ' + + echo "### Duplikate & Komplexität" echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" - echo "" - - echo "### Ratings & Issues" - echo "" - echo "| Bewertung | Wert | Anzahl |" - echo "|---|---|---|" - echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" - echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" - echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" - echo "| Technische Schuld | ${DEBT_FMT} | – |" - } >> "$SUMMARY_FILE" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" - - name: Fail job on Quality Gate failure - if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' - run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." - exit 1 + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." \ No newline at end of file From c07e86914277f2ff178c92ee7b2605ba4e340f00 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:26:49 +0200 Subject: [PATCH 61/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 194ea0f..0bc72f6 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -44,7 +44,7 @@ --cov-report=xml:backend/coverage.xml \ --cov-report=term-missing \ backend/tests/ - continue-on-error: true + continue-on-error: true # ===================================================== # SONARCLOUD SCAN From 6bb6a65820e498db40d4e21c3cca3d2d5afb08d0 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:32:08 +0200 Subject: [PATCH 62/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 0bc72f6..c3bfb59 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -236,4 +236,5 @@ - name: Fail job on Quality Gate failure if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." \ No newline at end of file + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 \ No newline at end of file From 119f4acd7f2bc5359b84de7509d1a38b60d0dabf Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:50:04 +0200 Subject: [PATCH 63/76] Revert "Apply suggestions from code review" This reverts commit 0e2bc05b --- .github/workflows/sonarqube.yml | 463 ++++++++++++++++---------------- docs/Review-Protokoll-01.md | 2 +- 2 files changed, 231 insertions(+), 234 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c3bfb59..bf6704a 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,240 +1,237 @@ - name: SonarCloud Analysis - - on: - push: - branches: [main, master, develop] - pull_request: - branches: [main, master, develop] - workflow_dispatch: - - permissions: - contents: read - - jobs: - sonarcloud: - name: Build, Test & Sonar Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ===================================================== - # BACKEND (Python / FastAPI) – Tests + Coverage - # ===================================================== - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install backend dependencies - working-directory: project/backend - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install "pytest-cov>=5,<7" "coverage>=7" - - - name: Run backend tests with coverage - working-directory: project - run: | - python -m pytest \ - --cov=backend \ - --cov-report=xml:backend/coverage.xml \ - --cov-report=term-missing \ - backend/tests/ - continue-on-error: true - - # ===================================================== - # SONARCLOUD SCAN - # ===================================================== - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v3 - with: - projectBaseDir: project - args: > - -Dsonar.projectKey=GalacticCodeGambit_LazyCook - -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties - -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.python.version=3.10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: SonarCloud Quality Gate check - id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master - timeout-minutes: 5 - continue-on-error: true - with: - scanMetadataReportFile: project/.scannerwork/report-task.txt - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - # ===================================================== - # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN - # ===================================================== - - name: Render SonarCloud metrics to Job Summary - if: always() - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST: https://sonarcloud.io - QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set +e - - PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ - | head -1 | cut -d'=' -f2 | tr -d ' \r\n') - - # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch - PR_PARAM="" - if [ -n "${PR_NUMBER}" ]; then - PR_PARAM="&pullRequest=${PR_NUMBER}" - echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" - else - echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" - fi - - METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" - HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ - -u "${SONAR_TOKEN}:" "${API_URL}") - RESP=$(cat /tmp/sonar.json) - - HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" - - if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then - { - echo "## SonarCloud Analyse" - echo "" - echo ":warning: Konnte die Metriken nicht abrufen." - echo "" - echo "- **HTTP Status:** ${HTTP_CODE}" - echo "- **Project Key:** ${PROJECT_KEY}" - echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - } >> "$SUMMARY_FILE" - exit 0 - fi - - get() { - echo "${RESP}" | jq -r --arg k "$1" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' - } - - get_sub() { - local sub_resp curl_args - curl_args=(-s -u "${SONAR_TOKEN}:" -G - --data-urlencode "component=${PROJECT_KEY}:$1" - --data-urlencode "metricKeys=$2") - if [ -n "${PR_NUMBER}" ]; then - curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") - fi - sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") - echo "${sub_resp}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" - } - - rating() { - case "$(get "$1")" in - 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; - 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; - esac - } - - DEBT_MIN=$(get sqale_index) - if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then - DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) - DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" - else - DEBT_FMT="-" - fi - - if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then - QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" - else - QG_ICON="FAILED"; QG_EMOJI=":x:" - fi - +name: SonarCloud Analysis + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + +jobs: + sonarcloud: + name: Build, Test & Sonar Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=3.10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" + echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" + else + echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" + fi + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then { - echo "## SonarCloud Analyse – LazyCook" + echo "## SonarCloud Analyse" echo "" - echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" - echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - echo "" - echo "### Coverage" - echo "" - echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" - echo "|---|---|---|---|---|" - echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" - echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "" - - echo "### Duplikate & Komplexität" - echo "" - echo "| Bereich | Metrik | Wert |" - echo "|---|---|---|" - echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" - echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" - echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" - echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" - echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" - echo "" - - echo "### Alle Dateien mit Duplikaten" + echo ":warning: Konnte die Metriken nicht abrufen." echo "" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" } >> "$SUMMARY_FILE" - - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" - TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") - - FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") - - { - if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then - echo "_Keine Datei hat aktuell Duplikate._" - echo "" - else - echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" - echo "" - echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" - echo "|---|---:|---:|---:|---:|" - echo "${TREE_RESP}" | jq -r ' - .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" - ' - echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + exit 0 + fi + + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + get_sub() { + local sub_resp curl_args + curl_args=(-s -u "${SONAR_TOKEN}:" -G + --data-urlencode "component=${PROJECT_KEY}:$1" + --data-urlencode "metricKeys=$2") + if [ -n "${PR_NUMBER}" ]; then + curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + fi + sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + + { + echo "## SonarCloud Analyse – LazyCook" + echo "" + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + echo "" + echo "### Coverage" + echo "" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" + echo "" + + echo "### Duplikate & Komplexität" + echo "" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" echo "" - - echo "### Ratings & Issues" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" echo "" - echo "| Bewertung | Wert | Anzahl |" - echo "|---|---|---|" - echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" - echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" - echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" - echo "| Technische Schuld | ${DEBT_FMT} | – |" - } >> "$SUMMARY_FILE" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" - - name: Fail job on Quality Gate failure - if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' - run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." - exit 1 \ No newline at end of file + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 \ No newline at end of file diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index d49bd59..5ce09da 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -1,6 +1,6 @@ # Review-Protokoll-01 ### **Datum** (Startzeit und Endzeit): -12.05.2026 16:00 bis 16:20 +12.05.2026 16.:00 bis 16:20 ### **Teilnehmende**: - Moderator: PrussianBaron From f38c1c81228408d1fbb7ec57ea28ad7aa19c9d6e Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 09:54:29 +0200 Subject: [PATCH 64/76] Revert "Test: Sonarqube workflow with result print in CI-Pipeline" This reverts commit c7647ced34c2d0d37108e44425184fb420d560d5. --- .github/workflows/sonarqube.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index bf6704a..932cf02 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -104,6 +104,17 @@ jobs: HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + # DIAGNOSE – wir wollen sehen, was der Script konkret sieht + echo "===== DEBUG =====" + echo "PROJECT_KEY = '${PROJECT_KEY}'" + echo "API_URL = ${API_URL}" + echo "HTTP_CODE = ${HTTP_CODE}" + echo "HAS_MEASURES= ${HAS_MEASURES}" + echo "RESP first 800 chars:" + echo "${RESP}" | head -c 800 + echo "" + echo "===== /DEBUG =====" + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then @@ -168,6 +179,17 @@ jobs: echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" + echo "
Debug-Info (kann später raus)" + echo "" + echo "- PROJECT_KEY: ${PROJECT_KEY}" + echo "- HTTP_CODE: ${HTTP_CODE}" + echo "- HAS_MEASURES: ${HAS_MEASURES}" + echo "- coverage value direkt aus get: '$(get coverage)'" + echo "- ncloc direkt aus get: '$(get ncloc)'" + echo "- duplicated_lines_density direkt aus get: '$(get duplicated_lines_density)'" + echo "" + echo "
" + echo "" echo "### Coverage" echo "" echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" From 0a4051d3ffbdc56574d0ad8e7eacaa8b66679bc2 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:11:59 +0200 Subject: [PATCH 65/76] Revert "Revert "Apply suggestions from code review"" This reverts commit 119f4acd --- .github/workflows/sonarqube.yml | 485 +++++++++++++++----------------- docs/Review-Protokoll-01.md | 2 +- 2 files changed, 234 insertions(+), 253 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 932cf02..c3bfb59 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,259 +1,240 @@ -name: SonarCloud Analysis - -on: - push: - branches: [main, master, develop] - pull_request: - branches: [main, master, develop] - workflow_dispatch: - -jobs: - sonarcloud: - name: Build, Test & Sonar Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ===================================================== - # BACKEND (Python / FastAPI) – Tests + Coverage - # ===================================================== - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install backend dependencies - working-directory: project/backend - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install "pytest-cov>=5,<7" "coverage>=7" - - - name: Run backend tests with coverage - working-directory: project - run: | - python -m pytest \ - --cov=backend \ - --cov-report=xml:backend/coverage.xml \ - --cov-report=term-missing \ - backend/tests/ - continue-on-error: true - - # ===================================================== - # SONARCLOUD SCAN - # ===================================================== - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v3 - with: - projectBaseDir: project - args: > - -Dsonar.projectKey=GalacticCodeGambit_LazyCook - -Dsonar.organization=galacticcodegambit - -Dproject.settings=sonar-project.properties - -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.python.version=3.10 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - name: SonarCloud Quality Gate check - id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master - timeout-minutes: 5 - continue-on-error: true - with: - scanMetadataReportFile: project/.scannerwork/report-task.txt - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - # ===================================================== - # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN - # ===================================================== - - name: Render SonarCloud metrics to Job Summary - if: always() - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST: https://sonarcloud.io - QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set +e - - PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ - | head -1 | cut -d'=' -f2 | tr -d ' \r\n') - - # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch - PR_PARAM="" - if [ -n "${PR_NUMBER}" ]; then - PR_PARAM="&pullRequest=${PR_NUMBER}" - echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" - else - echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" - fi - - METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" - - API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" - HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ - -u "${SONAR_TOKEN}:" "${API_URL}") - RESP=$(cat /tmp/sonar.json) - - HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) - - # DIAGNOSE – wir wollen sehen, was der Script konkret sieht - echo "===== DEBUG =====" - echo "PROJECT_KEY = '${PROJECT_KEY}'" - echo "API_URL = ${API_URL}" - echo "HTTP_CODE = ${HTTP_CODE}" - echo "HAS_MEASURES= ${HAS_MEASURES}" - echo "RESP first 800 chars:" - echo "${RESP}" | head -c 800 - echo "" - echo "===== /DEBUG =====" - - SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" - - if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + name: SonarCloud Analysis + + on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + + permissions: + contents: read + + jobs: + sonarcloud: + name: Build, Test & Sonar Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ===================================================== + # BACKEND (Python / FastAPI) – Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install backend dependencies + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SONARCLOUD SCAN + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=3.10 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: SonarCloud Quality Gate check + id: sonar-qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # METRIKEN ALS MARKDOWN-SUMMARY IN GITHUB AUSGEBEN + # ===================================================== + - name: Render SonarCloud metrics to Job Summary + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.sonar-qg.outputs.quality-gate-status }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set +e + + PROJECT_KEY=$(grep -E '^\s*sonar.projectKey\s*=' project/sonar-project.properties \ + | head -1 | cut -d'=' -f2 | tr -d ' \r\n') + + # PR-Kontext erkennen: dann Daten der PR-Analyse holen, nicht der Main-Branch + PR_PARAM="" + if [ -n "${PR_NUMBER}" ]; then + PR_PARAM="&pullRequest=${PR_NUMBER}" + echo "PR-Modus aktiv – Daten werden für PR #${PR_NUMBER} geholt" + else + echo "Branch-Modus aktiv – Daten werden für den Default-Branch geholt" + fi + + METRICS="alert_status,bugs,vulnerabilities,code_smells,coverage,line_coverage,branch_coverage,duplicated_lines_density,duplicated_blocks,duplicated_files,complexity,cognitive_complexity,ncloc,sqale_rating,reliability_rating,security_rating,sqale_index" + + API_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}${PR_PARAM}" + HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ + -u "${SONAR_TOKEN}:" "${API_URL}") + RESP=$(cat /tmp/sonar.json) + + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" + + if [ "${HTTP_CODE}" != "200" ] || [ "${HAS_MEASURES}" != "yes" ]; then + { + echo "## SonarCloud Analyse" + echo "" + echo ":warning: Konnte die Metriken nicht abrufen." + echo "" + echo "- **HTTP Status:** ${HTTP_CODE}" + echo "- **Project Key:** ${PROJECT_KEY}" + echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" + } >> "$SUMMARY_FILE" + exit 0 + fi + + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' + } + + get_sub() { + local sub_resp curl_args + curl_args=(-s -u "${SONAR_TOKEN}:" -G + --data-urlencode "component=${PROJECT_KEY}:$1" + --data-urlencode "metricKeys=$2") + if [ -n "${PR_NUMBER}" ]; then + curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + fi + sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") + echo "${sub_resp}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null || echo "-" + } + + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + DEBT_MIN=$(get sqale_index) + if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then + DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) + DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" + else + DEBT_FMT="-" + fi + + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" + else + QG_ICON="FAILED"; QG_EMOJI=":x:" + fi + { - echo "## SonarCloud Analyse" + echo "## SonarCloud Analyse – LazyCook" echo "" - echo ":warning: Konnte die Metriken nicht abrufen." + echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" echo "" - echo "- **HTTP Status:** ${HTTP_CODE}" - echo "- **Project Key:** ${PROJECT_KEY}" - echo "- **Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - } >> "$SUMMARY_FILE" - exit 0 - fi - - get() { - echo "${RESP}" | jq -r --arg k "$1" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' - } - - get_sub() { - local sub_resp curl_args - curl_args=(-s -u "${SONAR_TOKEN}:" -G - --data-urlencode "component=${PROJECT_KEY}:$1" - --data-urlencode "metricKeys=$2") - if [ -n "${PR_NUMBER}" ]; then - curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") - fi - sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") - echo "${sub_resp}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" - } - - rating() { - case "$(get "$1")" in - 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; - 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; - esac - } - - DEBT_MIN=$(get sqale_index) - if [[ "${DEBT_MIN}" =~ ^[0-9]+$ ]]; then - DEBT_H=$((DEBT_MIN / 60)); DEBT_REST=$((DEBT_MIN % 60)) - DEBT_FMT="${DEBT_H}h ${DEBT_REST}min" - else - DEBT_FMT="-" - fi - - if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then - QG_ICON="PASSED"; QG_EMOJI=":white_check_mark:" - else - QG_ICON="FAILED"; QG_EMOJI=":x:" - fi - - { - echo "## SonarCloud Analyse – LazyCook" - echo "" - echo "**Quality Gate:** ${QG_EMOJI} **${QG_ICON}**" - echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${PROJECT_KEY}](${SONAR_HOST}/project/overview?id=${PROJECT_KEY})" - echo "" - echo "
Debug-Info (kann später raus)" - echo "" - echo "- PROJECT_KEY: ${PROJECT_KEY}" - echo "- HTTP_CODE: ${HTTP_CODE}" - echo "- HAS_MEASURES: ${HAS_MEASURES}" - echo "- coverage value direkt aus get: '$(get coverage)'" - echo "- ncloc direkt aus get: '$(get ncloc)'" - echo "- duplicated_lines_density direkt aus get: '$(get duplicated_lines_density)'" - echo "" - echo "
" - echo "" - echo "### Coverage" - echo "" - echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" - echo "|---|---|---|---|---|" - echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" - echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" - echo "" - - echo "### Duplikate & Komplexität" - echo "" - echo "| Bereich | Metrik | Wert |" - echo "|---|---|---|" - echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" - echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" - echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" - echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" - echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" - echo "" - - echo "### Alle Dateien mit Duplikaten" - echo "" - } >> "$SUMMARY_FILE" - - TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" - TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") - - FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") - - { - if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then - echo "_Keine Datei hat aktuell Duplikate._" + echo "### Coverage" echo "" - else - echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "| Komponente | Coverage | Line Coverage | Branch Coverage | LoC |" + echo "|---|---|---|---|---|" + echo "| **Gesamt** | $(get coverage)% | $(get line_coverage)% | $(get branch_coverage)% | $(get ncloc) |" + echo "| Backend (Python) | $(get_sub backend coverage)% | $(get_sub backend line_coverage)% | $(get_sub backend branch_coverage)% | $(get_sub backend ncloc) |" echo "" - echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" - echo "|---|---:|---:|---:|---:|" - echo "${TREE_RESP}" | jq -r ' - .components[]? - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b - | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l - | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | select(($b | tonumber) > 0) - | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" - ' + + echo "### Duplikate & Komplexität" echo "" - fi - echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" - echo "" - - echo "### Ratings & Issues" - echo "" - echo "| Bewertung | Wert | Anzahl |" - echo "|---|---|---|" - echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" - echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" - echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" - echo "| Technische Schuld | ${DEBT_FMT} | – |" - } >> "$SUMMARY_FILE" + echo "| Bereich | Metrik | Wert |" + echo "|---|---|---|" + echo "| Duplikate | Duplizierte Zeilen | $(get duplicated_lines_density)% |" + echo "| Duplikate | Duplizierte Blöcke | $(get duplicated_blocks) |" + echo "| Duplikate | Betroffene Dateien | $(get duplicated_files) |" + echo "| Komplexität | Zyklomatische Komplexität | $(get complexity) |" + echo "| Komplexität | Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + echo "### Alle Dateien mit Duplikaten" + echo "" + } >> "$SUMMARY_FILE" + + TREE_URL="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=duplicated_blocks,duplicated_lines,duplicated_lines_density,ncloc&qualifiers=FIL&ps=500&s=metric&metricSort=duplicated_blocks&asc=false${PR_PARAM}" + TREE_RESP=$(curl -s -u "${SONAR_TOKEN}:" "${TREE_URL}") + + FILES_WITH_DUPS=$(echo "${TREE_RESP}" | jq '[.components[]? | select((.measures[]? | select(.metric=="duplicated_blocks") | .value | tonumber) > 0)] | length' 2>/dev/null || echo "0") + + { + if [ "${FILES_WITH_DUPS}" = "0" ] || [ -z "${FILES_WITH_DUPS}" ]; then + echo "_Keine Datei hat aktuell Duplikate._" + echo "" + else + echo "_Insgesamt **${FILES_WITH_DUPS}** Dateien mit mindestens einem Duplikat-Block._" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | Anteil | LoC |" + echo "|---|---:|---:|---:|---:|" + echo "${TREE_RESP}" | jq -r ' + .components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="duplicated_lines_density") | .value) // "0") as $pct + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $pct + "% | " + $n + " |" + ' + echo "" + fi + echo "[Volle Duplikat-Ansicht in SonarCloud öffnen](${SONAR_HOST}/component_measures?id=${PROJECT_KEY}&metric=duplicated_lines_density&view=list)" + echo "" + + echo "### Ratings & Issues" + echo "" + echo "| Bewertung | Wert | Anzahl |" + echo "|---|---|---|" + echo "| Reliability Rating | $(rating reliability_rating) | $(get bugs) Bugs |" + echo "| Security Rating | $(rating security_rating) | $(get vulnerabilities) Vulnerabilities |" + echo "| Maintainability Rating | $(rating sqale_rating) | $(get code_smells) Code Smells |" + echo "| Technische Schuld | ${DEBT_FMT} | – |" + } >> "$SUMMARY_FILE" - - name: Fail job on Quality Gate failure - if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' - run: | - echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." - exit 1 \ No newline at end of file + - name: Fail job on Quality Gate failure + if: steps.sonar-qg.outputs.quality-gate-status == 'FAILED' + run: | + echo "::error::SonarCloud Quality Gate ist FAILED – siehe Job Summary." + exit 1 \ No newline at end of file diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md index 5ce09da..d49bd59 100644 --- a/docs/Review-Protokoll-01.md +++ b/docs/Review-Protokoll-01.md @@ -1,6 +1,6 @@ # Review-Protokoll-01 ### **Datum** (Startzeit und Endzeit): -12.05.2026 16.:00 bis 16:20 +12.05.2026 16:00 bis 16:20 ### **Teilnehmende**: - Moderator: PrussianBaron From 77bc4c41c825af7235121f71eaf500b640241990 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:19:45 +0200 Subject: [PATCH 66/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 50 ++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c3bfb59..45f9763 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -104,8 +104,14 @@ HTTP_CODE=$(curl -s -o /tmp/sonar.json -w "%{http_code}" \ -u "${SONAR_TOKEN}:" "${API_URL}") RESP=$(cat /tmp/sonar.json) - + HAS_MEASURES=$(echo "${RESP}" | jq -r 'if (.component? and .component.measures?) then "yes" else "no" end' 2>/dev/null) + + # Zusätzlich Branch-Daten holen für Metriken, die im PR-Modus leer sind + # (z.B. ncloc, complexity, cognitive_complexity – Projekt-strukturelle Werte) + BRANCH_URL="${SONAR_HOST}/api/measures/component?component=${PROJECT_KEY}&metricKeys=${METRICS}" + curl -s -o /tmp/sonar_branch.json -u "${SONAR_TOKEN}:" "${BRANCH_URL}" >/dev/null + BRANCH_RESP=$(cat /tmp/sonar_branch.json) SUMMARY_FILE="${GITHUB_STEP_SUMMARY}" @@ -122,23 +128,47 @@ exit 0 fi + # Holt Metrik primär aus PR-Response, fällt auf Branch-Response zurück get() { - echo "${RESP}" | jq -r --arg k "$1" \ + local v + v=$(echo "${RESP}" | jq -r --arg k "$1" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' + else "-" end') + if [ "$v" = "-" ] || [ -z "$v" ]; then + v=$(echo "${BRANCH_RESP}" | jq -r --arg k "$1" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end') + fi + echo "$v" } + # Holt Metrik für Sub-Komponente. Versucht erst PR-Scope, fällt auf Branch zurück. get_sub() { - local sub_resp curl_args - curl_args=(-s -u "${SONAR_TOKEN}:" -G - --data-urlencode "component=${PROJECT_KEY}:$1" - --data-urlencode "metricKeys=$2") + local v sub_resp_pr sub_resp_branch + # PR-Versuch (falls PR-Kontext) if [ -n "${PR_NUMBER}" ]; then - curl_args+=(--data-urlencode "pullRequest=${PR_NUMBER}") + sub_resp_pr=$(curl -s -u "${SONAR_TOKEN}:" -G \ + --data-urlencode "component=${PROJECT_KEY}:$1" \ + --data-urlencode "metricKeys=$2" \ + --data-urlencode "pullRequest=${PR_NUMBER}" \ + "${SONAR_HOST}/api/measures/component") + v=$(echo "${sub_resp_pr}" | jq -r --arg k "$2" \ + 'if (.component? and .component.measures?) then + ((.component.measures[] | select(.metric==$k) | .value) // "-") + else "-" end' 2>/dev/null) + if [ "$v" != "-" ] && [ -n "$v" ]; then + echo "$v" + return + fi fi - sub_resp=$(curl "${curl_args[@]}" "${SONAR_HOST}/api/measures/component") - echo "${sub_resp}" | jq -r --arg k "$2" \ + # Fallback: Branch-/Default-Query ohne PR + sub_resp_branch=$(curl -s -u "${SONAR_TOKEN}:" -G \ + --data-urlencode "component=${PROJECT_KEY}:$1" \ + --data-urlencode "metricKeys=$2" \ + "${SONAR_HOST}/api/measures/component") + echo "${sub_resp_branch}" | jq -r --arg k "$2" \ 'if (.component? and .component.measures?) then ((.component.measures[] | select(.metric==$k) | .value) // "-") else "-" end' 2>/dev/null || echo "-" From 847fb33f823b0356cfbf855356dd7bc196c4e19f Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:24:21 +0200 Subject: [PATCH 67/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube.yml | 48 ++++++++++++++++----------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 45f9763..e3a0155 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -144,34 +144,34 @@ echo "$v" } - # Holt Metrik für Sub-Komponente. Versucht erst PR-Scope, fällt auf Branch zurück. + # Holt Metrik für Sub-Komponente. + # SonarCloud-Sub-Komponente kann ':' nicht immer per + # measures/component-Endpoint adressieren – wir nutzen component_tree + # mit dem 'q'-Filter, der nach Pfad-Suffix sucht. get_sub() { - local v sub_resp_pr sub_resp_branch - # PR-Versuch (falls PR-Kontext) - if [ -n "${PR_NUMBER}" ]; then - sub_resp_pr=$(curl -s -u "${SONAR_TOKEN}:" -G \ - --data-urlencode "component=${PROJECT_KEY}:$1" \ - --data-urlencode "metricKeys=$2" \ - --data-urlencode "pullRequest=${PR_NUMBER}" \ - "${SONAR_HOST}/api/measures/component") - v=$(echo "${sub_resp_pr}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null) - if [ "$v" != "-" ] && [ -n "$v" ]; then + local path="$1" + local metric="$2" + local tree_url tree_resp v + + # Branch-Tree erstmal, danach PR falls vorhanden – beide pruefen + for try_pr in branch pr; do + tree_url="${SONAR_HOST}/api/measures/component_tree?component=${PROJECT_KEY}&metricKeys=${metric}&qualifiers=DIR&q=${path}&ps=20" + if [ "$try_pr" = "pr" ] && [ -n "${PR_NUMBER}" ]; then + tree_url="${tree_url}&pullRequest=${PR_NUMBER}" + elif [ "$try_pr" = "pr" ]; then + continue + fi + tree_resp=$(curl -s -u "${SONAR_TOKEN}:" "${tree_url}") + # Sucht in den Components die mit path enden ODER deren path == argument + v=$(echo "${tree_resp}" | jq -r --arg p "$path" --arg k "$metric" \ + '[.components[]? | select(.path == $p)] + | .[0].measures[]? | select(.metric==$k) | .value' 2>/dev/null) + if [ -n "$v" ] && [ "$v" != "null" ] && [ "$v" != "-" ]; then echo "$v" return fi - fi - # Fallback: Branch-/Default-Query ohne PR - sub_resp_branch=$(curl -s -u "${SONAR_TOKEN}:" -G \ - --data-urlencode "component=${PROJECT_KEY}:$1" \ - --data-urlencode "metricKeys=$2" \ - "${SONAR_HOST}/api/measures/component") - echo "${sub_resp_branch}" | jq -r --arg k "$2" \ - 'if (.component? and .component.measures?) then - ((.component.measures[] | select(.metric==$k) | .value) // "-") - else "-" end' 2>/dev/null || echo "-" + done + echo "-" } rating() { From 2993dc3cb40e3ffa3d05f982d65c928b98013653 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:40:43 +0200 Subject: [PATCH 68/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 275 +++++++++++++++++++++++++++ .github/workflows/sonarqube.yml | 4 +- 2 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/sonarqube-main.yml diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml new file mode 100644 index 0000000..81dfb69 --- /dev/null +++ b/.github/workflows/sonarqube-main.yml @@ -0,0 +1,275 @@ +name: SonarCloud Full Metrics (main branch) + +on: + push: + branches: [main, master, sonarqube] + pull_request: + branches: [main, master, sonarqube] + workflow_dispatch: + +permissions: + contents: read + +jobs: + full-metrics: + name: Full Project Report + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ===================================================== + # Backend-Tests + Coverage + # ===================================================== + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install backend deps + working-directory: project/backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install "pytest-cov>=5,<7" "coverage>=7" + + - name: Run backend tests with coverage + working-directory: project + run: | + python -m pytest \ + --cov=backend \ + --cov-report=xml:backend/coverage.xml \ + backend/tests/ + continue-on-error: true + + # ===================================================== + # SonarCloud Scan + # ===================================================== + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v3 + with: + projectBaseDir: project + args: > + -Dsonar.projectKey=GalacticCodeGambit_LazyCook + -Dsonar.organization=galacticcodegambit + -Dproject.settings=sonar-project.properties + -Dsonar.python.coverage.reportPaths=backend/coverage.xml + -Dsonar.python.version=3.10 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + - name: Wait for analysis + id: qg + uses: SonarSource/sonarqube-quality-gate-action@master + timeout-minutes: 5 + continue-on-error: true + with: + scanMetadataReportFile: project/.scannerwork/report-task.txt + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # ===================================================== + # Voller Projekt-Report ins Job-Summary + # ===================================================== + - name: Render Full Metrics + if: always() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST: https://sonarcloud.io + QG_STATUS: ${{ steps.qg.outputs.quality-gate-status }} + run: | + set +e + KEY="GalacticCodeGambit_LazyCook" + OUT="$GITHUB_STEP_SUMMARY" + + # Alle relevanten Branch-Metriken auf einen Schwung holen + METRICS="alert_status,bugs,vulnerabilities,code_smells,security_hotspots,security_hotspots_reviewed,coverage,line_coverage,branch_coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_blocks,duplicated_files,duplicated_lines,complexity,cognitive_complexity,classes,functions,statements,files,ncloc,comment_lines,comment_lines_density,sqale_rating,reliability_rating,security_rating,security_review_rating,sqale_index,sqale_debt_ratio,reliability_remediation_effort,security_remediation_effort" + + URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" + RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") + + # Mini-Diagnose im Log + echo "Anfrage: ${URL}" + echo "Response (erste 300 Zeichen):" + echo "${RESP}" | head -c 300 + echo "" + + # Hilfsfunktion: Metrik-Wert holen, "-" als Fallback + get() { + echo "${RESP}" | jq -r --arg k "$1" \ + '(.component.measures[]? | select(.metric==$k) | .value) // "-"' 2>/dev/null + } + + # Rating-Buchstabe (1.0..5.0 → A..E) + rating() { + case "$(get "$1")" in + 1.0) echo "A" ;; 2.0) echo "B" ;; 3.0) echo "C" ;; + 4.0) echo "D" ;; 5.0) echo "E" ;; *) echo "-" ;; + esac + } + + # Minuten → Stunden + Minuten + fmt_debt() { + local m=$1 + if [[ "$m" =~ ^[0-9]+$ ]]; then + local h=$((m / 60)); local r=$((m % 60)) + echo "${h}h ${r}min" + else + echo "-" + fi + } + + # Quality-Gate-Icon + if [ "${QG_STATUS}" = "PASSED" ] || [ "$(get alert_status)" = "OK" ]; then + QG_ICON=":white_check_mark:"; QG_TXT="PASSED" + else + QG_ICON=":x:"; QG_TXT="FAILED" + fi + + DEBT_FMT=$(fmt_debt "$(get sqale_index)") + REL_EFF_FMT=$(fmt_debt "$(get reliability_remediation_effort)") + SEC_EFF_FMT=$(fmt_debt "$(get security_remediation_effort)") + + # ===== Summary schreiben ===== + { + echo "# SonarCloud – Voller Projekt-Report" + echo "" + echo "**Quality Gate:** ${QG_ICON} **${QG_TXT}** " + echo "**Branch:** ${GITHUB_REF_NAME} " + echo "**Commit:** ${GITHUB_SHA:0:7} " + echo "**Dashboard:** [${SONAR_HOST}/project/overview?id=${KEY}](${SONAR_HOST}/project/overview?id=${KEY})" + echo "" + + # --- Übersicht --- + echo "## Projekt-Übersicht" + echo "" + echo "| Kennzahl | Wert |" + echo "|---|---:|" + echo "| Lines of Code | $(get ncloc) |" + echo "| Kommentar-Zeilen | $(get comment_lines) |" + echo "| Kommentar-Anteil | $(get comment_lines_density)% |" + echo "| Dateien | $(get files) |" + echo "| Klassen | $(get classes) |" + echo "| Funktionen | $(get functions) |" + echo "| Statements | $(get statements) |" + echo "" + + # --- Coverage --- + echo "## Test-Coverage" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Coverage (gesamt) | $(get coverage)% |" + echo "| Line Coverage | $(get line_coverage)% |" + echo "| Branch Coverage | $(get branch_coverage)% |" + echo "| Deckbare Zeilen | $(get lines_to_cover) |" + echo "| Ungedeckte Zeilen | $(get uncovered_lines) |" + echo "" + + # --- Duplikate --- + echo "## Duplikate" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Duplizierte Zeilen (Rate) | $(get duplicated_lines_density)% |" + echo "| Duplizierte Zeilen (absolut) | $(get duplicated_lines) |" + echo "| Duplikat-Blöcke | $(get duplicated_blocks) |" + echo "| Betroffene Dateien | $(get duplicated_files) |" + echo "" + + # --- Komplexität --- + echo "## Komplexität" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Zyklomatische Komplexität | $(get complexity) |" + echo "| Cognitive Complexity | $(get cognitive_complexity) |" + echo "" + + # --- Issues & Ratings --- + echo "## Issues & Bewertungen" + echo "" + echo "| Kategorie | Rating | Anzahl | Behebungs-Aufwand |" + echo "|---|:---:|---:|---:|" + echo "| Reliability (Bugs) | $(rating reliability_rating) | $(get bugs) | ${REL_EFF_FMT} |" + echo "| Security (Vulnerabilities) | $(rating security_rating) | $(get vulnerabilities) | ${SEC_EFF_FMT} |" + echo "| Maintainability (Code Smells) | $(rating sqale_rating) | $(get code_smells) | ${DEBT_FMT} |" + echo "| Security Hotspots | $(rating security_review_rating) | $(get security_hotspots) | $(get security_hotspots_reviewed)% reviewed |" + echo "" + + # --- Technische Schuld --- + echo "## Technische Schuld" + echo "" + echo "| Metrik | Wert |" + echo "|---|---:|" + echo "| Gesamte Schuld | ${DEBT_FMT} |" + echo "| Debt Ratio | $(get sqale_debt_ratio)% |" + echo "" + } >> "$OUT" + + # ===================================================== + # Top-10 Files: höchste Komplexität, schlechteste Coverage + # ===================================================== + { + echo "## Top-10 komplexeste Dateien" + echo "" + echo "| Datei | Komplexität | Cognitive | LoC |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false" \ + | jq -r '.components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="complexity") | .value) // "-") as $c + | ((.measures[]? | select(.metric=="cognitive_complexity")| .value) // "-") as $cog + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | "| " + $p + " | " + $c + " | " + $cog + " | " + $n + " |"' \ + >> "$OUT" + + { + echo "" + echo "## Top-10 Dateien mit schlechtester Coverage" + echo "" + echo "| Datei | Coverage | Ungedeckte Zeilen | LoC |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true" \ + | jq -r '.components[]? + | select(.measures[]? | select(.metric=="coverage")) + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="coverage") | .value) // "-") as $cov + | ((.measures[]? | select(.metric=="uncovered_lines") | .value) // "-") as $unc + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | "| " + $p + " | " + $cov + "% | " + $unc + " | " + $n + " |"' \ + >> "$OUT" + + { + echo "" + echo "## Top-10 Dateien mit den meisten Duplikat-Blöcken" + echo "" + echo "| Datei | Blöcke | Dupl. Zeilen | LoC |" + echo "|---|---:|---:|---:|" + } >> "$OUT" + + curl -s -u "${SONAR_TOKEN}:" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false" \ + | jq -r '.components[]? + | (.path // .key) as $p + | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b + | ((.measures[]? | select(.metric=="duplicated_lines") | .value) // "0") as $l + | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n + | select(($b | tonumber) > 0) + | "| " + $p + " | " + $b + " | " + $l + " | " + $n + " |"' \ + >> "$OUT" + + { + echo "" + echo "---" + echo "_Generiert von SonarCloud-Analyse aus Push auf \`${GITHUB_REF_NAME}\`._" + } >> "$OUT" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index e3a0155..4a9a6c5 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -2,9 +2,9 @@ on: push: - branches: [main, master, develop] + branches: [main, master, sonarqube] pull_request: - branches: [main, master, develop] + branches: [main, master] workflow_dispatch: permissions: From da348f0210432e095b2e0a435aa6492a736a54d1 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:47:52 +0200 Subject: [PATCH 69/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 37 ++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index 81dfb69..9c29091 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -80,17 +80,44 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST: https://sonarcloud.io QG_STATUS: ${{ steps.qg.outputs.quality-gate-status }} + EVENT_NAME: ${{ github.event_name }} + BRANCH_NAME: ${{ github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | set +e KEY="GalacticCodeGambit_LazyCook" OUT="$GITHUB_STEP_SUMMARY" - # Alle relevanten Branch-Metriken auf einen Schwung holen + # ---- Scope der API-Abfrage dynamisch bestimmen ---- + # PR-Lauf → ?pullRequest= + # Push auf nicht-Default-Branch → ?branch= + # Push auf main/master → kein Parameter (Default-Branch) + SCOPE_PARAM="" + SCOPE_DESC="Default-Branch" + if [ -n "${PR_NUMBER}" ]; then + SCOPE_PARAM="&pullRequest=${PR_NUMBER}" + SCOPE_DESC="PR #${PR_NUMBER}" + elif [ "${BRANCH_NAME}" != "main" ] && [ "${BRANCH_NAME}" != "master" ]; then + SCOPE_PARAM="&branch=${BRANCH_NAME}" + SCOPE_DESC="Branch ${BRANCH_NAME}" + fi + echo "Abfrage-Scope: ${SCOPE_DESC}" + + # Alle relevanten Metriken auf einen Schwung holen METRICS="alert_status,bugs,vulnerabilities,code_smells,security_hotspots,security_hotspots_reviewed,coverage,line_coverage,branch_coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_blocks,duplicated_files,duplicated_lines,complexity,cognitive_complexity,classes,functions,statements,files,ncloc,comment_lines,comment_lines_density,sqale_rating,reliability_rating,security_rating,security_review_rating,sqale_index,sqale_debt_ratio,reliability_remediation_effort,security_remediation_effort" - URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" + URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}${SCOPE_PARAM}" RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") + # Falls die Scope-Abfrage leer ist: Fallback auf Default-Branch + HAS=$(echo "${RESP}" | jq -r '(.component.measures | length) // 0' 2>/dev/null) + if [ -z "${HAS}" ] || [ "${HAS}" = "0" ]; then + echo "Scope-Abfrage leer, Fallback auf Default-Branch" + URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" + RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") + SCOPE_DESC="${SCOPE_DESC} → fallback Default-Branch" + fi + # Mini-Diagnose im Log echo "Anfrage: ${URL}" echo "Response (erste 300 Zeichen):" @@ -221,7 +248,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false${SCOPE_PARAM}" \ | jq -r '.components[]? | (.path // .key) as $p | ((.measures[]? | select(.metric=="complexity") | .value) // "-") as $c @@ -239,7 +266,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true${SCOPE_PARAM}" \ | jq -r '.components[]? | select(.measures[]? | select(.metric=="coverage")) | (.path // .key) as $p @@ -258,7 +285,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false${SCOPE_PARAM}" \ | jq -r '.components[]? | (.path // .key) as $p | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b From ff40d5eb68302e34b97981dddce5ebf953e5aca8 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 10:53:43 +0200 Subject: [PATCH 70/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 44 ++++++---------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index 9c29091..ac265e2 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -2,9 +2,9 @@ name: SonarCloud Full Metrics (main branch) on: push: - branches: [main, master, sonarqube] + branches: [sonarqube] pull_request: - branches: [main, master, sonarqube] + branches: [main, master] workflow_dispatch: permissions: @@ -42,10 +42,11 @@ jobs: python -m pytest \ --cov=backend \ --cov-report=xml:backend/coverage.xml \ + --cov-report=term-missing \ backend/tests/ continue-on-error: true - # ===================================================== + # ===================================================== # SonarCloud Scan # ===================================================== - name: SonarQube Scan @@ -80,44 +81,17 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST: https://sonarcloud.io QG_STATUS: ${{ steps.qg.outputs.quality-gate-status }} - EVENT_NAME: ${{ github.event_name }} - BRANCH_NAME: ${{ github.ref_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} run: | set +e KEY="GalacticCodeGambit_LazyCook" OUT="$GITHUB_STEP_SUMMARY" - # ---- Scope der API-Abfrage dynamisch bestimmen ---- - # PR-Lauf → ?pullRequest= - # Push auf nicht-Default-Branch → ?branch= - # Push auf main/master → kein Parameter (Default-Branch) - SCOPE_PARAM="" - SCOPE_DESC="Default-Branch" - if [ -n "${PR_NUMBER}" ]; then - SCOPE_PARAM="&pullRequest=${PR_NUMBER}" - SCOPE_DESC="PR #${PR_NUMBER}" - elif [ "${BRANCH_NAME}" != "main" ] && [ "${BRANCH_NAME}" != "master" ]; then - SCOPE_PARAM="&branch=${BRANCH_NAME}" - SCOPE_DESC="Branch ${BRANCH_NAME}" - fi - echo "Abfrage-Scope: ${SCOPE_DESC}" - - # Alle relevanten Metriken auf einen Schwung holen + # Alle relevanten Branch-Metriken auf einen Schwung holen METRICS="alert_status,bugs,vulnerabilities,code_smells,security_hotspots,security_hotspots_reviewed,coverage,line_coverage,branch_coverage,lines_to_cover,uncovered_lines,duplicated_lines_density,duplicated_blocks,duplicated_files,duplicated_lines,complexity,cognitive_complexity,classes,functions,statements,files,ncloc,comment_lines,comment_lines_density,sqale_rating,reliability_rating,security_rating,security_review_rating,sqale_index,sqale_debt_ratio,reliability_remediation_effort,security_remediation_effort" - URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}${SCOPE_PARAM}" + URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") - # Falls die Scope-Abfrage leer ist: Fallback auf Default-Branch - HAS=$(echo "${RESP}" | jq -r '(.component.measures | length) // 0' 2>/dev/null) - if [ -z "${HAS}" ] || [ "${HAS}" = "0" ]; then - echo "Scope-Abfrage leer, Fallback auf Default-Branch" - URL="${SONAR_HOST}/api/measures/component?component=${KEY}&metricKeys=${METRICS}" - RESP=$(curl -s -u "${SONAR_TOKEN}:" "${URL}") - SCOPE_DESC="${SCOPE_DESC} → fallback Default-Branch" - fi - # Mini-Diagnose im Log echo "Anfrage: ${URL}" echo "Response (erste 300 Zeichen):" @@ -248,7 +222,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false${SCOPE_PARAM}" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=complexity,cognitive_complexity,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=complexity&asc=false" \ | jq -r '.components[]? | (.path // .key) as $p | ((.measures[]? | select(.metric=="complexity") | .value) // "-") as $c @@ -266,7 +240,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true${SCOPE_PARAM}" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true" \ | jq -r '.components[]? | select(.measures[]? | select(.metric=="coverage")) | (.path // .key) as $p @@ -285,7 +259,7 @@ jobs: } >> "$OUT" curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false${SCOPE_PARAM}" \ + "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=duplicated_blocks,duplicated_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=duplicated_blocks&asc=false" \ | jq -r '.components[]? | (.path // .key) as $p | ((.measures[]? | select(.metric=="duplicated_blocks") | .value) // "0") as $b From 5d072594d9a42fd906954e06a3455f4efa946377 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 11:01:07 +0200 Subject: [PATCH 71/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 89 ++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index ac265e2..9a7ad24 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -134,6 +134,35 @@ jobs: REL_EFF_FMT=$(fmt_debt "$(get reliability_remediation_effort)") SEC_EFF_FMT=$(fmt_debt "$(get security_remediation_effort)") + # ===== Backend-Coverage direkt aus coverage.xml ===== + COV_FILE="project/backend/coverage.xml" + if [ -f "${COV_FILE}" ]; then + COV_DATA=$(python3 - "${COV_FILE}" <<'PYEOF' + import sys, xml.etree.ElementTree as ET + root = ET.parse(sys.argv[1]).getroot() + lr = float(root.get('line-rate', '0')) + br = float(root.get('branch-rate', '0')) + lv = int(root.get('lines-valid', '0')) + lc = int(root.get('lines-covered', '0')) + bv = int(root.get('branches-valid', '0')) + bc = int(root.get('branches-covered', '0')) + total = lv + bv + covered = lc + bc + overall = (covered / total * 100) if total > 0 else 0.0 + uncov_lines = lv - lc + uncov_branches = bv - bc + print(f"{overall:.1f} {lr*100:.1f} {br*100:.1f} {lv} {lc} {bv} {bc} {uncov_lines} {uncov_branches}") + PYEOF + ) + read -r COV_OVERALL COV_LINE COV_BRANCH LINES_VALID LINES_COVERED BRANCHES_VALID BRANCHES_COVERED UNCOVERED_LINES UNCOVERED_BRANCHES <<< "${COV_DATA}" + echo "Backend-Coverage aus coverage.xml: ${COV_OVERALL}% (Line ${COV_LINE}%, Branch ${COV_BRANCH}%)" + else + echo "::warning::coverage.xml nicht gefunden bei ${COV_FILE}" + COV_OVERALL="-"; COV_LINE="-"; COV_BRANCH="-" + LINES_VALID="-"; LINES_COVERED="-"; BRANCHES_VALID="-"; BRANCHES_COVERED="-" + UNCOVERED_LINES="-"; UNCOVERED_BRANCHES="-" + fi + # ===== Summary schreiben ===== { echo "# SonarCloud – Voller Projekt-Report" @@ -158,16 +187,22 @@ jobs: echo "| Statements | $(get statements) |" echo "" - # --- Coverage --- - echo "## Test-Coverage" + # --- Coverage (aus pytest-cov coverage.xml, nicht aus Sonar-API) --- + echo "## Test-Coverage (Backend / Python)" + echo "" + echo "_Werte direkt aus \`backend/coverage.xml\` von pytest-cov gelesen._" echo "" echo "| Metrik | Wert |" echo "|---|---:|" - echo "| Coverage (gesamt) | $(get coverage)% |" - echo "| Line Coverage | $(get line_coverage)% |" - echo "| Branch Coverage | $(get branch_coverage)% |" - echo "| Deckbare Zeilen | $(get lines_to_cover) |" - echo "| Ungedeckte Zeilen | $(get uncovered_lines) |" + echo "| Coverage (gesamt) | ${COV_OVERALL}% |" + echo "| Line Coverage | ${COV_LINE}% |" + echo "| Branch Coverage | ${COV_BRANCH}% |" + echo "| Deckbare Zeilen | ${LINES_VALID} |" + echo "| Gedeckte Zeilen | ${LINES_COVERED} |" + echo "| Ungedeckte Zeilen | ${UNCOVERED_LINES} |" + echo "| Deckbare Branches | ${BRANCHES_VALID} |" + echo "| Gedeckte Branches | ${BRANCHES_COVERED} |" + echo "| Ungedeckte Branches | ${UNCOVERED_BRANCHES} |" echo "" # --- Duplikate --- @@ -233,22 +268,38 @@ jobs: { echo "" - echo "## Top-10 Dateien mit schlechtester Coverage" + echo "## Top-10 Dateien mit schlechtester Coverage (Backend)" + echo "" + echo "_Aus \`backend/coverage.xml\`, sortiert aufsteigend nach Coverage._" echo "" - echo "| Datei | Coverage | Ungedeckte Zeilen | LoC |" + echo "| Datei | Coverage | Gedeckte / Deckbare | Ungedeckte Zeilen |" echo "|---|---:|---:|---:|" } >> "$OUT" - curl -s -u "${SONAR_TOKEN}:" \ - "${SONAR_HOST}/api/measures/component_tree?component=${KEY}&metricKeys=coverage,uncovered_lines,ncloc&qualifiers=FIL&ps=10&s=metric&metricSort=coverage&asc=true" \ - | jq -r '.components[]? - | select(.measures[]? | select(.metric=="coverage")) - | (.path // .key) as $p - | ((.measures[]? | select(.metric=="coverage") | .value) // "-") as $cov - | ((.measures[]? | select(.metric=="uncovered_lines") | .value) // "-") as $unc - | ((.measures[]? | select(.metric=="ncloc") | .value) // "-") as $n - | "| " + $p + " | " + $cov + "% | " + $unc + " | " + $n + " |"' \ - >> "$OUT" + if [ -f "${COV_FILE}" ]; then + python3 - "${COV_FILE}" >> "$OUT" <<'PYEOF' + import sys, xml.etree.ElementTree as ET + root = ET.parse(sys.argv[1]).getroot() + rows = [] + for cls in root.iter('class'): + fname = cls.get('filename', '') + lines = cls.find('lines') + if lines is None: + continue + total = len(lines.findall('line')) + if total == 0: + continue + covered = sum(1 for l in lines.findall('line') if int(l.get('hits', '0')) > 0) + uncov = total - covered + pct = covered / total * 100 + rows.append((pct, fname, covered, total, uncov)) + rows.sort(key=lambda r: (r[0], -r[3])) + for pct, fname, cov, total, uncov in rows[:10]: + print(f"| {fname} | {pct:.1f}% | {cov} / {total} | {uncov} |") + PYEOF + else + echo "_coverage.xml nicht gefunden._" >> "$OUT" + fi { echo "" From da3856b9c5cbb4f6bf900e868a3bcafee8b7ed3f Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 11:07:35 +0200 Subject: [PATCH 72/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index 9a7ad24..035362d 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -268,9 +268,9 @@ jobs: { echo "" - echo "## Top-10 Dateien mit schlechtester Coverage (Backend)" + echo "## Coverage-Übersicht aller Backend-Dateien" echo "" - echo "_Aus \`backend/coverage.xml\`, sortiert aufsteigend nach Coverage._" + echo "_Aus \`backend/coverage.xml\`, sortiert aufsteigend nach Coverage (schlechteste zuerst)._" echo "" echo "| Datei | Coverage | Gedeckte / Deckbare | Ungedeckte Zeilen |" echo "|---|---:|---:|---:|" @@ -293,9 +293,12 @@ jobs: uncov = total - covered pct = covered / total * 100 rows.append((pct, fname, covered, total, uncov)) + # Aufsteigend nach Coverage, bei Gleichstand größere Dateien zuerst rows.sort(key=lambda r: (r[0], -r[3])) - for pct, fname, cov, total, uncov in rows[:10]: + for pct, fname, cov, total, uncov in rows: print(f"| {fname} | {pct:.1f}% | {cov} / {total} | {uncov} |") + print() + print(f"_Insgesamt {len(rows)} Datei(en) mit Coverage-Daten._") PYEOF else echo "_coverage.xml nicht gefunden._" >> "$OUT" From 5cc69dbafcb740b72a2679c903fc2bb1c5de49f3 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Thu, 21 May 2026 11:13:32 +0200 Subject: [PATCH 73/76] Test: Bash Print in CI/CD - Pipeline with detailed information about coverage, duplication and complexity --- .github/workflows/sonarqube-main.yml | 2 -- .github/workflows/sonarqube.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index 035362d..986c1dd 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -1,8 +1,6 @@ name: SonarCloud Full Metrics (main branch) on: - push: - branches: [sonarqube] pull_request: branches: [main, master] workflow_dispatch: diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 4a9a6c5..22ff9bb 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,8 +1,6 @@ name: SonarCloud Analysis on: - push: - branches: [main, master, sonarqube] pull_request: branches: [main, master] workflow_dispatch: From f7278d4c621ea21c49418f444896d71687bd59d7 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 26 May 2026 12:35:10 +0200 Subject: [PATCH 74/76] Fix: Code Preview from Copilot fixes --- README.md | 4 +- project/backend/Auth.py | 4 -- project/backend/Database.py | 10 ++-- project/backend/EmailService.py | 39 -------------- project/backend/Ingredient.py | 1 + project/backend/Recipe.py | 4 +- project/backend/RecipeSUCUK.py | 23 ++++---- project/frontend/app/homepage/homepage.tsx | 1 - .../frontend/app/profile/changeEmailPopup.tsx | 3 +- .../app/profile/changePasswordPopup.tsx | 52 +++++-------------- project/frontend/app/profile/page.tsx | 5 +- project/frontend/lib/auth.tsx | 15 +++--- 12 files changed, 47 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 66a742c..e0db6b5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate 1. Klonen das Repository: `git clone https://github.com/GalacticCodeGambit/LazyCook.git` 2. Navigieren zum Projektverzeichnis: - `cd LazyCook/Project` + `cd LazyCook/project` 3. Starten der Anwendung mit: Docker Compose: `docker compose up --build -d` @@ -34,7 +34,7 @@ Die Anwendung sollte jetzt unter `http://localhost:8000` erreichbar sein. Für die Funktion [#115](https://github.com/GalacticCodeGambit/LazyCook/issues/115) von Email Versenden/Empfangen muss im Ordner `project/` eine `.env` Datei mit den folgenden Variablen angelegt werden: ``` -EMAIL_HOST= +GMAIL_USER= GMAIL_PASSWORD= ``` diff --git a/project/backend/Auth.py b/project/backend/Auth.py index 3c8dc46..2447716 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -16,10 +16,6 @@ PASSWORD_RESET_EXPIRE_MINUTES = 30 -import hashlib - -PASSWORD_RESET_EXPIRE_MINUTES = 30 - from Models import Token, User from Database import getAccountByEmail, saveRefreshToken, getRefreshToken diff --git a/project/backend/Database.py b/project/backend/Database.py index 580ad41..69ad3c9 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -247,7 +247,7 @@ def addRecipe(name: str, description: str, vid: int) -> int: return cur.lastrowid -def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: +def addIngredientToRecipe(zid: int, rid: int, amount: float) -> None: with getDB() as con: cur = con.cursor() cur.execute( @@ -272,8 +272,8 @@ def getIngridientByName(name: str): cur.execute( """ SELECT id, amountType - FROM Recipe - WHERE id = ? + FROM Ingredient + WHERE name = ? """, (name), ) @@ -336,8 +336,8 @@ def getAllocatedRecipes(name: str) -> list[dict]: """ SELECT rid FROM Exists_from - Inner Join Ingredient on rid=id, - Where Exists_from.id = ? + Inner Join Ingredient on rid=id + Where Ingrediant.name = ? """, (name,), ) diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py index d978e0d..324b2b8 100644 --- a/project/backend/EmailService.py +++ b/project/backend/EmailService.py @@ -73,42 +73,3 @@ def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: print(f"E-Mail Fehler: {e}") raise - -def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None: - try: - msg = MIMEMultipart("alternative") - msg["Subject"] = "Passwort zurücksetzen – Lazy Cook" - msg["From"] = gmailUser - msg["To"] = to_email - - html = f""" -
-

Hallo {name},

-

du hast angefordert, dein Passwort bei Lazy Cook zurückzusetzen.

-

Klicke auf den Button, um ein neues Passwort festzulegen:

-

- - Passwort zurücksetzen - -

-

- Oder kopiere diesen Link in deinen Browser:
- {resetLink} -

-

Der Link ist 30 Minuten gültig.

-

Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.

-
-

– Das Lazy Cook Team

-
- """ - msg.attach(MIMEText(html, "html")) - - with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server: - server.login(gmailUser, gmailPassword) - server.sendmail(gmailUser, to_email, msg.as_string()) - - except Exception as e: - print(f"E-Mail Fehler: {e}") - raise diff --git a/project/backend/Ingredient.py b/project/backend/Ingredient.py index 154caba..7014e22 100644 --- a/project/backend/Ingredient.py +++ b/project/backend/Ingredient.py @@ -6,6 +6,7 @@ class Ingredient: def __init__(self, name: str, amount: float): self.__name = name self.__amount = amount + self.__amountType = None def getName(self) -> str: return self.__name diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index b9ce137..5c0ca46 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -16,7 +16,7 @@ def __init__(self, name: str, ingredients: list[Ingredient], description: str): def saveInDB(self) -> bool: rid = addRecipe(self.__name, self.__description, None) for ingridient in self.__ingredients: - zid = getIngridientByName(ingridient.getName()) + zid = getIngridientByName(ingridient.getName())['id'] if not zid: return False else: @@ -59,7 +59,7 @@ def getDuration(self) -> str: def setDuration(self, duration: str): self.__duration = duration - def getingredients(self) -> list[Ingredient]: + def getIngredients(self) -> list[Ingredient]: return self.__ingredients def setIngredient(self, ingredients: list[Ingredient]): diff --git a/project/backend/RecipeSUCUK.py b/project/backend/RecipeSUCUK.py index b41c3e6..d3de8f0 100644 --- a/project/backend/RecipeSUCUK.py +++ b/project/backend/RecipeSUCUK.py @@ -49,14 +49,15 @@ def __formatIngredients(id: int) -> list[Ingredient]: return Ingredients -ini = [] -ini.append(Ingredient("Linguine", 10)) -ini.append(Ingredient("Fresh Basil", 10)) -ini.append(Ingredient("Pine Nuts", 10)) -ini.append(Ingredient("Parmesan", 10)) -ini.append(Ingredient("Ground Beef", 10)) -ini.append(Ingredient("Cumin", 10)) -ini.append(Ingredient("Cucumber", 10)) -arr = findRecipes(ini) -for i in arr: - print(i.getName() + " " + str(i.getRating())) +if __name__ == "__main__": + ini = [] + ini.append(Ingredient("Linguine", 10)) + ini.append(Ingredient("Fresh Basil", 10)) + ini.append(Ingredient("Pine Nuts", 10)) + ini.append(Ingredient("Parmesan", 10)) + ini.append(Ingredient("Ground Beef", 10)) + ini.append(Ingredient("Cumin", 10)) + ini.append(Ingredient("Cucumber", 10)) + arr = findRecipes(ini) + for i in arr: + print(i.getName() + " " + str(i.getRating())) diff --git a/project/frontend/app/homepage/homepage.tsx b/project/frontend/app/homepage/homepage.tsx index cb21ae5..52ff7bf 100644 --- a/project/frontend/app/homepage/homepage.tsx +++ b/project/frontend/app/homepage/homepage.tsx @@ -28,7 +28,6 @@ export default function Homepage() { diff --git a/project/frontend/app/profile/changeEmailPopup.tsx b/project/frontend/app/profile/changeEmailPopup.tsx index 1d1dd3b..b473678 100644 --- a/project/frontend/app/profile/changeEmailPopup.tsx +++ b/project/frontend/app/profile/changeEmailPopup.tsx @@ -3,7 +3,6 @@ import {fetchWithAuth} from "@/lib/auth"; import {useState} from "react"; import "../recipeFinder/style.css" -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function ChangeEmail (){ @@ -13,7 +12,7 @@ export default function ChangeEmail (){ async function handleEmailChange() { setEmailMsg(""); try { - const res = await fetchWithAuth(`${API_URL}/users/me`, { + const res = await fetchWithAuth(`/users/me`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: newEmail }), diff --git a/project/frontend/app/profile/changePasswordPopup.tsx b/project/frontend/app/profile/changePasswordPopup.tsx index 0eccf7f..12423c8 100644 --- a/project/frontend/app/profile/changePasswordPopup.tsx +++ b/project/frontend/app/profile/changePasswordPopup.tsx @@ -5,14 +5,12 @@ import {Eye, EyeOff} from "lucide-react"; import "../recipeFinder/style.css" import Field from "@/app/components/fields"; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; - function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: { - value: string; - onChange: (v: string) => void; - placeholder: string; - onKeyDown?: (e: React.KeyboardEvent) => void; - ariaLabel?: string; + readonly value: string; + readonly onChange: (v: string) => void; + readonly placeholder: string; + readonly onKeyDown?: (e: React.KeyboardEvent) => void; + readonly ariaLabel?: string; }) { const [show, setShow] = useState(false); return ( @@ -52,14 +50,11 @@ function PasswordInput({ value, onChange, placeholder, onKeyDown, ariaLabel }: { ); } -type Modus = "change" | "forgot"; - interface ChangePasswordProps { - modus: Modus; onSuccess?: () => void; } -export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps) { +export default function ChangePassword({ onSuccess }: Readonly) { const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -67,13 +62,10 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps const [passwordMsg, setPasswordMsg] = useState(""); const [pwBlurred, setPwBlurred] = useState(false); - const isForgot = modus === "forgot"; - async function handlePasswordChange() { setPasswordMsg(""); - // Pflichtfelder im "change"-Modus - if (!isForgot && !currentPassword) { + if (!currentPassword) { setPasswordMsg("Bitte das aktuelle Passwort eingeben."); return; } @@ -86,27 +78,16 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return; } - // Bestätigung prüfen if (newPassword !== confirmPassword) { setPasswordMsg("Die Passwörter stimmen nicht überein."); return; } try { - const endpoint = isForgot - ? `${API_URL}/users/forgot-password` - : `${API_URL}/users/me`; - - const body = isForgot - ? { newPassword } - : { currentPassword, newPassword }; - - const fetcher = isForgot ? fetch : fetchWithAuth; - - const res = await fetcher(endpoint, { + const res = await fetchWithAuth("/users/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify({ currentPassword, newPassword }), }); if (!res.ok) { @@ -115,11 +96,7 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return; } - setPasswordMsg( - isForgot - ? "Passwort erfolgreich zurückgesetzt." - : "Passwort erfolgreich geändert." - ); + setPasswordMsg("Passwort erfolgreich geändert."); setCurrentPassword(""); setNewPassword(""); setConfirmPassword(""); @@ -131,13 +108,10 @@ export default function ChangePassword({ modus, onSuccess }: ChangePasswordProps return (
-

- {isForgot ? "Passwort zurücksetzen" : "Passwort ändern"} -

+

Passwort ändern

- {!isForgot && ( - setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} />)} + setPwBlurred(true)} onKeyDown={(e) => e.key === "Enter" && handlePasswordChange()} state={pwBlurred && !currentPassword? "error" : "default"} /> - {isForgot ? "Zurücksetzen" : "Speichern"} + Speichern
diff --git a/project/frontend/app/profile/page.tsx b/project/frontend/app/profile/page.tsx index a6be68f..98df4a6 100644 --- a/project/frontend/app/profile/page.tsx +++ b/project/frontend/app/profile/page.tsx @@ -12,7 +12,6 @@ import ChangePassword from "@/app/profile/changePasswordPopup"; import "../recipeFinder/style.css" -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; export default function Profile() { const { user, loading, logout } = useAuth(); @@ -40,7 +39,7 @@ export default function Profile() { if (!user) return null; async function handleAccountDeletion() { - const res = await fetchWithAuth(`${API_URL}/users/me`, { method: "DELETE" }); + const res = await fetchWithAuth(`/users/me`, { method: "DELETE" }); if (!res.ok) throw new Error("Konto löschen fehlgeschlagen"); logout(); router.push("/"); @@ -124,7 +123,7 @@ export default function Profile() {
setShowPasswordModal(false)}> - + ); diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 64c79d9..828fe7c 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -6,6 +6,7 @@ import { useState, useEffect, useCallback, + useMemo, type ReactNode, } from "react"; @@ -108,7 +109,8 @@ async function fetchWithAuth(url: string, options: RequestInit = {}): Promise(null); -export function AuthProvider({ children }: { children: ReactNode }) { +export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); @@ -195,8 +197,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); } }; - window.addEventListener("storage", handleStorageChange); - return () => window.removeEventListener("storage", handleStorageChange); + globalThis.addEventListener("storage", handleStorageChange); + return () => globalThis.removeEventListener("storage", handleStorageChange); }, []); const login = useCallback(async (email: string, password: string) => { @@ -220,8 +222,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(null); }, []); + const obj = useMemo(() => ({ user, loading, login, register, logout }), []); return ( - + {children} ); From 24359c21510baf6a095ccf2fb1d8855b8147cee5 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 26 May 2026 12:39:39 +0200 Subject: [PATCH 75/76] Fix: Code Preview from Copilot fixes --- project/frontend/lib/auth.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 828fe7c..0fc4026 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -222,9 +222,8 @@ export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { setUser(null); }, []); - const obj = useMemo(() => ({ user, loading, login, register, logout }), []); - return ( - + return ( + {children} ); From b3b683ac05ef0bc45f3240400d706d1f3918a175 Mon Sep 17 00:00:00 2001 From: Eden Bernhard Date: Tue, 26 May 2026 13:11:33 +0200 Subject: [PATCH 76/76] Fix: Code Preview from Copilot fixes --- .github/workflows/sonarqube-main.yml | 15 ++++++++++----- .github/workflows/sonarqube.yml | 13 +++++++++---- project/backend/Database.py | 2 +- project/backend/Recipe.py | 8 ++++---- project/frontend/lib/auth.tsx | 3 +-- project/sonar-project.properties | 4 ++-- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/sonarqube-main.yml b/.github/workflows/sonarqube-main.yml index 986c1dd..ef8c1cc 100644 --- a/.github/workflows/sonarqube-main.yml +++ b/.github/workflows/sonarqube-main.yml @@ -1,7 +1,7 @@ name: SonarCloud Full Metrics (main branch) on: - pull_request: + push: branches: [main, master] workflow_dispatch: @@ -19,13 +19,19 @@ jobs: with: fetch-depth: 0 + - name: Python-Version aus Dockerfile lesen + id: pyver + run: | + VERSION=$(grep -oP '(?<=python:)\d+\.\d+' project/Dockerfile-backend | head -1) + echo "version=$VERSION" >> $GITHUB_OUTPUT + # ===================================================== # Backend-Tests + Coverage # ===================================================== - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: ${{ steps.pyver.outputs.version }} - name: Install backend deps working-directory: project/backend @@ -56,15 +62,14 @@ jobs: -Dsonar.organization=galacticcodegambit -Dproject.settings=sonar-project.properties -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.python.version=3.10 + -Dsonar.python.version=${{ steps.pyver.outputs.version }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: Wait for analysis id: qg - uses: SonarSource/sonarqube-quality-gate-action@master + uses: SonarSource/sonarqube-quality-gate-action@v1.2.0 timeout-minutes: 5 - continue-on-error: true with: scanMetadataReportFile: project/.scannerwork/report-task.txt env: diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 22ff9bb..e2bc9c3 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -19,13 +19,19 @@ with: fetch-depth: 0 + - name: Python-Version aus Dockerfile lesen + id: pyver + run: | + VERSION=$(grep -oP '(?<=python:)\d+\.\d+' project/Dockerfile-backend | head -1) + echo "version=$VERSION" >> $GITHUB_OUTPUT + # ===================================================== # BACKEND (Python / FastAPI) – Tests + Coverage # ===================================================== - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: ${{ steps.pyver.outputs.version }} - name: Install backend dependencies working-directory: project/backend @@ -56,16 +62,15 @@ -Dsonar.organization=galacticcodegambit -Dproject.settings=sonar-project.properties -Dsonar.python.coverage.reportPaths=backend/coverage.xml - -Dsonar.python.version=3.10 + -Dsonar.python.version=${{ steps.pyver.outputs.version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - name: SonarCloud Quality Gate check id: sonar-qg - uses: SonarSource/sonarqube-quality-gate-action@master + uses: SonarSource/sonarqube-quality-gate-action@v1.2.0 timeout-minutes: 5 - continue-on-error: true with: scanMetadataReportFile: project/.scannerwork/report-task.txt env: diff --git a/project/backend/Database.py b/project/backend/Database.py index 69ad3c9..6fc2b65 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -275,7 +275,7 @@ def getIngridientByName(name: str): FROM Ingredient WHERE name = ? """, - (name), + (name,), ) row = cur.fetchone() return dict(row) if row else None diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index 5c0ca46..356012a 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -16,11 +16,11 @@ def __init__(self, name: str, ingredients: list[Ingredient], description: str): def saveInDB(self) -> bool: rid = addRecipe(self.__name, self.__description, None) for ingridient in self.__ingredients: - zid = getIngridientByName(ingridient.getName())['id'] - if not zid: + result = getIngridientByName(ingridient.getName()) + if not result: return False - else: - addIngredientToRecipe(zid, rid, ingridient.getAmount()) + zid = result['id'] + addIngredientToRecipe(zid, rid, ingridient.getAmount()) return True def getName(self) -> str: diff --git a/project/frontend/lib/auth.tsx b/project/frontend/lib/auth.tsx index 0fc4026..470fb1b 100644 --- a/project/frontend/lib/auth.tsx +++ b/project/frontend/lib/auth.tsx @@ -6,7 +6,6 @@ import { useState, useEffect, useCallback, - useMemo, type ReactNode, } from "react"; @@ -222,7 +221,7 @@ export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { setUser(null); }, []); - return ( + return ( {children} diff --git a/project/sonar-project.properties b/project/sonar-project.properties index 78c8d4b..1252ae3 100644 --- a/project/sonar-project.properties +++ b/project/sonar-project.properties @@ -7,13 +7,13 @@ sonar.projectVersion=1.0 # Quellen + Tests sonar.sources=backend,frontend/app sonar.tests=backend/tests -sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/.next/**,frontend/app/components/ui/**,**/*.config.*,**/coverage/**,backend/tests/** +sonar.exclusions=**/node_modules/**,**/__pycache__/**,**/.next/**,frontend/app/components/ui/**,**/*.config.*,**/coverage/** # Encoding sonar.sourceEncoding=UTF-8 # Python -sonar.python.version=3.10 +sonar.python.version=3.11 sonar.python.coverage.reportPaths=backend/coverage.xml # CPD-Schwellwerte auf das absolute Minimum gestellt: