diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9d0d0..43bbbc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,32 +55,3 @@ jobs: - name: Run backend tests run: | docker compose -f project/compose.yaml exec -T backend-dev sh -c "python -m pytest tests/ -v" - - linter: - name: Lint code base - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Run GitHub Super Linter - uses: super-linter/super-linter/slim@v8.6.0 - 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/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ac275e3 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: "Lint code base" + +on: + pull_request: + branches: [main, 'blatt*'] + push: + branches: [main, 'blatt*'] + +permissions: + contents: read + +jobs: + linter: + name: Lint code base + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run GitHub Super Linter + uses: super-linter/super-linter/slim@v8.6.0 + 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 5beb5c9..66a742c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # LazyCook -[![ci](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml) +[![ci.yml](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/ci.yml) +[![lint.yml](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/lint.yml/badge.svg)](https://github.com/GalacticCodeGambit/LazyCook/actions/workflows/lint.yml) LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutaten einzutragen und darauf soll LazyCook dir mögliche Rezepte/Gerichte vorschlagen die du aus diesen Zutaten gemacht werden können. @@ -21,6 +22,22 @@ LazyCook soll eine Webanwendung sein. Die es ermöglichen seine vorhanden Zutate - [x] [#47](https://github.com/GalacticCodeGambit/LazyCook/issues/47) Der Nutzer kann bereits hinzugefügte Zutaten bearbeiten, wie z.B. Menge, Einheit - [x] [#45](https://github.com/GalacticCodeGambit/LazyCook/issues/45) Passwörter "sicher" in Datenbank Abspeichern als Hash und vom Frontend/Backend "sicher" behandeln + ## Installation und 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. + +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 ``` diff --git a/docs/Review-Protokoll-01.md b/docs/Review-Protokoll-01.md new file mode 100644 index 0000000..5ce09da --- /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: PrussianBaron +- Zeitwächter: Nicoolaus +- Notizen: GalacticCodeGambit +- Teilnehmer: EdenBernhard, Hellocrafting + +### **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) 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 diff --git a/project/backend/Auth.py b/project/backend/Auth.py index bbe28e5..3c8dc46 100644 --- a/project/backend/Auth.py +++ b/project/backend/Auth.py @@ -11,7 +11,14 @@ from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt import bcrypt -from pydantic import BaseModel + +import hashlib + +PASSWORD_RESET_EXPIRE_MINUTES = 30 + +import hashlib + +PASSWORD_RESET_EXPIRE_MINUTES = 30 from Models import Token, User @@ -132,3 +139,37 @@ 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["usedAt"] is not None: + return None + expiresAt = datetime.fromisoformat(entry["expiresAt"]) + if expiresAt.tzinfo is None: + expiresAt = expiresAt.replace(tzinfo=timezone.utc) + if expiresAt < datetime.now(timezone.utc): + return None + return entry diff --git a/project/backend/Database.py b/project/backend/Database.py index 5d35f3a..580ad41 100644 --- a/project/backend/Database.py +++ b/project/backend/Database.py @@ -14,8 +14,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 journal_mode = WAL") con.execute("PRAGMA foreign_keys = ON") - # con.execute("PRAGMA journal_mode = WAL") return con @@ -61,7 +61,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,7 +79,8 @@ def initDB(): CREATE TABLE IF NOT EXISTS Recipe ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, - vid INTEGER NOT NULL, + description nvarchar(20000), + vid INTEGER, FOREIGN KEY (vid) REFERENCES Author (id) ON DELETE CASCADE ) """) @@ -89,7 +90,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) ) @@ -104,6 +105,30 @@ 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, + 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") @@ -210,3 +235,324 @@ 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: + cur = con.cursor() + cur.execute( + "INSERT INTO Recipe (name, description) VALUES (?, ?)", + (name, description), + ) + return cur.lastrowid + + +def addIngredientToRecipe(zid: int, rid: int, amount: float) -> int: + 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) -> int: + with getDB() as con: + cur = con.cursor() + cur.execute( + "INSERT INTO Ingredient (name, amountType) VALUES (?, ?)", + (name, amountType), + ) + return cur.lastrowid + + +def getIngridientByName(name: str): + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT id, amountType + FROM Recipe + WHERE id = ? + """, + (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( + """ + 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 getAllIngredientsForRecipe(rid: int): + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + 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] + + +def getAllocatedRecipes(name: str) -> list[dict]: + + with getDB() as con: + cur = con.cursor() + cur.execute( + """ + SELECT rid + FROM Exists_from + Inner Join Ingredient on rid=id, + Where Exists_from.id = ? + """, + (name,), + ) + rows = cur.fetchall() + return [dict(row) for row in rows] + + +""" +# --- 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) +""" + + +# ── 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 usedAt = 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 = ?", + (tokenID,), + ) + + +def updateKontoPassword(konto_id: int, hashed_password: str) -> None: + with getDB() as con: + cur = con.cursor() + cur.execute( + "UPDATE Account SET hashedPassword = ? WHERE id = ?", + (hashed_password, konto_id), + ) + + +# ── 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() + 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() diff --git a/project/backend/EmailService.py b/project/backend/EmailService.py index 96d1a23..d978e0d 100644 --- a/project/backend/EmailService.py +++ b/project/backend/EmailService.py @@ -32,3 +32,83 @@ def sendPasswordChangedEmail(to_email: str, name: str) -> None: 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 + + +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 new file mode 100644 index 0000000..154caba --- /dev/null +++ b/project/backend/Ingredient.py @@ -0,0 +1,36 @@ +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 + + def setName(self, name: str): + self.__name = name + + def getAmount(self) -> float: + return self.__amount + + 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 + else: + zid = addIngredient(self.__name, self.__amountType) + if not zid: + return False + else: + return True diff --git a/project/backend/Ingridient.py b/project/backend/Ingridient.py deleted file mode 100644 index 232b9f3..0000000 --- a/project/backend/Ingridient.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import List, Dict, Any - - -class Ingridient: - def __init__(self, name: str, amount: float): - self.__name = name - self.__amount = amount - - def getName(self) -> str: - return self.__name - - def setName(self, name: str): - self.__name = name - - def getAmount(self) -> float: - return self.__amount - - def setAmount(self, amount: float): - self.__amount = amount diff --git a/project/backend/LazyCookAdministration.py b/project/backend/LazyCookAdministration.py index bb0802c..9e38512 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/Models.py b/project/backend/Models.py index 5e15029..462b9db 100644 --- a/project/backend/Models.py +++ b/project/backend/Models.py @@ -4,6 +4,7 @@ class Token(BaseModel): access_token: str + refresh_token: str token_type: str @@ -24,3 +25,29 @@ 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 + + +class IngredientSearch(BaseModel): + name: str + amount: float + unit: str + + +class RecipeSearchRequest(BaseModel): + zutaten: list[IngredientSearch] + servings: int diff --git a/project/backend/Recipe.py b/project/backend/Recipe.py index a255948..b9ce137 100644 --- a/project/backend/Recipe.py +++ b/project/backend/Recipe.py @@ -1,53 +1,66 @@ -from Database import Database -from Ingridient import Ingridient +from Ingredient import Ingredient +from Database import addIngredientToRecipe, addRecipe, getIngridientByName 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.__Ingridienten = ingridients + self.__ingredients = ingredients self.__description = description self.__original = "" self.__duration = "" - self.__rating = 0 + self.__rating = 0.0 self.__countPersons = 1 - self.__database = Database() + self.__matching = 0 def saveInDB(self) -> bool: + rid = addRecipe(self.__name, self.__description, None) + for ingridient in self.__ingredients: + zid = getIngridientByName(ingridient.getName()) + if not zid: + return False + else: + addIngredientToRecipe(zid, rid, ingridient.getAmount()) 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 + + def setRating(self, rating: float): + self.__rating = rating - def getRating(self) -> int: - return self.rating + def getMatching(self) -> int: + return self.__matching - def setRating(self, rating: int): - self.rating = rating + 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 getIngridient(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 new file mode 100644 index 0000000..b41c3e6 --- /dev/null +++ b/project/backend/RecipeSUCUK.py @@ -0,0 +1,62 @@ +# SUCUK = Search for Uncomplicated Cooking and User-friendly Kitchen recipes +import Database +from Ingredient import Ingredient +from Recipe import Recipe +from Database import getAllRecipes, getAllIngredientsForRecipe + + +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) + + if len(recipes) > 98: + return recipes[:98] + + return recipes + + +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())) + return recipes + + +def __initRecipes() -> list[Recipe]: + recipesRaw = getAllRecipes() + recipes = [] + for recipeRaw in recipesRaw: + recipe = Recipe( + recipeRaw["name"], + __formatIngredients(recipeRaw["id"]), + recipeRaw["description"], + ) + recipes.append(recipe) + return recipes + + +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())) diff --git a/project/backend/Routes.py b/project/backend/Routes.py index 1850bc8..868108d 100644 --- a/project/backend/Routes.py +++ b/project/backend/Routes.py @@ -1,8 +1,8 @@ +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 -from pydantic import BaseModel as _BaseModel +from EmailService import sendPasswordChangedEmail, sendPasswordResetEmail from Auth import ( @@ -13,18 +13,37 @@ validatePassword, validateRefreshToken, verifyPassword, - createAccessToken, + createPasswordResetToken, + validatePasswordResetToken, ) from Database import ( createAccount, getAccountByEmail, + getAccountById, deleteRefreshToken, deleteAllRefreshTokens, deleteAccount, updateAccount, + markResetTokenUsed, + updateKontoPassword, + incrementIngredientUsage, + getTopIngredients, ) -from Models import User, Token, UserCreate, RefreshRequest, LogoutRequest +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") router = APIRouter() @@ -79,7 +98,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) @@ -107,12 +126,6 @@ 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( data: UpdateUser, currentUser: Annotated[User, Depends(getCurrentUser)] @@ -129,7 +142,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: @@ -146,3 +159,111 @@ 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"{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."} + + +@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["kontoID"], hashPassword(body.new_password)) + markResetTokenUsed(entry["id"]) + # Sicherheitsmaßnahme: Alle aktiven Sessions ungültig machen + 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."} + + +# ── 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/backend/SearchRecipeNames.py b/project/backend/SearchRecipeNames.py new file mode 100644 index 0000000..1b51649 --- /dev/null +++ b/project/backend/SearchRecipeNames.py @@ -0,0 +1,19 @@ +from Database import getAllRecipes, getAllIngredientsForRecipe +from Ingredient import Ingredient +from Recipe import Recipe + +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(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 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/components/fields.tsx b/project/frontend/app/components/fields.tsx index 9e3fdae..9227038 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,40 @@ 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/components/markdown-renderer.tsx b/project/frontend/app/components/markdown-renderer.tsx new file mode 100644 index 0000000..e64acf9 --- /dev/null +++ b/project/frontend/app/components/markdown-renderer.tsx @@ -0,0 +1,57 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { ComponentPropsWithoutRef } from "react"; + +type MarkdownRendererProps = { + content: string; +}; + +const markdownComponents = { + h1: (props: ComponentPropsWithoutRef<"h1">) => ( +

+ ), + h2: (props: ComponentPropsWithoutRef<"h2">) => ( +

+ ), + h3: (props: ComponentPropsWithoutRef<"h3">) => ( +

+ ), + p: (props: ComponentPropsWithoutRef<"p">) => ( +

+ ), + ul: (props: ComponentPropsWithoutRef<"ul">) => ( +