Compare commits
15 Commits
main
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4305d104e5 | ||
|
|
5b00036951 | ||
|
|
26e8b2cd0c | ||
|
|
834a3bf4d5 | ||
|
|
905115d115 | ||
|
|
f4b1f1e30c | ||
|
|
b58edfc6eb | ||
|
|
5d9d517d18 | ||
|
|
d1831955c7 | ||
|
|
1abf4a647d | ||
|
|
c2eb905d62 | ||
|
|
6af5df32f6 | ||
|
|
80c73595d2 | ||
|
|
cd7a3f7414 | ||
|
|
3dceae930c |
62
.claude/scripts/bump.sh
Normal file
62
.claude/scripts/bump.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# bump.sh – Version erhöhen, CHANGELOG aktualisieren, committen und pushen
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# bash .claude/scripts/bump.sh patch "Beschreibung der Änderung"
|
||||||
|
# bash .claude/scripts/bump.sh minor "Beschreibung der Änderung"
|
||||||
|
# bash .claude/scripts/bump.sh major "Beschreibung der Änderung"
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUMP_TYPE="${1:-patch}"
|
||||||
|
MESSAGE="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$MESSAGE" ]]; then
|
||||||
|
echo "Fehler: Beschreibung fehlt."
|
||||||
|
echo "Verwendung: bash .claude/scripts/bump.sh [patch|minor|major] \"Beschreibung\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
VERSION_FILE="$ROOT/VERSION"
|
||||||
|
CHANGELOG_FILE="$ROOT/CHANGELOG.md"
|
||||||
|
|
||||||
|
# Aktuelle Version lesen
|
||||||
|
CURRENT=$(cat "$VERSION_FILE" | tr -d '[:space:]')
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
|
|
||||||
|
# Version erhöhen
|
||||||
|
case "$BUMP_TYPE" in
|
||||||
|
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||||
|
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||||
|
patch) PATCH=$((PATCH + 1)) ;;
|
||||||
|
*)
|
||||||
|
echo "Fehler: Ungültiger Typ '$BUMP_TYPE'. Erlaubt: patch, minor, major"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
echo "Bump: $CURRENT → $NEW_VERSION ($BUMP_TYPE)"
|
||||||
|
|
||||||
|
# VERSION aktualisieren
|
||||||
|
echo "$NEW_VERSION" > "$VERSION_FILE"
|
||||||
|
|
||||||
|
# CHANGELOG-Eintrag einfügen (nach der ersten ---)
|
||||||
|
ENTRY="## [$NEW_VERSION] - $DATE\n\n### Changed\n- $MESSAGE\n\n---\n"
|
||||||
|
# Füge nach der ersten '---' Zeile ein
|
||||||
|
awk -v entry="$ENTRY" '/^---$/ && !inserted { print; printf "%s", entry; inserted=1; next } { print }' \
|
||||||
|
"$CHANGELOG_FILE" > "$CHANGELOG_FILE.tmp" && mv "$CHANGELOG_FILE.tmp" "$CHANGELOG_FILE"
|
||||||
|
|
||||||
|
# Commit und Push
|
||||||
|
git -C "$ROOT" add "$VERSION_FILE" "$CHANGELOG_FILE"
|
||||||
|
git -C "$ROOT" commit -m "chore: bump version to $NEW_VERSION
|
||||||
|
|
||||||
|
$MESSAGE
|
||||||
|
|
||||||
|
Version: $NEW_VERSION"
|
||||||
|
git -C "$ROOT" push
|
||||||
|
|
||||||
|
echo "Fertig: Version $NEW_VERSION committed und gepusht."
|
||||||
21
.claude/scripts/git-commit.sh
Normal file
21
.claude/scripts/git-commit.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# git-commit.sh – Stage all, commit, push
|
||||||
|
# Verwendung: bash .claude/scripts/git-commit.sh "commit message" [minor|patch(default)]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MESSAGE="${1:-}"
|
||||||
|
BUMP="${2:-patch}"
|
||||||
|
|
||||||
|
if [[ -z "$MESSAGE" ]]; then
|
||||||
|
echo "Verwendung: bash .claude/scripts/git-commit.sh \"message\" [patch|minor]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "$MESSAGE
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
git push
|
||||||
61
.claude/scripts/git-pr.sh
Normal file
61
.claude/scripts/git-pr.sh
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# git-pr.sh – Pull-Request via Gitea API erstellen und optional mergen
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# bash .claude/scripts/git-pr.sh create <head> <base> "<title>" ["<body>"]
|
||||||
|
# bash .claude/scripts/git-pr.sh merge <pr_number> [squash|merge|rebase]
|
||||||
|
# bash .claude/scripts/git-pr.sh list [open|closed]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="Admin/gartenmanager"
|
||||||
|
API="https://tea.jr-family.de/api/v1"
|
||||||
|
|
||||||
|
get_token() {
|
||||||
|
git credential fill <<'EOF' | grep "^password=" | cut -d= -f2-
|
||||||
|
protocol=https
|
||||||
|
host=tea.jr-family.de
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
CMD="${1:-}"
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
create)
|
||||||
|
HEAD="${2:?'head branch fehlt'}"
|
||||||
|
BASE="${3:?'base branch fehlt'}"
|
||||||
|
TITLE="${4:?'titel fehlt'}"
|
||||||
|
BODY="${5:-}"
|
||||||
|
TOKEN=$(get_token)
|
||||||
|
RESULT=$(curl -s -X POST "$API/repos/$REPO/pulls" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"head\":\"$HEAD\",\"base\":\"$BASE\",\"title\":\"$TITLE\",\"body\":\"$BODY\"}")
|
||||||
|
PR_NUM=$(echo "$RESULT" | python3 -c "import sys,json; r=json.load(sys.stdin); print(r.get('number','ERR: '+str(r.get('message',''))))")
|
||||||
|
echo "PR #$PR_NUM erstellt: $TITLE ($HEAD → $BASE)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
merge)
|
||||||
|
PR_NUM="${2:?'PR-Nummer fehlt'}"
|
||||||
|
STYLE="${3:-squash}"
|
||||||
|
TOKEN=$(get_token)
|
||||||
|
curl -s -X POST "$API/repos/$REPO/pulls/$PR_NUM/merge" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"Do\":\"$STYLE\"}"
|
||||||
|
echo "PR #$PR_NUM gemergt ($STYLE)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
STATE="${2:-open}"
|
||||||
|
TOKEN=$(get_token)
|
||||||
|
curl -s "$API/repos/$REPO/pulls?state=$STATE&limit=20" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" | \
|
||||||
|
python3 -c "import sys,json; [print(f\"#{r['number']} [{r['state']}] {r['title']} ({r['head']['label']} → {r['base']['label']})\") for r in json.load(sys.stdin)]"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Verwendung: bash .claude/scripts/git-pr.sh [create|merge|list] ..."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
36
.claude/scripts/git-switch.sh
Normal file
36
.claude/scripts/git-switch.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# git-switch.sh – Branch wechseln oder erstellen, remote aktualisieren
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# bash .claude/scripts/git-switch.sh <branch> – wechseln (pull wenn vorhanden)
|
||||||
|
# bash .claude/scripts/git-switch.sh <branch> create – neu aus aktuellem Branch
|
||||||
|
# bash .claude/scripts/git-switch.sh <branch> from <base> – neu aus <base>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BRANCH="${1:?'Branch-Name fehlt'}"
|
||||||
|
MODE="${2:-switch}"
|
||||||
|
BASE="${3:-}"
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
switch)
|
||||||
|
git fetch origin "$BRANCH" 2>/dev/null || true
|
||||||
|
git checkout "$BRANCH" 2>/dev/null || git checkout -b "$BRANCH"
|
||||||
|
git pull origin "$BRANCH" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
create)
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
git push -u origin "$BRANCH"
|
||||||
|
;;
|
||||||
|
from)
|
||||||
|
BASE="${3:?'Basis-Branch fehlt'}"
|
||||||
|
git fetch origin "$BASE"
|
||||||
|
git checkout -b "$BRANCH" "origin/$BASE"
|
||||||
|
git push -u origin "$BRANCH"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Aktiver Branch: $(git branch --show-current)"
|
||||||
11
.claude/scripts/git-sync.sh
Normal file
11
.claude/scripts/git-sync.sh
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# git-sync.sh – aktuellen Branch mit Remote synchronisieren (pull + push)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
git pull origin "$BRANCH"
|
||||||
|
git push origin "$BRANCH"
|
||||||
|
echo "Synchronisiert: $BRANCH"
|
||||||
41
.claude/scripts/new-feature.sh
Normal file
41
.claude/scripts/new-feature.sh
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# new-feature.sh – Feature- oder Fix-Branch aus develop erstellen und pushen
|
||||||
|
#
|
||||||
|
# Verwendung:
|
||||||
|
# bash .claude/scripts/new-feature.sh feature <name>
|
||||||
|
# bash .claude/scripts/new-feature.sh fix <name>
|
||||||
|
# bash .claude/scripts/new-feature.sh debug <name>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TYPE="${1:-}"
|
||||||
|
NAME="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$TYPE" || -z "$NAME" ]]; then
|
||||||
|
echo "Verwendung: bash .claude/scripts/new-feature.sh [feature|fix|debug] <name>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$TYPE" in
|
||||||
|
feature|fix|debug) ;;
|
||||||
|
*)
|
||||||
|
echo "Fehler: Ungültiger Typ '$TYPE'. Erlaubt: feature, fix, debug"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BRANCH="$TYPE/$NAME"
|
||||||
|
ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# Sicherstellen dass develop aktuell ist
|
||||||
|
echo "Wechsle zu develop und aktualisiere..."
|
||||||
|
git -C "$ROOT" checkout develop
|
||||||
|
git -C "$ROOT" pull origin develop
|
||||||
|
|
||||||
|
# Branch erstellen und pushen
|
||||||
|
echo "Erstelle Branch: $BRANCH"
|
||||||
|
git -C "$ROOT" checkout -b "$BRANCH"
|
||||||
|
git -C "$ROOT" push -u origin "$BRANCH"
|
||||||
|
|
||||||
|
echo "Fertig: Branch '$BRANCH' erstellt und gepusht."
|
||||||
|
echo "Aktiver Branch: $(git -C "$ROOT" branch --show-current)"
|
||||||
63
.claude/session-context.md
Normal file
63
.claude/session-context.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Session-Kontext
|
||||||
|
|
||||||
|
> Claude liest diese Datei zu Beginn jeder Session.
|
||||||
|
> Claude aktualisiert sie am Ende jeder Session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktueller Stand
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| **Version** | 0.3.0 |
|
||||||
|
| **Aktiver Branch** | feature/phase-1 |
|
||||||
|
| **Basis-Branch** | develop |
|
||||||
|
| **Zuletzt geändert** | 2026-04-06 |
|
||||||
|
|
||||||
|
## Phase 1 – Status: ABGESCHLOSSEN ✓
|
||||||
|
|
||||||
|
Alle Dateien implementiert und gepusht. System ist startbereit.
|
||||||
|
|
||||||
|
## Offene Arbeit – nächste Session
|
||||||
|
|
||||||
|
Phase 1 ist fertig. Nächste Schritte nach Rücksprache mit Nutzer:
|
||||||
|
|
||||||
|
1. **System testen** – `docker compose -f docker-compose.dev.yml up` ausführen und manuell prüfen
|
||||||
|
2. **Ersten Superadmin anlegen** – Es gibt noch kein UI dafür, muss per DB-Insert oder API-Skript erfolgen
|
||||||
|
3. **Phase 2 starten** – Testing & CI/CD (Gitea Actions, pytest, Vitest, Playwright)
|
||||||
|
|
||||||
|
## Hinweis: Superadmin erstellen
|
||||||
|
|
||||||
|
Noch kein UI vorhanden. Seed-Skript oder direkt per Python:
|
||||||
|
```bash
|
||||||
|
docker compose exec backend python3 -c "
|
||||||
|
import asyncio
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
async def create():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
user = User(id=uuid.uuid4(), email='admin@example.com',
|
||||||
|
hashed_password=get_password_hash('changeme'),
|
||||||
|
full_name='Superadmin', is_superadmin=True)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
print('Superadmin erstellt.')
|
||||||
|
asyncio.run(create())
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellreferenz
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Entwicklungsumgebung starten
|
||||||
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
# Version bumpen
|
||||||
|
bash .claude/scripts/bump.sh patch "Was wurde geändert"
|
||||||
|
|
||||||
|
# Neuen Branch erstellen
|
||||||
|
bash .claude/scripts/new-feature.sh feature <name>
|
||||||
|
```
|
||||||
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Kopiere diese Datei nach .env und passe die Werte an.
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
POSTGRES_USER=gartenmanager
|
||||||
|
POSTGRES_PASSWORD=sicheres_passwort_aendern
|
||||||
|
POSTGRES_DB=gartenmanager
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
DATABASE_URL=postgresql+asyncpg://gartenmanager:sicheres_passwort_aendern@db:5432/gartenmanager
|
||||||
|
SECRET_KEY=bitte_aendern_langer_zufaelliger_string_min_32_zeichen
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
CORS_ORIGINS=["http://localhost", "http://localhost:80"]
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
35
.gitattributes
vendored
Normal file
35
.gitattributes
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Automatische Zeilenendenormalisierung
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# Explizit Text-Dateien auf LF normieren
|
||||||
|
*.md text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.xml text eol=lf
|
||||||
|
*.html text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.scss text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.tsx text eol=lf
|
||||||
|
*.jsx text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.env text eol=lf
|
||||||
|
|
||||||
|
# Binärdateien – kein Zeilenenden-Handling
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.svg binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.pdf binary
|
||||||
|
*.zip binary
|
||||||
28
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
28
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Beschreibung
|
||||||
|
|
||||||
|
<!-- Was wurde geändert und warum? -->
|
||||||
|
|
||||||
|
## Typ der Änderung
|
||||||
|
|
||||||
|
- [ ] `feat` – neues Feature
|
||||||
|
- [ ] `fix` – Bugfix
|
||||||
|
- [ ] `refactor` – Code-Umbau ohne Verhaltensänderung
|
||||||
|
- [ ] `chore` – Wartung, Build, Konfiguration
|
||||||
|
- [ ] `docs` – nur Dokumentation
|
||||||
|
|
||||||
|
## Checkliste
|
||||||
|
|
||||||
|
- [ ] Alle Tests erfolgreich (`<test-command>`)
|
||||||
|
- [ ] Versionsnummer erhöht (`VERSION` + `CHANGELOG.md`)
|
||||||
|
- [ ] `docs/project-structure.md` aktualisiert (falls Funktionen/Module geändert)
|
||||||
|
- [ ] `README.md` aktualisiert (falls nötig)
|
||||||
|
- [ ] Branch ist aktuell mit `develop` (rebase/merge)
|
||||||
|
- [ ] Kein direkter Push nach `main` – diese PR geht nach `develop`
|
||||||
|
|
||||||
|
## Getestete Szenarien
|
||||||
|
|
||||||
|
<!-- Was wurde manuell oder automatisch getestet? -->
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
<!-- z.B. 0.2.1 -->
|
||||||
58
.gitea/workflows/publish.yml
Normal file
58
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: tea.jr-family.de
|
||||||
|
BACKEND_IMAGE: tea.jr-family.de/admin/gartenmanager-backend
|
||||||
|
FRONTEND_IMAGE: tea.jr-family.de/admin/gartenmanager-frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read version
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compute image tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
BRANCH="${GITHUB_REF_NAME}"
|
||||||
|
if [ "$BRANCH" = "main" ]; then
|
||||||
|
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION} ${{ env.BACKEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
|
||||||
|
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION} ${{ env.FRONTEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION}-dev ${{ env.BACKEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
|
||||||
|
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION}-dev ${{ env.FRONTEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} \
|
||||||
|
-u ${{ gitea.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Build & push backend
|
||||||
|
run: |
|
||||||
|
docker build -t placeholder ./backend
|
||||||
|
for tag in ${{ steps.tags.outputs.backend_tags }}; do
|
||||||
|
docker tag placeholder $tag
|
||||||
|
docker push $tag
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Build & push frontend
|
||||||
|
run: |
|
||||||
|
docker build -t placeholder ./frontend
|
||||||
|
for tag in ${{ steps.tags.outputs.frontend_tags }}; do
|
||||||
|
docker tag placeholder $tag
|
||||||
|
docker push $tag
|
||||||
|
done
|
||||||
87
.gitea/workflows/test.yml
Normal file
87
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'ci/**'
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: gartenmanager_test
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/requirements.txt
|
||||||
|
backend/requirements-test.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install -r backend/requirements.txt -r backend/requirements-test.txt
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/gartenmanager_test
|
||||||
|
SECRET_KEY: ci-test-secret-key-min-32-characters
|
||||||
|
run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: backend/coverage.xml
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Build check
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm run build
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
postgres_data/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
91
CHANGELOG.md
Normal file
91
CHANGELOG.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle wesentlichen Änderungen am Projekt werden hier dokumentiert.
|
||||||
|
Format: `[MAJOR.MINOR.PATCH] - YYYY-MM-DD`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-04-06
|
||||||
|
|
||||||
|
### Added – Phase 1 komplett implementiert
|
||||||
|
|
||||||
|
**Backend (FastAPI)**
|
||||||
|
- `app/main.py` – FastAPI App mit CORS und /health Endpoint
|
||||||
|
- `app/api/v1/` – Vollständige REST-API: Auth, Plants, Beds, Plantings
|
||||||
|
- `app/crud/` – CRUD-Layer für alle Entitäten (CRUDBase + spezialisierte Klassen)
|
||||||
|
- `app/schemas/` – Pydantic v2 Schemas komplett (plant, bed, planting)
|
||||||
|
- `app/seeds/initial_data.py` – 28 globale Pflanzen + 15 Kompatibilitäten (idempotent)
|
||||||
|
- `alembic/env.py` + `versions/001_initial.py` – Vollständiges DB-Schema
|
||||||
|
|
||||||
|
**Frontend (Vue 3)**
|
||||||
|
- `src/api/` – Axios-Client mit JWT-Interceptor und Auto-Refresh
|
||||||
|
- `src/stores/` – Pinia Stores: auth, beds, plants
|
||||||
|
- `src/router/` – Vue Router mit Auth-Guard
|
||||||
|
- `src/views/` – Login, Beete-Übersicht, Beet-Detail, Pflanzenbibliothek
|
||||||
|
- `src/components/` – AppLayout, BedForm, PlantingForm, PlantForm
|
||||||
|
|
||||||
|
**Docker**
|
||||||
|
- `docker-compose.yml` – Produktion (db + backend + frontend/nginx)
|
||||||
|
- `docker-compose.dev.yml` – Entwicklung mit Hot-Reload
|
||||||
|
- `frontend/Dockerfile` – Multi-stage Build (Node → nginx:alpine)
|
||||||
|
- `frontend/nginx.conf` – SPA-Fallback + API-Proxy
|
||||||
|
- `.env.example` – Konfigurationsvorlage
|
||||||
|
- `.gitignore` hinzugefügt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.3] - 2026-04-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update project plan: finalize phases, techstack and architecture decisions
|
||||||
|
|
||||||
|
---
|
||||||
|
## [0.2.2] - 2026-04-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Add autonomous branch-switching rule to workflow docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-04-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `CLAUDE.md` auf reine Dispatch-Tabelle reduziert (keine Regelwiederholungen)
|
||||||
|
- `docs/project-structure.md` als dichte Modulreferenz-Vorlage strukturiert
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `.claude/scripts/bump.sh` – Version + CHANGELOG + commit + push in einem Befehl
|
||||||
|
- `.claude/scripts/new-feature.sh` – Feature/Fix/Debug-Branch aus develop erstellen
|
||||||
|
- `.claude/session-context.md` – Sessionstart-Kontext (Version, Branch, offene Arbeit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-04-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `.gitattributes` – automatische LF-Normalisierung, keine CRLF-Warnungen mehr
|
||||||
|
- `README.md` – Projektbeschreibung, Features-Übersicht, Links zur Dokumentation
|
||||||
|
- `.gitea/PULL_REQUEST_TEMPLATE.md` – Checkliste für PRs (Tests, Version, Docs)
|
||||||
|
- Branch Protection für `main` und `develop` (serverseitig konfiguriert)
|
||||||
|
- Squash-Merge als Standard-Merge-Strategie (serverseitig konfiguriert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-04-05
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Entwicklungsstandards um Branching-Regeln, Versionierungsschema und Workflow-Regeln erweitert
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CHANGELOG.md eingeführt
|
||||||
|
- VERSION-Datei eingeführt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-04-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- CLAUDE.md – Guidance für Claude Code
|
||||||
|
- docs/development-standards.md – allgemeine Entwicklungsstandards
|
||||||
|
- docs/project-structure.md – Projektstruktur und Domänenmodell
|
||||||
|
- docs/branching-strategy.md – Branching-Strategie
|
||||||
124
CLAUDE.md
124
CLAUDE.md
@@ -2,46 +2,108 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Projekt
|
## Verhaltensregeln
|
||||||
|
|
||||||
**Gartenmanager** – eine Anwendung zur Verwaltung und Planung von Gartenaktivitäten (Pflanzen, Aussaatkalender, Aufgaben, Bewässerung etc.).
|
- Kein erklärender Text bei Routineaufgaben (Commits, Pushes, Branch-Wechsel)
|
||||||
|
- Bei Fehlern: erst 2x selbst versuchen, dann fragen
|
||||||
|
- Branches selbstständig wechseln wie benötigt
|
||||||
|
|
||||||
## Weiterführende Dokumente
|
## Sprache & Konventionen
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
- **Code-Bezeichner, Commit-Messages:** Englisch, Imperativ (`Add`, `Fix`, `Refactor`)
|
||||||
|---|---|
|
- **Dokumentation & Kommentare:** Deutsch
|
||||||
| [docs/development-standards.md](docs/development-standards.md) | Allgemeine Entwicklungsstandards (Coding Style, Git, Testing) |
|
- **Commit-Format:** `<type>: <short description>` (max. 72 Zeichen)
|
||||||
| [docs/project-structure.md](docs/project-structure.md) | Projektstruktur und Architekturübersicht |
|
Types: `feat` | `fix` | `refactor` | `test` | `docs` | `chore`
|
||||||
|
- Nach jedem Commit sofort pushen
|
||||||
|
- Keine `console.log`/`print` in Produktionscode, keine auskommentierten Code-Blöcke
|
||||||
|
|
||||||
## Techstack
|
## Projektkontext (Schnelleinstieg)
|
||||||
|
|
||||||
> Noch festzulegen – diese Sektion aktualisieren, sobald der Stack definiert ist.
|
**Gartenmanager** – Docker-basierte Gartenverwaltung. Multi-User, Multi-Tenant, Rollensystem.
|
||||||
|
**Stack:** Vue 3 + PrimeVue → FastAPI (Python 3.11) → PostgreSQL 15
|
||||||
|
**Phase:** 1 abgeschlossen (Auth, Beete, Pflanzen, Bepflanzung). Phase 2 = Testing & CI/CD.
|
||||||
|
**Version:** `cat VERSION` | **Branch:** `git branch --show-current`
|
||||||
|
**Sessionstart:** [.claude/session-context.md](.claude/session-context.md) lesen.
|
||||||
|
**Modulreferenz:** [docs/project-structure.md](docs/project-structure.md) – vor Quellcode-Reads.
|
||||||
|
|
||||||
## Build & Entwicklung
|
## CI/CD Workflow
|
||||||
|
|
||||||
> Befehle eintragen, sobald Build-System definiert ist.
|
**Pflicht vor jedem Merge nach `develop`:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Abhängigkeiten installieren
|
# 1. Auf ci/staging mergen und pushen
|
||||||
# <install-command>
|
git checkout ci/staging
|
||||||
|
git merge feature/<name>
|
||||||
# Entwicklungsserver starten
|
git push
|
||||||
# <dev-command>
|
# 2. Warten bis alle Actions grün sind (.gitea/workflows/test.yml)
|
||||||
|
# 3. Erst dann nach develop mergen
|
||||||
# Tests ausführen
|
|
||||||
# <test-command>
|
|
||||||
|
|
||||||
# Einzelnen Test ausführen
|
|
||||||
# <single-test-command>
|
|
||||||
|
|
||||||
# Linting
|
|
||||||
# <lint-command>
|
|
||||||
|
|
||||||
# Build für Produktion
|
|
||||||
# <build-command>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Wichtige Konventionen
|
**Gitea Actions:**
|
||||||
|
- `test.yml` – läuft auf `ci/**`, `develop`, PRs nach `develop`/`main`
|
||||||
|
- `publish.yml` – baut + pusht Container bei Push nach `develop` und `main`
|
||||||
|
- Registry: `tea.jr-family.de` | Images: `admin/gartenmanager-backend`, `admin/gartenmanager-frontend`
|
||||||
|
- develop → Tag `:dev` | main → Tag `:latest` + Versionsnummer
|
||||||
|
|
||||||
- Sprache: Deutsch für Domänenkonzepte (Pflanzen, Beet, Aussaat …), Englisch für Code-Bezeichner und Commit-Messages
|
## Scripts (immer diese verwenden)
|
||||||
- Alle Dateipfade in den `docs/`-Dokumenten aktuell halten, wenn sich die Struktur ändert
|
|
||||||
|
```bash
|
||||||
|
bash .claude/scripts/git-commit.sh "message" # stage all + commit + push
|
||||||
|
bash .claude/scripts/git-switch.sh <branch> # branch wechseln
|
||||||
|
bash .claude/scripts/git-switch.sh <branch> from <base> # neuer branch aus base
|
||||||
|
bash .claude/scripts/git-pr.sh create <head> <base> "titel" # PR erstellen
|
||||||
|
bash .claude/scripts/git-pr.sh merge <nr> squash # PR mergen
|
||||||
|
bash .claude/scripts/git-pr.sh list # offene PRs
|
||||||
|
bash .claude/scripts/bump.sh [patch|minor] "beschreibung" # version + commit + push
|
||||||
|
bash .claude/scripts/new-feature.sh [feature|fix] <name> # branch aus develop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/
|
||||||
|
core/config.py – Settings (DATABASE_URL, SECRET_KEY, ...)
|
||||||
|
core/security.py – JWT (Access 30min / Refresh 7d), Passwort-Hashing
|
||||||
|
core/deps.py – get_current_user, get_tenant_context, require_min_role()
|
||||||
|
db/session.py – async Engine + get_session()
|
||||||
|
models/ – SQLAlchemy ORM (UUID-PKs): User, Tenant, Plant, Bed, BedPlanting
|
||||||
|
schemas/ – Pydantic v2 Create/Update/Read je Entität
|
||||||
|
crud/ – CRUDBase + spezialisierte Klassen, nur DB-Zugriff
|
||||||
|
api/v1/ – Router: auth, plants, beds, plantings
|
||||||
|
seeds/initial_data.py – 28 Pflanzen + 15 Kompatibilitäten (idempotent)
|
||||||
|
|
||||||
|
frontend/src/
|
||||||
|
api/client.js – Axios + JWT-Interceptor + Auto-Refresh bei 401
|
||||||
|
stores/ – Pinia: auth, beds, plants
|
||||||
|
router/index.js – Auth-Guard, /login /beete /beete/:id /pflanzen
|
||||||
|
views/ – LoginView, BedsView, BedDetailView, PlantsView
|
||||||
|
components/ – AppLayout, BedForm, PlantingForm, PlantForm
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tenant-Kontext:** Header `X-Tenant-ID` bei allen Nicht-Auth-Requests.
|
||||||
|
**Rollen:** `READ_ONLY` < `READ_WRITE` < `TENANT_ADMIN` < `is_superadmin`
|
||||||
|
|
||||||
|
## Pflichtregeln
|
||||||
|
|
||||||
|
1. **Nie direkt nach `main`** – nur PR, nur auf Anweisung
|
||||||
|
2. **Arbeit in `feature/`, `fix/` oder `chore/`** unter `develop`
|
||||||
|
3. **Nach jeder Änderung:** `bump.sh` ausführen (MINOR bei Features, PATCH bei Fixes)
|
||||||
|
4. **MAJOR-Version** niemals selbstständig erhöhen
|
||||||
|
5. **Vor Merge/PR:** CHANGELOG, README, `docs/project-structure.md` prüfen
|
||||||
|
6. **In `develop` mergen erst** wenn alle Tests grün sind
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Integrationstests testen gegen echte DB (keine DB-Mocks)
|
||||||
|
- Backend: pytest in `backend/tests/` | Frontend: Vitest neben Quelldateien oder `__tests__/`
|
||||||
|
- Run: `docker compose exec backend pytest` / `cd frontend && npm run test`
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yml up # Dev (hot-reload)
|
||||||
|
docker compose up -d # Prod
|
||||||
|
docker compose exec backend alembic upgrade head
|
||||||
|
docker compose exec backend python -m app.seeds.initial_data
|
||||||
|
cd frontend && npm run dev
|
||||||
|
```
|
||||||
|
|||||||
32
README.md
Normal file
32
README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Gartenmanager
|
||||||
|
|
||||||
|
Persönliche Webanwendung zur Verwaltung und Planung von Gartenaktivitäten.
|
||||||
|
|
||||||
|
## Features (geplant)
|
||||||
|
|
||||||
|
- **Pflanzenverwaltung** – Pflanzenarten mit Eigenschaften (Aussaatzeit, Wasserbedarf, Abstand …)
|
||||||
|
- **Beetplanung** – Beete anlegen und Pflanzen zuordnen
|
||||||
|
- **Aussaatkalender** – Jahresbasierter Aussaat- und Pflanzungsplan
|
||||||
|
- **Aufgabenverwaltung** – Gartenaufgaben mit Fälligkeitsdatum
|
||||||
|
- **Bewässerungsplan** – Intervalle je Beet oder Pflanze
|
||||||
|
|
||||||
|
## Techstack
|
||||||
|
|
||||||
|
> Noch festzulegen.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
> Build- und Run-Befehle werden hier ergänzt, sobald der Techstack feststeht.
|
||||||
|
|
||||||
|
Weiterführende Dokumentation:
|
||||||
|
|
||||||
|
- [Entwicklungsstandards](docs/development-standards.md)
|
||||||
|
- [Projektstruktur](docs/project-structure.md)
|
||||||
|
- [Branching-Strategie](docs/branching-strategy.md)
|
||||||
|
- [Changelog](CHANGELOG.md)
|
||||||
|
|
||||||
|
## Versionierung
|
||||||
|
|
||||||
|
Aktuelle Version: siehe [VERSION](VERSION)
|
||||||
|
|
||||||
|
Schema: `MAJOR.MINOR.PATCH` – Details in den [Entwicklungsstandards](docs/development-standards.md#versionierung).
|
||||||
6
backend/Dockerfile
Normal file
6
backend/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . .
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
41
backend/alembic.ini
Normal file
41
backend/alembic.ini
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
59
backend/alembic/env.py
Normal file
59
backend/alembic/env.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
from app.core.config import settings # noqa: E402
|
||||||
|
from app.db.base import Base # noqa: E402, F401 – imports all models via __init__
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
155
backend/alembic/versions/001_initial.py
Normal file
155
backend/alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-04-06
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy.dialects.postgresql as pg
|
||||||
|
|
||||||
|
revision: str = "001"
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Enums
|
||||||
|
op.execute("CREATE TYPE tenant_role AS ENUM ('READ_ONLY', 'READ_WRITE', 'TENANT_ADMIN')")
|
||||||
|
op.execute("CREATE TYPE nutrient_demand AS ENUM ('schwach', 'mittel', 'stark')")
|
||||||
|
op.execute("CREATE TYPE water_demand AS ENUM ('wenig', 'mittel', 'viel')")
|
||||||
|
op.execute("CREATE TYPE compatibility_rating AS ENUM ('gut', 'neutral', 'schlecht')")
|
||||||
|
op.execute("CREATE TYPE location_type AS ENUM ('sonnig', 'halbschatten', 'schatten')")
|
||||||
|
op.execute("CREATE TYPE soil_type AS ENUM ('normal', 'sandig', 'lehmig', 'humusreich')")
|
||||||
|
|
||||||
|
# users
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
||||||
|
sa.Column("hashed_password", sa.String(255), nullable=False),
|
||||||
|
sa.Column("full_name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.Column("is_superadmin", sa.Boolean, nullable=False, server_default="false"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_id", "users", ["id"])
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# tenants
|
||||||
|
op.create_table(
|
||||||
|
"tenants",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_tenants_id", "tenants", ["id"])
|
||||||
|
op.create_index("ix_tenants_slug", "tenants", ["slug"])
|
||||||
|
|
||||||
|
# user_tenants
|
||||||
|
op.create_table(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column("user_id", pg.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("role", sa.Enum("READ_ONLY", "READ_WRITE", "TENANT_ADMIN", name="tenant_role", create_type=False), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# plant_families
|
||||||
|
op.create_table(
|
||||||
|
"plant_families",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False, unique=True),
|
||||||
|
sa.Column("latin_name", sa.String(255), nullable=False, unique=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plant_families_id", "plant_families", ["id"])
|
||||||
|
|
||||||
|
# plants
|
||||||
|
op.create_table(
|
||||||
|
"plants",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("family_id", pg.UUID(as_uuid=True), sa.ForeignKey("plant_families.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("latin_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("nutrient_demand", sa.Enum("schwach", "mittel", "stark", name="nutrient_demand", create_type=False), nullable=False),
|
||||||
|
sa.Column("water_demand", sa.Enum("wenig", "mittel", "viel", name="water_demand", create_type=False), nullable=False),
|
||||||
|
sa.Column("spacing_cm", sa.Integer, nullable=False),
|
||||||
|
sa.Column("sowing_start_month", sa.Integer, nullable=False),
|
||||||
|
sa.Column("sowing_end_month", sa.Integer, nullable=False),
|
||||||
|
sa.Column("rest_years", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plants_id", "plants", ["id"])
|
||||||
|
op.create_index("ix_plants_tenant_id", "plants", ["tenant_id"])
|
||||||
|
op.create_index("ix_plants_family_id", "plants", ["family_id"])
|
||||||
|
|
||||||
|
# plant_compatibilities
|
||||||
|
op.create_table(
|
||||||
|
"plant_compatibilities",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("plant_id_a", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("plant_id_b", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("rating", sa.Enum("gut", "neutral", "schlecht", name="compatibility_rating", create_type=False), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text, nullable=True),
|
||||||
|
sa.UniqueConstraint("plant_id_a", "plant_id_b", name="uq_plant_compatibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# beds
|
||||||
|
op.create_table(
|
||||||
|
"beds",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("tenant_id", pg.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("width_m", sa.Numeric(5, 2), nullable=False),
|
||||||
|
sa.Column("length_m", sa.Numeric(5, 2), nullable=False),
|
||||||
|
sa.Column("location", sa.Enum("sonnig", "halbschatten", "schatten", name="location_type", create_type=False), nullable=False),
|
||||||
|
sa.Column("soil_type", sa.Enum("normal", "sandig", "lehmig", "humusreich", name="soil_type", create_type=False), nullable=False, server_default="normal"),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_beds_id", "beds", ["id"])
|
||||||
|
op.create_index("ix_beds_tenant_id", "beds", ["tenant_id"])
|
||||||
|
|
||||||
|
# bed_plantings
|
||||||
|
op.create_table(
|
||||||
|
"bed_plantings",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("bed_id", pg.UUID(as_uuid=True), sa.ForeignKey("beds.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("plant_id", pg.UUID(as_uuid=True), sa.ForeignKey("plants.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("area_m2", sa.Numeric(5, 2), nullable=True),
|
||||||
|
sa.Column("count", sa.Integer, nullable=True),
|
||||||
|
sa.Column("planted_date", sa.Date, nullable=True),
|
||||||
|
sa.Column("removed_date", sa.Date, nullable=True),
|
||||||
|
sa.Column("notes", sa.Text, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_bed_plantings_id", "bed_plantings", ["id"])
|
||||||
|
op.create_index("ix_bed_plantings_bed_id", "bed_plantings", ["bed_id"])
|
||||||
|
op.create_index("ix_bed_plantings_plant_id", "bed_plantings", ["plant_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("bed_plantings")
|
||||||
|
op.drop_table("beds")
|
||||||
|
op.drop_table("plant_compatibilities")
|
||||||
|
op.drop_table("plants")
|
||||||
|
op.drop_table("plant_families")
|
||||||
|
op.drop_table("user_tenants")
|
||||||
|
op.drop_table("tenants")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.execute("DROP TYPE IF EXISTS soil_type")
|
||||||
|
op.execute("DROP TYPE IF EXISTS location_type")
|
||||||
|
op.execute("DROP TYPE IF EXISTS compatibility_rating")
|
||||||
|
op.execute("DROP TYPE IF EXISTS water_demand")
|
||||||
|
op.execute("DROP TYPE IF EXISTS nutrient_demand")
|
||||||
|
op.execute("DROP TYPE IF EXISTS tenant_role")
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
68
backend/app/api/v1/auth.py
Normal file
68
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import CurrentUser, get_session
|
||||||
|
from app.core.security import TOKEN_TYPE_REFRESH, create_access_token, create_refresh_token, decode_token
|
||||||
|
from app.crud.user import crud_user
|
||||||
|
from app.schemas.auth import AccessTokenResponse, LoginRequest, RefreshRequest, TokenResponse
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Authentifizierung"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(
|
||||||
|
body: LoginRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> TokenResponse:
|
||||||
|
user = await crud_user.authenticate(db, email=body.email, password=body.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="E-Mail oder Passwort falsch.",
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Benutzerkonto ist deaktiviert.",
|
||||||
|
)
|
||||||
|
tenants = await crud_user.get_tenants(db, user_id=user.id)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=create_access_token(str(user.id)),
|
||||||
|
refresh_token=create_refresh_token(str(user.id)),
|
||||||
|
user=UserRead.model_validate(user),
|
||||||
|
tenants=tenants,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=AccessTokenResponse)
|
||||||
|
async def refresh_token(
|
||||||
|
body: RefreshRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> AccessTokenResponse:
|
||||||
|
try:
|
||||||
|
payload = decode_token(body.refresh_token)
|
||||||
|
if payload.get("type") != TOKEN_TYPE_REFRESH:
|
||||||
|
raise JWTError("Falscher Token-Typ")
|
||||||
|
user_id: str = payload["sub"]
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Ungültiger oder abgelaufener Refresh-Token.",
|
||||||
|
)
|
||||||
|
from uuid import UUID
|
||||||
|
user = await crud_user.get(db, id=UUID(user_id))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Benutzer nicht gefunden oder deaktiviert.",
|
||||||
|
)
|
||||||
|
return AccessTokenResponse(access_token=create_access_token(user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def get_me(current_user: CurrentUser) -> UserRead:
|
||||||
|
return UserRead.model_validate(current_user)
|
||||||
78
backend/app/api/v1/beds.py
Normal file
78
backend/app/api/v1/beds.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_session, get_tenant_context, require_min_role
|
||||||
|
from app.crud.bed import crud_bed
|
||||||
|
from app.models.user import TenantRole
|
||||||
|
from app.schemas.bed import BedCreate, BedDetailRead, BedRead, BedUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/beds", tags=["Beete"])
|
||||||
|
|
||||||
|
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
|
||||||
|
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
|
||||||
|
AdminCtx = Annotated[tuple, Depends(require_min_role(TenantRole.TENANT_ADMIN))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BedRead])
|
||||||
|
async def list_beds(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: TenantCtx,
|
||||||
|
) -> list[BedRead]:
|
||||||
|
_, tenant_id = ctx
|
||||||
|
beds = await crud_bed.get_multi_for_tenant(db, tenant_id=tenant_id)
|
||||||
|
return [BedRead.model_validate(b) for b in beds]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{bed_id}", response_model=BedDetailRead)
|
||||||
|
async def get_bed(
|
||||||
|
bed_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: TenantCtx,
|
||||||
|
) -> BedDetailRead:
|
||||||
|
_, tenant_id = ctx
|
||||||
|
bed = await crud_bed.get_with_plantings(db, id=bed_id)
|
||||||
|
if not bed or bed.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
|
||||||
|
return BedDetailRead.model_validate(bed)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BedRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bed(
|
||||||
|
body: BedCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> BedRead:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
bed = await crud_bed.create_for_tenant(db, obj_in=body, tenant_id=tenant_id)
|
||||||
|
return BedRead.model_validate(bed)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{bed_id}", response_model=BedRead)
|
||||||
|
async def update_bed(
|
||||||
|
bed_id: UUID,
|
||||||
|
body: BedUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> BedRead:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
bed = await crud_bed.get(db, id=bed_id)
|
||||||
|
if not bed or bed.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
|
||||||
|
updated = await crud_bed.update(db, db_obj=bed, obj_in=body)
|
||||||
|
return BedRead.model_validate(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{bed_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_bed(
|
||||||
|
bed_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: AdminCtx,
|
||||||
|
) -> None:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
bed = await crud_bed.get(db, id=bed_id)
|
||||||
|
if not bed or bed.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
|
||||||
|
await crud_bed.remove(db, id=bed_id)
|
||||||
78
backend/app/api/v1/plantings.py
Normal file
78
backend/app/api/v1/plantings.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_session, get_tenant_context, require_min_role
|
||||||
|
from app.crud.bed import crud_bed
|
||||||
|
from app.crud.planting import crud_planting
|
||||||
|
from app.models.user import TenantRole
|
||||||
|
from app.schemas.planting import PlantingCreate, PlantingRead, PlantingUpdate
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Bepflanzungen"])
|
||||||
|
|
||||||
|
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
|
||||||
|
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_bed_or_404(db: AsyncSession, bed_id: UUID, tenant_id: UUID):
|
||||||
|
bed = await crud_bed.get(db, id=bed_id)
|
||||||
|
if not bed or bed.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Beet nicht gefunden.")
|
||||||
|
return bed
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/beds/{bed_id}/plantings", response_model=list[PlantingRead])
|
||||||
|
async def list_plantings(
|
||||||
|
bed_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: TenantCtx,
|
||||||
|
) -> list[PlantingRead]:
|
||||||
|
_, tenant_id = ctx
|
||||||
|
await _get_bed_or_404(db, bed_id, tenant_id)
|
||||||
|
plantings = await crud_planting.get_multi_for_bed(db, bed_id=bed_id)
|
||||||
|
return [PlantingRead.model_validate(p) for p in plantings]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/beds/{bed_id}/plantings", response_model=PlantingRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_planting(
|
||||||
|
bed_id: UUID,
|
||||||
|
body: PlantingCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> PlantingRead:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
await _get_bed_or_404(db, bed_id, tenant_id)
|
||||||
|
planting = await crud_planting.create_for_bed(db, obj_in=body, bed_id=bed_id)
|
||||||
|
return PlantingRead.model_validate(planting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/plantings/{planting_id}", response_model=PlantingRead)
|
||||||
|
async def update_planting(
|
||||||
|
planting_id: UUID,
|
||||||
|
body: PlantingUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> PlantingRead:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
planting = await crud_planting.get(db, id=planting_id)
|
||||||
|
if not planting:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bepflanzung nicht gefunden.")
|
||||||
|
await _get_bed_or_404(db, planting.bed_id, tenant_id)
|
||||||
|
updated = await crud_planting.update(db, db_obj=planting, obj_in=body)
|
||||||
|
return PlantingRead.model_validate(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/plantings/{planting_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_planting(
|
||||||
|
planting_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> None:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
planting = await crud_planting.get(db, id=planting_id)
|
||||||
|
if not planting:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bepflanzung nicht gefunden.")
|
||||||
|
await _get_bed_or_404(db, planting.bed_id, tenant_id)
|
||||||
|
await crud_planting.remove(db, id=planting_id)
|
||||||
95
backend/app/api/v1/plants.py
Normal file
95
backend/app/api/v1/plants.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_session, get_tenant_context, require_min_role
|
||||||
|
from app.crud.plant import crud_plant, crud_plant_family
|
||||||
|
from app.models.user import TenantRole
|
||||||
|
from app.schemas.plant import PlantCreate, PlantFamilyRead, PlantRead, PlantUpdate
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Pflanzen"])
|
||||||
|
|
||||||
|
TenantCtx = Annotated[tuple, Depends(get_tenant_context)]
|
||||||
|
WriteCtx = Annotated[tuple, Depends(require_min_role(TenantRole.READ_WRITE))]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plant-families", response_model=list[PlantFamilyRead])
|
||||||
|
async def list_plant_families(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
_: TenantCtx,
|
||||||
|
) -> list[PlantFamilyRead]:
|
||||||
|
families = await crud_plant_family.get_all(db)
|
||||||
|
return [PlantFamilyRead.model_validate(f) for f in families]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plants", response_model=list[PlantRead])
|
||||||
|
async def list_plants(
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: TenantCtx,
|
||||||
|
) -> list[PlantRead]:
|
||||||
|
_, tenant_id = ctx
|
||||||
|
plants = await crud_plant.get_multi_for_tenant(db, tenant_id=tenant_id)
|
||||||
|
return [PlantRead.model_validate(p) for p in plants]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plants/{plant_id}", response_model=PlantRead)
|
||||||
|
async def get_plant(
|
||||||
|
plant_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
_: TenantCtx,
|
||||||
|
) -> PlantRead:
|
||||||
|
plant = await crud_plant.get(db, id=plant_id)
|
||||||
|
if not plant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
|
||||||
|
return PlantRead.model_validate(plant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/plants", response_model=PlantRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_plant(
|
||||||
|
body: PlantCreate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> PlantRead:
|
||||||
|
_, tenant_id, _ = ctx
|
||||||
|
plant = await crud_plant.create_for_tenant(db, obj_in=body, tenant_id=tenant_id)
|
||||||
|
return PlantRead.model_validate(plant)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/plants/{plant_id}", response_model=PlantRead)
|
||||||
|
async def update_plant(
|
||||||
|
plant_id: UUID,
|
||||||
|
body: PlantUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> PlantRead:
|
||||||
|
user, tenant_id, _ = ctx
|
||||||
|
plant = await crud_plant.get(db, id=plant_id)
|
||||||
|
if not plant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
|
||||||
|
# Global plants: only superadmin
|
||||||
|
if plant.tenant_id is None and not user.is_superadmin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Globale Pflanzen können nur von Superadmins bearbeitet werden.")
|
||||||
|
# Tenant plants: must belong to current tenant
|
||||||
|
if plant.tenant_id is not None and plant.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Kein Zugriff auf diese Pflanze.")
|
||||||
|
updated = await crud_plant.update(db, db_obj=plant, obj_in=body)
|
||||||
|
return PlantRead.model_validate(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/plants/{plant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_plant(
|
||||||
|
plant_id: UUID,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
ctx: WriteCtx,
|
||||||
|
) -> None:
|
||||||
|
user, tenant_id, _ = ctx
|
||||||
|
plant = await crud_plant.get(db, id=plant_id)
|
||||||
|
if not plant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pflanze nicht gefunden.")
|
||||||
|
if plant.tenant_id is None and not user.is_superadmin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Globale Pflanzen können nur von Superadmins gelöscht werden.")
|
||||||
|
if plant.tenant_id is not None and plant.tenant_id != tenant_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Kein Zugriff auf diese Pflanze.")
|
||||||
|
await crud_plant.remove(db, id=plant_id)
|
||||||
10
backend/app/api/v1/router.py
Normal file
10
backend/app/api/v1/router.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import auth, beds, plantings, plants
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(plants.router)
|
||||||
|
api_router.include_router(beds.router)
|
||||||
|
api_router.include_router(plantings.router)
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
21
backend/app/core/config.py
Normal file
21
backend/app/core/config.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/gartenmanager"
|
||||||
|
SECRET_KEY: str = "change-me-in-production-use-a-long-random-string"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
|
APP_TITLE: str = "Gartenmanager API"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
161
backend/app/core/deps.py
Normal file
161
backend/app/core/deps.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Security, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import TOKEN_TYPE_ACCESS, decode_token
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.models.user import User, UserTenant, TenantRole
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> User:
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Nicht authentifiziert. Bitte melden Sie sich an.",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
if payload.get("type") != TOKEN_TYPE_ACCESS:
|
||||||
|
raise JWTError("Falscher Token-Typ")
|
||||||
|
user_id: str | None = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise JWTError("Kein Subject im Token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Ungültiger oder abgelaufener Token.",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.crud.user import crud_user
|
||||||
|
|
||||||
|
user = await crud_user.get(db, id=UUID(user_id))
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Benutzer nicht gefunden.",
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Benutzerkonto ist deaktiviert.",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_context(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None,
|
||||||
|
) -> tuple[User, UUID]:
|
||||||
|
"""Returns (current_user, tenant_id). Validates tenant membership."""
|
||||||
|
if x_tenant_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Header 'X-Tenant-ID' fehlt.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tenant_id = UUID(x_tenant_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Ungültige Tenant-ID.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_user.is_superadmin:
|
||||||
|
# Superadmin: verify tenant exists
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||||
|
tenant = result.scalar_one_or_none()
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Tenant nicht gefunden.",
|
||||||
|
)
|
||||||
|
return current_user, tenant_id
|
||||||
|
|
||||||
|
# Regular user: check membership
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserTenant).where(
|
||||||
|
UserTenant.user_id == current_user.id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
if membership is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Kein Zugriff auf diesen Tenant.",
|
||||||
|
)
|
||||||
|
return current_user, tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_role(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
x_tenant_id: Annotated[str | None, Header(alias="X-Tenant-ID")] = None,
|
||||||
|
) -> tuple[User, UUID, TenantRole | None]:
|
||||||
|
"""Returns (current_user, tenant_id, role). Role is None for superadmins."""
|
||||||
|
user, tenant_id = await get_tenant_context(current_user, db, x_tenant_id)
|
||||||
|
|
||||||
|
if user.is_superadmin:
|
||||||
|
return user, tenant_id, None
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserTenant).where(
|
||||||
|
UserTenant.user_id == user.id,
|
||||||
|
UserTenant.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
return user, tenant_id, membership.role if membership else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_min_role(min_role: TenantRole):
|
||||||
|
"""Dependency factory: ensures the user has at least the given role."""
|
||||||
|
|
||||||
|
async def _check(
|
||||||
|
ctx: Annotated[
|
||||||
|
tuple[User, UUID, TenantRole | None], Depends(get_tenant_role)
|
||||||
|
],
|
||||||
|
) -> tuple[User, UUID, TenantRole | None]:
|
||||||
|
user, tenant_id, role = ctx
|
||||||
|
if user.is_superadmin:
|
||||||
|
return ctx
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Kein Zugriff auf diesen Tenant.",
|
||||||
|
)
|
||||||
|
role_order = {
|
||||||
|
TenantRole.READ_ONLY: 0,
|
||||||
|
TenantRole.READ_WRITE: 1,
|
||||||
|
TenantRole.TENANT_ADMIN: 2,
|
||||||
|
}
|
||||||
|
if role_order[role] < role_order[min_role]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Unzureichende Berechtigungen.",
|
||||||
|
)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
return _check
|
||||||
53
backend/app/core/security.py
Normal file
53
backend/app/core/security.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
TOKEN_TYPE_ACCESS = "access"
|
||||||
|
TOKEN_TYPE_REFRESH = "refresh"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject: str | Any, extra_claims: dict | None = None) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
to_encode: dict[str, Any] = {
|
||||||
|
"sub": str(subject),
|
||||||
|
"exp": expire,
|
||||||
|
"type": TOKEN_TYPE_ACCESS,
|
||||||
|
}
|
||||||
|
if extra_claims:
|
||||||
|
to_encode.update(extra_claims)
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(subject: str | Any) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
|
)
|
||||||
|
to_encode: dict[str, Any] = {
|
||||||
|
"sub": str(subject),
|
||||||
|
"exp": expire,
|
||||||
|
"type": TOKEN_TYPE_REFRESH,
|
||||||
|
}
|
||||||
|
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Decode and validate a JWT. Raises JWTError on failure."""
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
return payload
|
||||||
6
backend/app/crud/__init__.py
Normal file
6
backend/app/crud/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from app.crud.user import crud_user
|
||||||
|
from app.crud.plant import crud_plant, crud_plant_family
|
||||||
|
from app.crud.bed import crud_bed
|
||||||
|
from app.crud.planting import crud_planting
|
||||||
|
|
||||||
|
__all__ = ["crud_user", "crud_plant", "crud_plant_family", "crud_bed", "crud_planting"]
|
||||||
57
backend/app/crud/base.py
Normal file
57
backend/app/crud/base.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
ModelType = TypeVar("ModelType", bound=Base)
|
||||||
|
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||||
|
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||||
|
def __init__(self, model: type[ModelType]):
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
async def get(self, db: AsyncSession, *, id: UUID) -> ModelType | None:
|
||||||
|
result = await db.execute(select(self.model).where(self.model.id == id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_multi(
|
||||||
|
self, db: AsyncSession, *, skip: int = 0, limit: int = 100
|
||||||
|
) -> list[ModelType]:
|
||||||
|
result = await db.execute(select(self.model).offset(skip).limit(limit))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create(self, db: AsyncSession, *, obj_in: CreateSchemaType, **extra: Any) -> ModelType:
|
||||||
|
data = obj_in.model_dump()
|
||||||
|
data.update(extra)
|
||||||
|
db_obj = self.model(**data)
|
||||||
|
db.add(db_obj)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, db: AsyncSession, *, db_obj: ModelType, obj_in: UpdateSchemaType | dict[str, Any]
|
||||||
|
) -> ModelType:
|
||||||
|
if isinstance(obj_in, dict):
|
||||||
|
update_data = obj_in
|
||||||
|
else:
|
||||||
|
update_data = obj_in.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(db_obj, field, value)
|
||||||
|
db.add(db_obj)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
async def remove(self, db: AsyncSession, *, id: UUID) -> ModelType | None:
|
||||||
|
db_obj = await self.get(db, id=id)
|
||||||
|
if db_obj:
|
||||||
|
await db.delete(db_obj)
|
||||||
|
await db.flush()
|
||||||
|
return db_obj
|
||||||
60
backend/app/crud/bed.py
Normal file
60
backend/app/crud/bed.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud.base import CRUDBase
|
||||||
|
from app.models.bed import Bed
|
||||||
|
from app.schemas.bed import BedCreate, BedUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDBed(CRUDBase[Bed, BedCreate, BedUpdate]):
|
||||||
|
async def get(self, db: AsyncSession, *, id: UUID) -> Bed | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Bed)
|
||||||
|
.options(
|
||||||
|
selectinload(Bed.plantings).selectinload(
|
||||||
|
__import__("app.models.planting", fromlist=["BedPlanting"]).BedPlanting.plant
|
||||||
|
).selectinload(
|
||||||
|
__import__("app.models.plant", fromlist=["Plant"]).Plant.family
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(Bed.id == id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_multi_for_tenant(
|
||||||
|
self, db: AsyncSession, *, tenant_id: UUID, skip: int = 0, limit: int = 100
|
||||||
|
) -> list[Bed]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Bed)
|
||||||
|
.where(Bed.tenant_id == tenant_id, Bed.is_active == True) # noqa: E712
|
||||||
|
.order_by(Bed.name)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_with_plantings(self, db: AsyncSession, *, id: UUID) -> Bed | None:
|
||||||
|
from app.models.planting import BedPlanting
|
||||||
|
from app.models.plant import Plant
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Bed)
|
||||||
|
.options(
|
||||||
|
selectinload(Bed.plantings)
|
||||||
|
.selectinload(BedPlanting.plant)
|
||||||
|
.selectinload(Plant.family)
|
||||||
|
)
|
||||||
|
.where(Bed.id == id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create_for_tenant(
|
||||||
|
self, db: AsyncSession, *, obj_in: BedCreate, tenant_id: UUID
|
||||||
|
) -> Bed:
|
||||||
|
return await self.create(db, obj_in=obj_in, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
crud_bed = CRUDBed(Bed)
|
||||||
54
backend/app/crud/plant.py
Normal file
54
backend/app/crud/plant.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud.base import CRUDBase
|
||||||
|
from app.models.plant import Plant, PlantCompatibility, PlantFamily
|
||||||
|
from app.schemas.plant import PlantCreate, PlantUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDPlant(CRUDBase[Plant, PlantCreate, PlantUpdate]):
|
||||||
|
async def get(self, db: AsyncSession, *, id: UUID) -> Plant | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Plant)
|
||||||
|
.options(selectinload(Plant.family))
|
||||||
|
.where(Plant.id == id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_multi_for_tenant(
|
||||||
|
self, db: AsyncSession, *, tenant_id: UUID, skip: int = 0, limit: int = 200
|
||||||
|
) -> list[Plant]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Plant)
|
||||||
|
.options(selectinload(Plant.family))
|
||||||
|
.where(
|
||||||
|
Plant.is_active == True, # noqa: E712
|
||||||
|
or_(Plant.tenant_id == None, Plant.tenant_id == tenant_id), # noqa: E711
|
||||||
|
)
|
||||||
|
.order_by(Plant.name)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create_for_tenant(
|
||||||
|
self, db: AsyncSession, *, obj_in: PlantCreate, tenant_id: UUID
|
||||||
|
) -> Plant:
|
||||||
|
return await self.create(db, obj_in=obj_in, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDPlantFamily(CRUDBase[PlantFamily, PlantFamily, PlantFamily]):
|
||||||
|
async def get_all(self, db: AsyncSession) -> list[PlantFamily]:
|
||||||
|
result = await db.execute(select(PlantFamily).order_by(PlantFamily.name))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_name(self, db: AsyncSession, *, name: str) -> PlantFamily | None:
|
||||||
|
result = await db.execute(select(PlantFamily).where(PlantFamily.name == name))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
crud_plant = CRUDPlant(Plant)
|
||||||
|
crud_plant_family = CRUDPlantFamily(PlantFamily)
|
||||||
39
backend/app/crud/planting.py
Normal file
39
backend/app/crud/planting.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.crud.base import CRUDBase
|
||||||
|
from app.models.planting import BedPlanting
|
||||||
|
from app.models.plant import Plant
|
||||||
|
from app.schemas.planting import PlantingCreate, PlantingUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDPlanting(CRUDBase[BedPlanting, PlantingCreate, PlantingUpdate]):
|
||||||
|
async def get(self, db: AsyncSession, *, id: UUID) -> BedPlanting | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BedPlanting)
|
||||||
|
.options(selectinload(BedPlanting.plant).selectinload(Plant.family))
|
||||||
|
.where(BedPlanting.id == id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_multi_for_bed(
|
||||||
|
self, db: AsyncSession, *, bed_id: UUID
|
||||||
|
) -> list[BedPlanting]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BedPlanting)
|
||||||
|
.options(selectinload(BedPlanting.plant).selectinload(Plant.family))
|
||||||
|
.where(BedPlanting.bed_id == bed_id)
|
||||||
|
.order_by(BedPlanting.planted_date.desc().nullslast())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create_for_bed(
|
||||||
|
self, db: AsyncSession, *, obj_in: PlantingCreate, bed_id: UUID
|
||||||
|
) -> BedPlanting:
|
||||||
|
return await self.create(db, obj_in=obj_in, bed_id=bed_id)
|
||||||
|
|
||||||
|
|
||||||
|
crud_planting = CRUDPlanting(BedPlanting)
|
||||||
48
backend/app/crud/user.py
Normal file
48
backend/app/crud/user.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import get_password_hash, verify_password
|
||||||
|
from app.crud.base import CRUDBase
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user import UserCreate, UserUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
|
||||||
|
async def get_by_email(self, db: AsyncSession, *, email: str) -> User | None:
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(self, db: AsyncSession, *, obj_in: UserCreate, **extra) -> User:
|
||||||
|
data = obj_in.model_dump(exclude={"password"})
|
||||||
|
data["hashed_password"] = get_password_hash(obj_in.password)
|
||||||
|
data.update(extra)
|
||||||
|
db_obj = User(**data)
|
||||||
|
db.add(db_obj)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(db_obj)
|
||||||
|
return db_obj
|
||||||
|
|
||||||
|
async def authenticate(self, db: AsyncSession, *, email: str, password: str) -> User | None:
|
||||||
|
user = await self.get_by_email(db, email=email)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_tenants(self, db: AsyncSession, *, user_id: UUID):
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from app.models.user import UserTenant
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Tenant)
|
||||||
|
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
|
||||||
|
.where(UserTenant.user_id == user_id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
crud_user = CRUDUser(User)
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
13
backend/app/db/base.py
Normal file
13
backend/app/db/base.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Import all models here so Alembic can detect them
|
||||||
|
from app.models.user import User, UserTenant # noqa: F401, E402
|
||||||
|
from app.models.tenant import Tenant # noqa: F401, E402
|
||||||
|
from app.models.plant import PlantFamily, Plant, PlantCompatibility # noqa: F401, E402
|
||||||
|
from app.models.bed import Bed # noqa: F401, E402
|
||||||
|
from app.models.planting import BedPlanting # noqa: F401, E402
|
||||||
33
backend/app/db/session.py
Normal file
33
backend/app/db/session.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
34
backend/app/main.py
Normal file
34
backend/app/main.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.v1.router import api_router
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_TITLE,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["System"])
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok", "version": settings.APP_VERSION}
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
66
backend/app/models/bed.py
Normal file
66
backend/app/models/bed.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LocationType(str, enum.Enum):
|
||||||
|
SONNIG = "sonnig"
|
||||||
|
HALBSCHATTEN = "halbschatten"
|
||||||
|
SCHATTEN = "schatten"
|
||||||
|
|
||||||
|
|
||||||
|
class SoilType(str, enum.Enum):
|
||||||
|
NORMAL = "normal"
|
||||||
|
SANDIG = "sandig"
|
||||||
|
LEHMIG = "lehmig"
|
||||||
|
HUMUSREICH = "humusreich"
|
||||||
|
|
||||||
|
|
||||||
|
class Bed(Base):
|
||||||
|
__tablename__ = "beds"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
width_m: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||||
|
length_m: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||||
|
location: Mapped[LocationType] = mapped_column(
|
||||||
|
Enum(LocationType, name="location_type"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
soil_type: Mapped[SoilType] = mapped_column(
|
||||||
|
Enum(SoilType, name="soil_type"),
|
||||||
|
nullable=False,
|
||||||
|
default=SoilType.NORMAL,
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="beds") # noqa: F821
|
||||||
|
plantings: Mapped[list["BedPlanting"]] = relationship( # noqa: F821
|
||||||
|
"BedPlanting", back_populates="bed", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
133
backend/app/models/plant.py
Normal file
133
backend/app/models/plant.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NutrientDemand(str, enum.Enum):
|
||||||
|
SCHWACH = "schwach"
|
||||||
|
MITTEL = "mittel"
|
||||||
|
STARK = "stark"
|
||||||
|
|
||||||
|
|
||||||
|
class WaterDemand(str, enum.Enum):
|
||||||
|
WENIG = "wenig"
|
||||||
|
MITTEL = "mittel"
|
||||||
|
VIEL = "viel"
|
||||||
|
|
||||||
|
|
||||||
|
class CompatibilityRating(str, enum.Enum):
|
||||||
|
GUT = "gut"
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
SCHLECHT = "schlecht"
|
||||||
|
|
||||||
|
|
||||||
|
class PlantFamily(Base):
|
||||||
|
__tablename__ = "plant_families"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
|
latin_name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
plants: Mapped[list["Plant"]] = relationship("Plant", back_populates="family")
|
||||||
|
|
||||||
|
|
||||||
|
class Plant(Base):
|
||||||
|
__tablename__ = "plants"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
family_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("plant_families.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
latin_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
nutrient_demand: Mapped[NutrientDemand] = mapped_column(
|
||||||
|
Enum(NutrientDemand, name="nutrient_demand"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
water_demand: Mapped[WaterDemand] = mapped_column(
|
||||||
|
Enum(WaterDemand, name="water_demand"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
spacing_cm: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
sowing_start_month: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
sowing_end_month: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
rest_years: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
tenant: Mapped["Tenant | None"] = relationship( # noqa: F821
|
||||||
|
"Tenant", back_populates="plants"
|
||||||
|
)
|
||||||
|
family: Mapped["PlantFamily"] = relationship("PlantFamily", back_populates="plants")
|
||||||
|
plantings: Mapped[list["BedPlanting"]] = relationship( # noqa: F821
|
||||||
|
"BedPlanting", back_populates="plant"
|
||||||
|
)
|
||||||
|
compatibility_a: Mapped[list["PlantCompatibility"]] = relationship(
|
||||||
|
"PlantCompatibility",
|
||||||
|
foreign_keys="PlantCompatibility.plant_id_a",
|
||||||
|
back_populates="plant_a",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
compatibility_b: Mapped[list["PlantCompatibility"]] = relationship(
|
||||||
|
"PlantCompatibility",
|
||||||
|
foreign_keys="PlantCompatibility.plant_id_b",
|
||||||
|
back_populates="plant_b",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlantCompatibility(Base):
|
||||||
|
__tablename__ = "plant_compatibilities"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("plant_id_a", "plant_id_b", name="uq_plant_compatibility"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
plant_id_a: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("plants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
plant_id_b: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("plants.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
rating: Mapped[CompatibilityRating] = mapped_column(
|
||||||
|
Enum(CompatibilityRating, name="compatibility_rating"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
plant_a: Mapped["Plant"] = relationship(
|
||||||
|
"Plant", foreign_keys=[plant_id_a], back_populates="compatibility_a"
|
||||||
|
)
|
||||||
|
plant_b: Mapped["Plant"] = relationship(
|
||||||
|
"Plant", foreign_keys=[plant_id_b], back_populates="compatibility_b"
|
||||||
|
)
|
||||||
40
backend/app/models/planting.py
Normal file
40
backend/app/models/planting.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BedPlanting(Base):
|
||||||
|
__tablename__ = "bed_plantings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
bed_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("beds.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
plant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("plants.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
area_m2: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
|
count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
planted_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
removed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
bed: Mapped["Bed"] = relationship("Bed", back_populates="plantings") # noqa: F821
|
||||||
|
plant: Mapped["Plant"] = relationship("Plant", back_populates="plantings") # noqa: F821
|
||||||
40
backend/app/models/tenant.py
Normal file
40
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(Base):
|
||||||
|
__tablename__ = "tenants"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user_tenants: Mapped[list["UserTenant"]] = relationship( # noqa: F821
|
||||||
|
"UserTenant", back_populates="tenant", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
beds: Mapped[list["Bed"]] = relationship( # noqa: F821
|
||||||
|
"Bed", back_populates="tenant", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
plants: Mapped[list["Plant"]] = relationship( # noqa: F821
|
||||||
|
"Plant", back_populates="tenant"
|
||||||
|
)
|
||||||
71
backend/app/models/user.py
Normal file
71
backend/app/models/user.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TenantRole(str, enum.Enum):
|
||||||
|
READ_ONLY = "READ_ONLY"
|
||||||
|
READ_WRITE = "READ_WRITE"
|
||||||
|
TENANT_ADMIN = "TENANT_ADMIN"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
primary_key=True, default=uuid.uuid4, index=True
|
||||||
|
)
|
||||||
|
email: Mapped[str] = mapped_column(
|
||||||
|
String(255), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
is_superadmin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user_tenants: Mapped[list["UserTenant"]] = relationship(
|
||||||
|
"UserTenant", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenant(Base):
|
||||||
|
__tablename__ = "user_tenants"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
role: Mapped[TenantRole] = mapped_column(
|
||||||
|
Enum(TenantRole, name="tenant_role"),
|
||||||
|
nullable=False,
|
||||||
|
default=TenantRole.READ_ONLY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="user_tenants")
|
||||||
|
tenant: Mapped["Tenant"] = relationship( # noqa: F821
|
||||||
|
"Tenant", back_populates="user_tenants"
|
||||||
|
)
|
||||||
15
backend/app/schemas/__init__.py
Normal file
15
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from app.schemas.auth import AccessTokenResponse, LoginRequest, RefreshRequest, TokenResponse
|
||||||
|
from app.schemas.user import UserCreate, UserRead, UserUpdate
|
||||||
|
from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
|
||||||
|
from app.schemas.plant import PlantCompatibilityRead, PlantCreate, PlantFamilyRead, PlantRead, PlantUpdate
|
||||||
|
from app.schemas.bed import BedCreate, BedDetailRead, BedRead, BedUpdate
|
||||||
|
from app.schemas.planting import PlantingCreate, PlantingRead, PlantingUpdate
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AccessTokenResponse", "LoginRequest", "RefreshRequest", "TokenResponse",
|
||||||
|
"UserCreate", "UserRead", "UserUpdate",
|
||||||
|
"TenantCreate", "TenantRead", "TenantUpdate",
|
||||||
|
"PlantCompatibilityRead", "PlantCreate", "PlantFamilyRead", "PlantRead", "PlantUpdate",
|
||||||
|
"BedCreate", "BedDetailRead", "BedRead", "BedUpdate",
|
||||||
|
"PlantingCreate", "PlantingRead", "PlantingUpdate",
|
||||||
|
]
|
||||||
26
backend/app/schemas/auth.py
Normal file
26
backend/app/schemas/auth.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from app.schemas.tenant import TenantRead
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserRead
|
||||||
|
tenants: list[TenantRead]
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
62
backend/app/schemas/bed.py
Normal file
62
backend/app/schemas/bed.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, computed_field
|
||||||
|
|
||||||
|
from app.models.bed import LocationType, SoilType
|
||||||
|
from app.schemas.plant import PlantRead
|
||||||
|
|
||||||
|
|
||||||
|
class BedBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
width_m: Decimal
|
||||||
|
length_m: Decimal
|
||||||
|
location: LocationType
|
||||||
|
soil_type: SoilType = SoilType.NORMAL
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BedCreate(BedBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BedUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
width_m: Decimal | None = None
|
||||||
|
length_m: Decimal | None = None
|
||||||
|
location: LocationType | None = None
|
||||||
|
soil_type: SoilType | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BedRead(BedBase):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def area_m2(self) -> Decimal:
|
||||||
|
return self.width_m * self.length_m
|
||||||
|
|
||||||
|
|
||||||
|
class PlantingInBed(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
plant: PlantRead
|
||||||
|
area_m2: Decimal | None
|
||||||
|
count: int | None
|
||||||
|
planted_date: datetime | None = None
|
||||||
|
removed_date: datetime | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BedDetailRead(BedRead):
|
||||||
|
plantings: list[PlantingInBed] = []
|
||||||
63
backend/app/schemas/plant.py
Normal file
63
backend/app/schemas/plant.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.models.plant import CompatibilityRating, NutrientDemand, WaterDemand
|
||||||
|
|
||||||
|
|
||||||
|
class PlantFamilyRead(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
latin_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PlantBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
latin_name: str | None = None
|
||||||
|
family_id: uuid.UUID
|
||||||
|
nutrient_demand: NutrientDemand
|
||||||
|
water_demand: WaterDemand
|
||||||
|
spacing_cm: int
|
||||||
|
sowing_start_month: int
|
||||||
|
sowing_end_month: int
|
||||||
|
rest_years: int = 0
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlantCreate(PlantBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlantUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
latin_name: str | None = None
|
||||||
|
family_id: uuid.UUID | None = None
|
||||||
|
nutrient_demand: NutrientDemand | None = None
|
||||||
|
water_demand: WaterDemand | None = None
|
||||||
|
spacing_cm: int | None = None
|
||||||
|
sowing_start_month: int | None = None
|
||||||
|
sowing_end_month: int | None = None
|
||||||
|
rest_years: int | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlantRead(PlantBase):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tenant_id: uuid.UUID | None
|
||||||
|
is_active: bool
|
||||||
|
family: PlantFamilyRead
|
||||||
|
|
||||||
|
|
||||||
|
class PlantCompatibilityRead(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
plant_id_a: uuid.UUID
|
||||||
|
plant_id_b: uuid.UUID
|
||||||
|
rating: CompatibilityRating
|
||||||
|
reason: str | None
|
||||||
38
backend/app/schemas/planting.py
Normal file
38
backend/app/schemas/planting.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.schemas.plant import PlantRead
|
||||||
|
|
||||||
|
|
||||||
|
class PlantingBase(BaseModel):
|
||||||
|
plant_id: uuid.UUID
|
||||||
|
area_m2: Decimal | None = None
|
||||||
|
count: int | None = None
|
||||||
|
planted_date: date | None = None
|
||||||
|
removed_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlantingCreate(PlantingBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlantingUpdate(BaseModel):
|
||||||
|
plant_id: uuid.UUID | None = None
|
||||||
|
area_m2: Decimal | None = None
|
||||||
|
count: int | None = None
|
||||||
|
planted_date: date | None = None
|
||||||
|
removed_date: date | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlantingRead(PlantingBase):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
bed_id: uuid.UUID
|
||||||
|
plant: PlantRead
|
||||||
|
created_at: datetime
|
||||||
45
backend/app/schemas/tenant.py
Normal file
45
backend/app/schemas/tenant.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
@field_validator("slug")
|
||||||
|
@classmethod
|
||||||
|
def slug_format(cls, v: str) -> str:
|
||||||
|
if not re.match(r"^[a-z0-9-]+$", v):
|
||||||
|
raise ValueError(
|
||||||
|
"Der Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TenantCreate(TenantBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TenantUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
slug: str | None = None
|
||||||
|
|
||||||
|
@field_validator("slug")
|
||||||
|
@classmethod
|
||||||
|
def slug_format(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and not re.match(r"^[a-z0-9-]+$", v):
|
||||||
|
raise ValueError(
|
||||||
|
"Der Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten."
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TenantRead(TenantBase):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
44
backend/app/schemas/user.py
Normal file
44
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_min_length(cls, v: str) -> str:
|
||||||
|
if len(v) < 8:
|
||||||
|
raise ValueError("Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
full_name: str | None = None
|
||||||
|
email: EmailStr | None = None
|
||||||
|
password: str | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
@field_validator("password")
|
||||||
|
@classmethod
|
||||||
|
def password_min_length(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and len(v) < 8:
|
||||||
|
raise ValueError("Das Passwort muss mindestens 8 Zeichen lang sein.")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
is_active: bool
|
||||||
|
is_superadmin: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
0
backend/app/seeds/__init__.py
Normal file
0
backend/app/seeds/__init__.py
Normal file
171
backend/app/seeds/initial_data.py
Normal file
171
backend/app/seeds/initial_data.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Seed-Daten: Globale Pflanzenfamilien, Pflanzen und Kompatibilitäten.
|
||||||
|
Idempotent – kann mehrfach ausgeführt werden ohne Fehler.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.plant import (
|
||||||
|
CompatibilityRating,
|
||||||
|
NutrientDemand,
|
||||||
|
Plant,
|
||||||
|
PlantCompatibility,
|
||||||
|
PlantFamily,
|
||||||
|
WaterDemand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
FAMILIES = [
|
||||||
|
{"name": "Solanaceae", "latin_name": "Solanaceae"},
|
||||||
|
{"name": "Kreuzblütler", "latin_name": "Brassicaceae"},
|
||||||
|
{"name": "Doldenblütler", "latin_name": "Apiaceae"},
|
||||||
|
{"name": "Hülsenfrüchtler", "latin_name": "Fabaceae"},
|
||||||
|
{"name": "Kürbisgewächse", "latin_name": "Cucurbitaceae"},
|
||||||
|
{"name": "Korbblütler", "latin_name": "Asteraceae"},
|
||||||
|
{"name": "Lauchgewächse", "latin_name": "Alliaceae"},
|
||||||
|
{"name": "Gänsefußgewächse", "latin_name": "Amaranthaceae"},
|
||||||
|
{"name": "Lippenblütler", "latin_name": "Lamiaceae"},
|
||||||
|
{"name": "Süßgräser", "latin_name": "Poaceae"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# (name, latin_name, family_name, nutrient, water, spacing_cm, sow_start, sow_end, rest_years)
|
||||||
|
PLANTS = [
|
||||||
|
# Solanaceae
|
||||||
|
("Tomate", "Solanum lycopersicum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 60, 3, 4, 3),
|
||||||
|
("Paprika", "Capsicum annuum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 2, 3, 3),
|
||||||
|
("Aubergine", "Solanum melongena", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 2, 3, 3),
|
||||||
|
# Kreuzblütler
|
||||||
|
("Brokkoli", "Brassica oleracea var. italica", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 3, 4, 4),
|
||||||
|
("Weißkohl", "Brassica oleracea var. capitata", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 3, 4, 4),
|
||||||
|
("Kohlrabi", "Brassica oleracea var. gongylodes", "Kreuzblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 22, 3, 7, 3),
|
||||||
|
("Radieschen", "Raphanus sativus", "Kreuzblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 5, 3, 8, 2),
|
||||||
|
# Doldenblütler
|
||||||
|
("Möhre", "Daucus carota", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.WENIG, 5, 3, 7, 3),
|
||||||
|
("Petersilie", "Petroselinum crispum", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 18, 3, 5, 2),
|
||||||
|
("Sellerie", "Apium graveolens", "Doldenblütler", NutrientDemand.STARK, WaterDemand.VIEL, 28, 2, 3, 3),
|
||||||
|
# Hülsenfrüchtler
|
||||||
|
("Buschbohne", "Phaseolus vulgaris", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 12, 5, 7, 3),
|
||||||
|
("Erbse", "Pisum sativum", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 8, 3, 5, 3),
|
||||||
|
# Kürbisgewächse
|
||||||
|
("Gurke", "Cucumis sativus", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 60, 4, 5, 2),
|
||||||
|
("Zucchini", "Cucurbita pepo", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 90, 4, 5, 2),
|
||||||
|
("Kürbis", "Cucurbita maxima", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 180, 4, 5, 2),
|
||||||
|
# Korbblütler
|
||||||
|
("Kopfsalat", "Lactuca sativa", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 22, 3, 8, 2),
|
||||||
|
("Feldsalat", "Valerianella locusta", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 8, 8, 9, 2),
|
||||||
|
# Lauchgewächse
|
||||||
|
("Zwiebel", "Allium cepa", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.WENIG, 12, 3, 4, 3),
|
||||||
|
("Lauch", "Allium porrum", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 2, 3, 3),
|
||||||
|
("Knoblauch", "Allium sativum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 10, 11, 4),
|
||||||
|
("Schnittlauch", "Allium schoenoprasum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 3, 4, 3),
|
||||||
|
# Gänsefußgewächse
|
||||||
|
("Mangold", "Beta vulgaris var. cicla", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 28, 3, 6, 3),
|
||||||
|
("Spinat", "Spinacia oleracea", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 3, 9, 3),
|
||||||
|
("Rote Bete", "Beta vulgaris var. conditiva", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 10, 4, 6, 3),
|
||||||
|
# Lippenblütler
|
||||||
|
("Basilikum", "Ocimum basilicum", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 20, 4, 5, 2),
|
||||||
|
("Thymian", "Thymus vulgaris", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
|
||||||
|
("Minze", "Mentha spicata", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.VIEL, 30, 3, 4, 2),
|
||||||
|
("Oregano", "Origanum vulgare", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
# (plant_a_name, plant_b_name, rating, reason)
|
||||||
|
COMPATIBILITIES = [
|
||||||
|
("Tomate", "Basilikum", CompatibilityRating.GUT, "Basilikum fördert das Tomatenwachstum und hält Schädlinge fern."),
|
||||||
|
("Tomate", "Möhre", CompatibilityRating.GUT, "Gute Nachbarn, gegenseitige Förderung."),
|
||||||
|
("Tomate", "Petersilie", CompatibilityRating.GUT, "Petersilie stärkt die Tomaten."),
|
||||||
|
("Tomate", "Brokkoli", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
|
||||||
|
("Tomate", "Weißkohl", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
|
||||||
|
("Möhre", "Zwiebel", CompatibilityRating.GUT, "Zwiebeln halten die Möhrenfliege fern."),
|
||||||
|
("Möhre", "Lauch", CompatibilityRating.GUT, "Lauch schützt die Möhre vor der Möhrenfliege."),
|
||||||
|
("Möhre", "Erbse", CompatibilityRating.GUT, "Erbsen lockern den Boden für Möhren."),
|
||||||
|
("Gurke", "Buschbohne", CompatibilityRating.GUT, "Klassische gute Nachbarschaft."),
|
||||||
|
("Weißkohl", "Sellerie", CompatibilityRating.GUT, "Sellerie hält die Kohlfliege fern."),
|
||||||
|
("Brokkoli", "Sellerie", CompatibilityRating.GUT, "Sellerie hält Kohlschädlinge fern."),
|
||||||
|
("Spinat", "Radieschen", CompatibilityRating.GUT, "Platzsparende Kombination, gegenseitig förderlich."),
|
||||||
|
("Zwiebel", "Buschbohne", CompatibilityRating.SCHLECHT, "Zwiebeln hemmen das Bohnenwachstum."),
|
||||||
|
("Zwiebel", "Erbse", CompatibilityRating.SCHLECHT, "Zwiebeln und Hülsenfrüchtler vertragen sich nicht."),
|
||||||
|
("Zwiebel", "Weißkohl", CompatibilityRating.SCHLECHT, "Konkurrenz um Nährstoffe."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_initial_data(db: AsyncSession) -> None:
|
||||||
|
# 1. Plant families
|
||||||
|
family_map: dict[str, PlantFamily] = {}
|
||||||
|
for f in FAMILIES:
|
||||||
|
result = await db.execute(select(PlantFamily).where(PlantFamily.name == f["name"]))
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
family_map[f["name"]] = existing
|
||||||
|
else:
|
||||||
|
obj = PlantFamily(id=uuid.uuid4(), **f)
|
||||||
|
db.add(obj)
|
||||||
|
await db.flush()
|
||||||
|
family_map[f["name"]] = obj
|
||||||
|
|
||||||
|
# 2. Global plants
|
||||||
|
plant_map: dict[str, Plant] = {}
|
||||||
|
for (name, latin, family_name, nutrient, water, spacing, sow_start, sow_end, rest) in PLANTS:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Plant).where(Plant.name == name, Plant.tenant_id == None) # noqa: E711
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
plant_map[name] = existing
|
||||||
|
else:
|
||||||
|
obj = Plant(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
tenant_id=None,
|
||||||
|
family_id=family_map[family_name].id,
|
||||||
|
name=name,
|
||||||
|
latin_name=latin,
|
||||||
|
nutrient_demand=nutrient,
|
||||||
|
water_demand=water,
|
||||||
|
spacing_cm=spacing,
|
||||||
|
sowing_start_month=sow_start,
|
||||||
|
sowing_end_month=sow_end,
|
||||||
|
rest_years=rest,
|
||||||
|
)
|
||||||
|
db.add(obj)
|
||||||
|
await db.flush()
|
||||||
|
plant_map[name] = obj
|
||||||
|
|
||||||
|
# 3. Compatibilities (both directions)
|
||||||
|
for (name_a, name_b, rating, reason) in COMPATIBILITIES:
|
||||||
|
if name_a not in plant_map or name_b not in plant_map:
|
||||||
|
continue
|
||||||
|
id_a = plant_map[name_a].id
|
||||||
|
id_b = plant_map[name_b].id
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlantCompatibility).where(
|
||||||
|
PlantCompatibility.plant_id_a == id_a,
|
||||||
|
PlantCompatibility.plant_id_b == id_b,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_a, plant_id_b=id_b, rating=rating, reason=reason))
|
||||||
|
# Reverse direction
|
||||||
|
result = await db.execute(
|
||||||
|
select(PlantCompatibility).where(
|
||||||
|
PlantCompatibility.plant_id_a == id_b,
|
||||||
|
PlantCompatibility.plant_id_b == id_a,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_b, plant_id_b=id_a, rating=rating, reason=reason))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print("Seed-Daten erfolgreich eingespielt.")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await seed_initial_data(db)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
3
backend/pytest.ini
Normal file
3
backend/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
4
backend/requirements-test.txt
Normal file
4
backend/requirements-test.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pytest==8.2.0
|
||||||
|
pytest-asyncio==0.23.6
|
||||||
|
pytest-cov==5.0.0
|
||||||
|
httpx==0.27.0
|
||||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.29.0
|
||||||
|
sqlalchemy==2.0.30
|
||||||
|
alembic==1.13.1
|
||||||
|
asyncpg==0.29.0
|
||||||
|
pydantic==2.7.1
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
|
greenlet==3.0.3
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
117
backend/tests/conftest.py
Normal file
117
backend/tests/conftest.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.db.base import Base
|
||||||
|
from app.db.session import get_session
|
||||||
|
from app.main import app
|
||||||
|
from app.models.plant import NutrientDemand, PlantFamily, WaterDemand
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.user import TenantRole, User, UserTenant
|
||||||
|
|
||||||
|
_engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
_SessionLocal = async_sessionmaker(
|
||||||
|
bind=_engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def setup_database():
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await _engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _override_get_session():
|
||||||
|
async with _SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(setup_database):
|
||||||
|
app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||||
|
yield ac
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db(setup_database):
|
||||||
|
async with _SessionLocal() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_tenant(db: AsyncSession):
|
||||||
|
tenant = Tenant(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="Test Tenant",
|
||||||
|
slug=f"test-{uuid.uuid4().hex[:8]}",
|
||||||
|
)
|
||||||
|
db.add(tenant)
|
||||||
|
await db.commit()
|
||||||
|
yield tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_family(db: AsyncSession):
|
||||||
|
family = PlantFamily(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name=f"Testfamilie-{uuid.uuid4().hex[:6]}",
|
||||||
|
latin_name=f"Familia testus {uuid.uuid4().hex[:6]}",
|
||||||
|
)
|
||||||
|
db.add(family)
|
||||||
|
await db.commit()
|
||||||
|
yield family
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_user(db: AsyncSession, test_tenant: Tenant):
|
||||||
|
email = f"user-{uuid.uuid4().hex[:6]}@test.local"
|
||||||
|
password = "testpass123"
|
||||||
|
user = User(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
email=email,
|
||||||
|
hashed_password=get_password_hash(password),
|
||||||
|
full_name="Test User",
|
||||||
|
is_superadmin=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
db.add(UserTenant(user_id=user.id, tenant_id=test_tenant.id, role=TenantRole.READ_WRITE))
|
||||||
|
await db.commit()
|
||||||
|
# Attach plaintext password for use in auth fixtures
|
||||||
|
user._plain_password = password # type: ignore[attr-defined]
|
||||||
|
yield user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def auth_headers(client: AsyncClient, test_user: User, test_tenant: Tenant):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": test_user.email, "password": test_user._plain_password}, # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-Tenant-ID": str(test_tenant.id),
|
||||||
|
}
|
||||||
53
backend/tests/test_auth.py
Normal file
53
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
async def test_login_success(client, test_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": test_user.email, "password": test_user._plain_password},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "access_token" in data
|
||||||
|
assert "refresh_token" in data
|
||||||
|
assert data["token_type"] == "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_wrong_password(client, test_user):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": test_user.email, "password": "wrongpassword"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_unknown_email(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "nobody@example.com", "password": "irrelevant"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_me(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "email" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_me_unauthenticated(client):
|
||||||
|
resp = await client.get("/api/v1/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_refresh(client, test_user):
|
||||||
|
login = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": test_user.email, "password": test_user._plain_password},
|
||||||
|
)
|
||||||
|
refresh_token = login.json()["refresh_token"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/refresh",
|
||||||
|
json={"refresh_token": refresh_token},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "access_token" in resp.json()
|
||||||
95
backend/tests/test_beds.py
Normal file
95
backend/tests/test_beds.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_beds(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/beds", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_beds_unauthenticated(client):
|
||||||
|
resp = await client.get("/api/v1/beds")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_bed(client, auth_headers):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/beds",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Testbeet",
|
||||||
|
"width_m": "1.5",
|
||||||
|
"length_m": "3.0",
|
||||||
|
"location": "sonnig",
|
||||||
|
"soil_type": "normal",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Testbeet"
|
||||||
|
assert data["tenant_id"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bed_by_id(client, auth_headers):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/beds",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Einzelbeet",
|
||||||
|
"width_m": "2.0",
|
||||||
|
"length_m": "4.0",
|
||||||
|
"location": "halbschatten",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bed_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/beds/{bed_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["id"] == bed_id
|
||||||
|
assert "plantings" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_bed_not_found(client, auth_headers):
|
||||||
|
resp = await client.get(f"/api/v1/beds/{uuid.uuid4()}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_bed(client, auth_headers):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/beds",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Altbeet",
|
||||||
|
"width_m": "1.0",
|
||||||
|
"length_m": "2.0",
|
||||||
|
"location": "sonnig",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bed_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/beds/{bed_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Neubeet"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "Neubeet"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_bed_requires_tenant_admin(client, auth_headers):
|
||||||
|
# test_user has READ_WRITE, not TENANT_ADMIN → delete should fail
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/beds",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "ZuLoeschenBeet",
|
||||||
|
"width_m": "1.0",
|
||||||
|
"length_m": "1.0",
|
||||||
|
"location": "schatten",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bed_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/beds/{bed_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 403
|
||||||
6
backend/tests/test_health.py
Normal file
6
backend/tests/test_health.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
async def test_health(client):
|
||||||
|
resp = await client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "version" in data
|
||||||
114
backend/tests/test_plants.py
Normal file
114
backend/tests/test_plants.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_plants(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/plants", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_plants_unauthenticated(client):
|
||||||
|
resp = await client.get("/api/v1/plants")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_plant_families(client, auth_headers):
|
||||||
|
resp = await client.get("/api/v1/plant-families", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert isinstance(resp.json(), list)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_plant(client, auth_headers, test_family):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/plants",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Testtomate",
|
||||||
|
"family_id": str(test_family.id),
|
||||||
|
"nutrient_demand": "stark",
|
||||||
|
"water_demand": "viel",
|
||||||
|
"spacing_cm": 50,
|
||||||
|
"sowing_start_month": 3,
|
||||||
|
"sowing_end_month": 5,
|
||||||
|
"rest_years": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.json()
|
||||||
|
assert data["name"] == "Testtomate"
|
||||||
|
assert data["tenant_id"] is not None
|
||||||
|
return data["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_plant_by_id(client, auth_headers, test_family):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/plants",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Testsalat",
|
||||||
|
"family_id": str(test_family.id),
|
||||||
|
"nutrient_demand": "mittel",
|
||||||
|
"water_demand": "mittel",
|
||||||
|
"spacing_cm": 30,
|
||||||
|
"sowing_start_month": 3,
|
||||||
|
"sowing_end_month": 6,
|
||||||
|
"rest_years": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
plant_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/plants/{plant_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == plant_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_plant_not_found(client, auth_headers):
|
||||||
|
resp = await client.get(f"/api/v1/plants/{uuid.uuid4()}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_plant(client, auth_headers, test_family):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/plants",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "Altname",
|
||||||
|
"family_id": str(test_family.id),
|
||||||
|
"nutrient_demand": "schwach",
|
||||||
|
"water_demand": "wenig",
|
||||||
|
"spacing_cm": 20,
|
||||||
|
"sowing_start_month": 4,
|
||||||
|
"sowing_end_month": 6,
|
||||||
|
"rest_years": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
plant_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/v1/plants/{plant_id}",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={"name": "Neuname"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "Neuname"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_plant(client, auth_headers, test_family):
|
||||||
|
create = await client.post(
|
||||||
|
"/api/v1/plants",
|
||||||
|
headers=auth_headers,
|
||||||
|
json={
|
||||||
|
"name": "ZuLoeschenPflanze",
|
||||||
|
"family_id": str(test_family.id),
|
||||||
|
"nutrient_demand": "mittel",
|
||||||
|
"water_demand": "mittel",
|
||||||
|
"spacing_cm": 25,
|
||||||
|
"sowing_start_month": 4,
|
||||||
|
"sowing_end_month": 7,
|
||||||
|
"rest_years": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
plant_id = create.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/plants/{plant_id}", headers=auth_headers)
|
||||||
|
assert resp.status_code == 204
|
||||||
41
docker-compose.dev.yml
Normal file
41
docker-compose.dev.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: gartenmanager
|
||||||
|
POSTGRES_PASSWORD: dev_password
|
||||||
|
POSTGRES_DB: gartenmanager
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_dev_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gartenmanager -d gartenmanager"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://gartenmanager:dev_password@db:5432/gartenmanager
|
||||||
|
SECRET_KEY: dev_secret_key_not_for_production
|
||||||
|
CORS_ORIGINS: '["http://localhost:5173", "http://localhost:80"]'
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head &&
|
||||||
|
python -m app.seeds.initial_data &&
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_dev_data:
|
||||||
45
docker-compose.yml
Normal file
45
docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head &&
|
||||||
|
python -m app.seeds.initial_data &&
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
15
docs/branching-strategy.md
Normal file
15
docs/branching-strategy.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Branching-Strategie
|
||||||
|
|
||||||
|
```
|
||||||
|
main
|
||||||
|
└── develop
|
||||||
|
├── feature/<name>
|
||||||
|
├── fix/<name>
|
||||||
|
└── chore/<name>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `main` – stabiler Produktionsstand, nur Merges aus `develop`
|
||||||
|
- `develop` – Integrationsstand, Basis für alle Feature-Branches
|
||||||
|
- `feature/*` – neue Features, Basis: `develop`
|
||||||
|
- `fix/*` – Bugfixes, Basis: `develop`
|
||||||
|
- `chore/*` – Wartung, Konfiguration, Basis: `develop`
|
||||||
@@ -13,17 +13,27 @@ Allgemein gültige Standards für die Arbeit in diesem Repository.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Git
|
## Git & Branching
|
||||||
|
|
||||||
### Branching
|
### Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
main – stabiler Produktionsstand
|
main
|
||||||
feature/<name> – neue Features
|
└── develop
|
||||||
fix/<name> – Bugfixes
|
├── feature/<name> – neue Features
|
||||||
chore/<name> – Wartung, Abhängigkeiten, Konfiguration
|
├── fix/<name> – Bugfixes / kleine Fixes
|
||||||
|
└── debug/<name> – Debugging / Fehleranalyse
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Regeln
|
||||||
|
|
||||||
|
0. **Branch-Wechsel erfolgen selbstständig** – Claude wechselt eigenständig in den jeweils passenden Branch, ohne nachzufragen. Dabei gelten alle übrigen Regeln uneingeschränkt.
|
||||||
|
1. **Nie direkt nach `main` pushen oder mergen.** Änderungen in `main` kommen ausschließlich über eine Pull-Request aus `develop`.
|
||||||
|
2. Jede Änderung findet in einem eigenen `feature/` oder `fix/` Branch unterhalb von `develop` statt.
|
||||||
|
3. In `develop` mergen erst, wenn die Arbeit abgeschlossen ist und alle Tests erfolgreich waren.
|
||||||
|
4. Eine PR nach `main` wird nur auf explizite Anweisung des Nutzers geöffnet.
|
||||||
|
5. Vor jedem Merge nach `develop` und vor jeder PR nach `main` werden alle wesentlichen Dokumente (README.md, CHANGELOG.md, docs/) geprüft und ggf. aktualisiert.
|
||||||
|
|
||||||
### Commit-Messages
|
### Commit-Messages
|
||||||
|
|
||||||
Format: `<type>: <short description>` (max. 72 Zeichen)
|
Format: `<type>: <short description>` (max. 72 Zeichen)
|
||||||
@@ -37,7 +47,31 @@ Format: `<type>: <short description>` (max. 72 Zeichen)
|
|||||||
| `docs` | Nur Dokumentation |
|
| `docs` | Nur Dokumentation |
|
||||||
| `chore` | Build, Dependencies, Konfiguration |
|
| `chore` | Build, Dependencies, Konfiguration |
|
||||||
|
|
||||||
Beispiel: `feat: add watering schedule to plant detail view`
|
**Nach jedem Commit wird sofort gepusht.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionierung
|
||||||
|
|
||||||
|
Schema: `MAJOR.MINOR.PATCH`
|
||||||
|
|
||||||
|
| Teil | Bedeutung | Wechsel |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAJOR` | Hauptrelease | Nur auf explizite Anweisung des Nutzers |
|
||||||
|
| `MINOR` | Größere Updates, neue Features | Bei jeder Feature-Erweiterung oder größerem Umbau |
|
||||||
|
| `PATCH` | Kleine Fixes, Minimalkorrekturen | Bei Bugfixes, kleinen Ergänzungen, Mini-Umbauten |
|
||||||
|
|
||||||
|
- Die aktuelle Version steht in [CHANGELOG.md](../CHANGELOG.md) und in der Datei `VERSION`
|
||||||
|
- Nach **jeder** Änderung wird die Versionsnummer eigenständig erhöht und in beiden Dateien notiert
|
||||||
|
- Die Versionsnummer wird im Commit-Message-Footer vermerkt: `Version: x.y.z`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur-Dokumentation
|
||||||
|
|
||||||
|
- Alle Funktionen/Module werden kurz in [docs/project-structure.md](project-structure.md) dokumentiert
|
||||||
|
- Ziel: Token-Effizienz in zukünftigen Conversations – nicht alles neu einlesen müssen
|
||||||
|
- Bei jeder Änderung an Funktionen/Modulen wird die Dokumentation synchron aktualisiert
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -56,6 +90,7 @@ Beispiel: `feat: add watering schedule to plant detail view`
|
|||||||
- Integrationstests an Systemgrenzen (API, Datenbank)
|
- Integrationstests an Systemgrenzen (API, Datenbank)
|
||||||
- Keine Mocks für die Datenbank in Integrationstests
|
- Keine Mocks für die Datenbank in Integrationstests
|
||||||
- Testdatei liegt neben der zu testenden Datei oder in einem `__tests__`/`tests`-Verzeichnis auf gleicher Ebene
|
- Testdatei liegt neben der zu testenden Datei oder in einem `__tests__`/`tests`-Verzeichnis auf gleicher Ebene
|
||||||
|
- **In `develop` wird erst gemergt, wenn alle Tests grün sind**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +1,147 @@
|
|||||||
# Projektstruktur
|
# Projektstruktur & Modulreferenz
|
||||||
|
|
||||||
> Dieses Dokument beschreibt die Verzeichnisstruktur und Architektur des Gartenmanagers.
|
> **Token-Sparmaßnahme:** Dieses Dokument ist die erste Anlaufstelle.
|
||||||
> Bei strukturellen Änderungen bitte hier aktualisieren.
|
> Vor dem Öffnen von Quellcode hier nachschlagen.
|
||||||
|
> Bei jeder Änderung an Funktionen, Modulen oder der Verzeichnisstruktur sofort aktualisieren.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verzeichnisübersicht
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
gartenmanager/
|
gartenmanager/
|
||||||
├── CLAUDE.md # Guidance für Claude Code
|
├── .claude/ # Claude-Tooling (kein Projektcode)
|
||||||
├── docs/
|
│ ├── scripts/bump.sh # Version bumpen + commit + push
|
||||||
│ ├── development-standards.md # Allgemeine Entwicklungsstandards
|
│ ├── scripts/new-feature.sh # Feature-Branch erstellen
|
||||||
│ └── project-structure.md # Dieses Dokument
|
│ └── session-context.md # Sessionstart-Kontext
|
||||||
│
|
├── .gitea/PULL_REQUEST_TEMPLATE.md
|
||||||
│ (weitere Verzeichnisse entstehen mit dem Projekt)
|
├── backend/ # FastAPI Python Backend
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── main.py # FastAPI App, CORS, Router-Include, /health
|
||||||
|
│ │ ├── core/
|
||||||
|
│ │ │ ├── config.py # pydantic-settings (DATABASE_URL, SECRET_KEY, ...)
|
||||||
|
│ │ │ ├── security.py # JWT erstellen/prüfen, Passwort-Hashing
|
||||||
|
│ │ │ └── deps.py # FastAPI-Dependencies: get_current_user, get_tenant_context, require_min_role()
|
||||||
|
│ │ ├── db/
|
||||||
|
│ │ │ ├── base.py # DeclarativeBase + alle Model-Imports für Alembic
|
||||||
|
│ │ │ └── session.py # async Engine, AsyncSessionLocal, get_session()
|
||||||
|
│ │ ├── models/ # SQLAlchemy ORM (alle UUID-PKs, async)
|
||||||
|
│ │ │ ├── user.py # User, UserTenant (+ TenantRole Enum)
|
||||||
|
│ │ │ ├── tenant.py # Tenant
|
||||||
|
│ │ │ ├── plant.py # PlantFamily, Plant, PlantCompatibility
|
||||||
|
│ │ │ ├── bed.py # Bed (+ LocationType, SoilType Enums)
|
||||||
|
│ │ │ └── planting.py # BedPlanting
|
||||||
|
│ │ ├── schemas/ # Pydantic v2 (Create/Update/Read)
|
||||||
|
│ │ │ ├── auth.py # LoginRequest, RefreshRequest, TokenResponse, AccessTokenResponse
|
||||||
|
│ │ │ ├── user.py # UserCreate, UserUpdate, UserRead
|
||||||
|
│ │ │ ├── tenant.py # TenantCreate, TenantUpdate, TenantRead
|
||||||
|
│ │ │ ├── plant.py # PlantCreate/Update/Read, PlantFamilyRead, PlantCompatibilityRead
|
||||||
|
│ │ │ ├── bed.py # BedCreate/Update/Read/DetailRead, PlantingInBed
|
||||||
|
│ │ │ └── planting.py # PlantingCreate/Update/Read
|
||||||
|
│ │ ├── crud/ # DB-Zugriff, keine Business-Logik
|
||||||
|
│ │ │ ├── base.py # CRUDBase[Model, Create, Update]: get, get_multi, create, update, remove
|
||||||
|
│ │ │ ├── user.py # get_by_email, authenticate, get_tenants
|
||||||
|
│ │ │ ├── plant.py # get_multi_for_tenant (global+tenant), create_for_tenant
|
||||||
|
│ │ │ ├── bed.py # get_multi_for_tenant, get_with_plantings, create_for_tenant
|
||||||
|
│ │ │ └── planting.py # get_multi_for_bed, create_for_bed
|
||||||
|
│ │ ├── api/v1/
|
||||||
|
│ │ │ ├── router.py # Alle Sub-Router unter /api/v1
|
||||||
|
│ │ │ ├── auth.py # POST /login, POST /refresh, GET /me
|
||||||
|
│ │ │ ├── plants.py # GET/POST/PUT/DELETE /plants, GET /plant-families
|
||||||
|
│ │ │ ├── beds.py # GET/POST/PUT/DELETE /beds
|
||||||
|
│ │ │ └── plantings.py # GET/POST /beds/{id}/plantings, PUT/DELETE /plantings/{id}
|
||||||
|
│ │ └── seeds/
|
||||||
|
│ │ └── initial_data.py # 28 globale Pflanzen + 15 Kompatibilitäten (idempotent)
|
||||||
|
│ ├── alembic/
|
||||||
|
│ │ ├── env.py # Async Alembic-Config, liest DATABASE_URL aus Settings
|
||||||
|
│ │ └── versions/001_initial.py # Vollständiges initiales Schema (alle Tabellen + Enums)
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile # python:3.11-slim, uvicorn
|
||||||
|
├── frontend/ # Vue 3 SPA
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js # App-Bootstrap: PrimeVue, Pinia, Router
|
||||||
|
│ │ ├── App.vue # Root: AppLayout (eingeloggt) / router-view (Login)
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ │ ├── client.js # Axios-Instanz, JWT-Interceptor, Auto-Refresh bei 401
|
||||||
|
│ │ │ └── index.js # authApi, plantsApi, bedsApi, plantingsApi
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ │ ├── auth.js # user, tenants, activeTenantId, login(), logout(), setActiveTenant()
|
||||||
|
│ │ │ ├── beds.js # beds, currentBed, fetchBeds/Bed, createBed/Planting, deleteBed/Planting
|
||||||
|
│ │ │ └── plants.js # plants, families, fetchPlants/Families, create/update/deletePlant
|
||||||
|
│ │ ├── router/index.js # /login, /beete, /beete/:id, /pflanzen – auth guard
|
||||||
|
│ │ ├── views/
|
||||||
|
│ │ │ ├── LoginView.vue # Email+Passwort Formular
|
||||||
|
│ │ │ ├── BedsView.vue # DataTable aller Beete, Create/Edit-Dialog
|
||||||
|
│ │ │ ├── BedDetailView.vue # Beet-Infos + Bepflanzungs-Tabelle + Add-Dialog
|
||||||
|
│ │ │ └── PlantsView.vue # Pflanzenbibliothek DataTable, Filter, eigene Pflanze anlegen
|
||||||
|
│ │ └── components/
|
||||||
|
│ │ ├── AppLayout.vue # Navbar (Logo, Nav-Links, Tenant-Selector, Logout)
|
||||||
|
│ │ ├── BedForm.vue # Formular für Beet anlegen/bearbeiten
|
||||||
|
│ │ ├── PlantingForm.vue # Formular für Bepflanzung hinzufügen
|
||||||
|
│ │ └── PlantForm.vue # Formular für eigene Pflanze anlegen
|
||||||
|
│ ├── nginx.conf # SPA fallback + API-Proxy → backend:8000
|
||||||
|
│ ├── Dockerfile # Multi-stage: node:20 build → nginx:alpine
|
||||||
|
│ └── package.json
|
||||||
|
├── docker-compose.yml # Produktion: db + backend + frontend
|
||||||
|
├── docker-compose.dev.yml # Entwicklung: db + backend (reload) + Frontend lokal via npm run dev
|
||||||
|
├── .env.example # Vorlage für .env
|
||||||
|
├── .gitignore
|
||||||
|
├── CHANGELOG.md
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── README.md
|
||||||
|
└── VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
> Sobald der Techstack feststeht, wird diese Struktur hier dokumentiert.
|
---
|
||||||
|
|
||||||
|
## Berechtigungslogik
|
||||||
|
|
||||||
|
```
|
||||||
|
is_superadmin=True → alles erlaubt, Tenant-Prüfung wird übersprungen
|
||||||
|
TENANT_ADMIN → alles im eigenen Tenant (inkl. Beet löschen)
|
||||||
|
READ_WRITE → lesen + schreiben, kein Beet löschen
|
||||||
|
READ_ONLY → nur GET-Endpoints
|
||||||
|
```
|
||||||
|
|
||||||
|
`require_min_role(TenantRole.READ_WRITE)` in `deps.py` gibt `(user, tenant_id, role)` zurück.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architektur
|
## Datenbankschema (Kurzform)
|
||||||
|
|
||||||
> Noch festzulegen. Typische Optionen für dieses Projekt:
|
```
|
||||||
>
|
users → id, email, hashed_password, is_superadmin
|
||||||
> - **Monolith** (z. B. Full-Stack-Framework wie Laravel, Django, Rails)
|
tenants → id, name, slug
|
||||||
> - **Frontend + Backend getrennt** (z. B. Vue/React + REST/GraphQL API)
|
user_tenants → user_id, tenant_id, role
|
||||||
> - **Mobile App** (z. B. Flutter, React Native)
|
plant_families → id, name, latin_name
|
||||||
|
plants → id, tenant_id(nullable=global), family_id, nutrient_demand, water_demand, rest_years, ...
|
||||||
|
plant_compat. → plant_id_a, plant_id_b, rating, reason
|
||||||
|
beds → id, tenant_id, width_m, length_m, location, soil_type
|
||||||
|
bed_plantings → id, bed_id, plant_id, area_m2, count, planted_date, removed_date
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Domänenmodell (geplant)
|
## API-Routen Übersicht
|
||||||
|
|
||||||
Die Kernkonzepte des Gartenmanagers:
|
```
|
||||||
|
POST /api/v1/auth/login
|
||||||
| Konzept | Beschreibung |
|
POST /api/v1/auth/refresh
|
||||||
|---|---|
|
GET /api/v1/auth/me
|
||||||
| `Plant` / Pflanze | Eine Pflanzenart mit Eigenschaften (Aussaatzeit, Abstand, Wasserbedarf …) |
|
GET /api/v1/plant-families
|
||||||
| `Bed` / Beet | Ein physischer Gartenbereich, dem Pflanzen zugeordnet werden |
|
GET /api/v1/plants
|
||||||
| `SowingCalendar` / Aussaatkalender | Zeitplan für Aussaat und Pflanzung je Pflanze und Jahr |
|
GET /api/v1/plants/{id}
|
||||||
| `Task` / Aufgabe | Eine zu erledigende Gartenarbeit mit Fälligkeitsdatum |
|
POST /api/v1/plants (READ_WRITE+)
|
||||||
| `WateringSchedule` / Bewässerungsplan | Regelmäßige Bewässerungsintervalle je Beet oder Pflanze |
|
PUT /api/v1/plants/{id} (READ_WRITE+, global nur Superadmin)
|
||||||
|
DELETE /api/v1/plants/{id} (READ_WRITE+, global nur Superadmin)
|
||||||
---
|
GET /api/v1/beds
|
||||||
|
GET /api/v1/beds/{id} (mit Bepflanzungen)
|
||||||
## Datenhaltung
|
POST /api/v1/beds (READ_WRITE+)
|
||||||
|
PUT /api/v1/beds/{id} (READ_WRITE+)
|
||||||
> Noch festzulegen (SQLite, PostgreSQL, lokale Dateien …).
|
DELETE /api/v1/beds/{id} (TENANT_ADMIN+)
|
||||||
|
GET /api/v1/beds/{id}/plantings
|
||||||
---
|
POST /api/v1/beds/{id}/plantings (READ_WRITE+)
|
||||||
|
PUT /api/v1/plantings/{id} (READ_WRITE+)
|
||||||
## Schnittstellen
|
DELETE /api/v1/plantings/{id} (READ_WRITE+)
|
||||||
|
GET /health
|
||||||
> Noch festzulegen.
|
```
|
||||||
|
|||||||
13
frontend/Dockerfile
Normal file
13
frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Gartenmanager</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/primevue/resources/themes/lara-light-green/theme.css" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/primeicons/primeicons.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
frontend/nginx.conf
Normal file
27
frontend/nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API-Proxy zum Backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health endpoint proxy
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend:8000/health;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||||
|
}
|
||||||
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "gartenmanager-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext .vue,.js,.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"primevue": "^3.53.0",
|
||||||
|
"primeicons": "^6.0.1",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"jsdom": "^24.1.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-wrapper">
|
||||||
|
<AppLayout v-if="auth.isLoggedIn" />
|
||||||
|
<router-view v-else />
|
||||||
|
<Toast />
|
||||||
|
<ConfirmDialog />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
|
import Toast from 'primevue/toast'
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: var(--font-family); background: var(--surface-ground); color: var(--text-color); }
|
||||||
|
.app-wrapper { min-height: 100vh; }
|
||||||
|
</style>
|
||||||
69
frontend/src/api/client.js
Normal file
69
frontend/src/api/client.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || '',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request: JWT aus localStorage anhängen
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
const tenantId = localStorage.getItem('tenant_id')
|
||||||
|
if (tenantId) config.headers['X-Tenant-ID'] = tenantId
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// Response: 401 → Token refresh versuchen
|
||||||
|
let isRefreshing = false
|
||||||
|
let failedQueue = []
|
||||||
|
|
||||||
|
const processQueue = (error, token = null) => {
|
||||||
|
failedQueue.forEach((prom) => (error ? prom.reject(error) : prom.resolve(token)))
|
||||||
|
failedQueue = []
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const original = error.config
|
||||||
|
if (error.response?.status === 401 && !original._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
original.headers.Authorization = `Bearer ${token}`
|
||||||
|
return apiClient(original)
|
||||||
|
})
|
||||||
|
.catch((err) => Promise.reject(err))
|
||||||
|
}
|
||||||
|
original._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (!refreshToken) {
|
||||||
|
isRefreshing = false
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post('/api/v1/auth/refresh', { refresh_token: refreshToken })
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
apiClient.defaults.headers.common.Authorization = `Bearer ${data.access_token}`
|
||||||
|
processQueue(null, data.access_token)
|
||||||
|
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||||
|
return apiClient(original)
|
||||||
|
} catch (err) {
|
||||||
|
processQueue(err, null)
|
||||||
|
localStorage.clear()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(err)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
33
frontend/src/api/index.js
Normal file
33
frontend/src/api/index.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (email, password) =>
|
||||||
|
apiClient.post('/api/v1/auth/login', { email, password }),
|
||||||
|
refresh: (refresh_token) =>
|
||||||
|
apiClient.post('/api/v1/auth/refresh', { refresh_token }),
|
||||||
|
me: () => apiClient.get('/api/v1/auth/me'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plantsApi = {
|
||||||
|
list: () => apiClient.get('/api/v1/plants'),
|
||||||
|
get: (id) => apiClient.get(`/api/v1/plants/${id}`),
|
||||||
|
create: (data) => apiClient.post('/api/v1/plants', data),
|
||||||
|
update: (id, data) => apiClient.put(`/api/v1/plants/${id}`, data),
|
||||||
|
delete: (id) => apiClient.delete(`/api/v1/plants/${id}`),
|
||||||
|
families: () => apiClient.get('/api/v1/plant-families'),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bedsApi = {
|
||||||
|
list: () => apiClient.get('/api/v1/beds'),
|
||||||
|
get: (id) => apiClient.get(`/api/v1/beds/${id}`),
|
||||||
|
create: (data) => apiClient.post('/api/v1/beds', data),
|
||||||
|
update: (id, data) => apiClient.put(`/api/v1/beds/${id}`, data),
|
||||||
|
delete: (id) => apiClient.delete(`/api/v1/beds/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const plantingsApi = {
|
||||||
|
list: (bedId) => apiClient.get(`/api/v1/beds/${bedId}/plantings`),
|
||||||
|
create: (bedId, data) => apiClient.post(`/api/v1/beds/${bedId}/plantings`, data),
|
||||||
|
update: (id, data) => apiClient.put(`/api/v1/plantings/${id}`, data),
|
||||||
|
delete: (id) => apiClient.delete(`/api/v1/plantings/${id}`),
|
||||||
|
}
|
||||||
88
frontend/src/components/AppLayout.vue
Normal file
88
frontend/src/components/AppLayout.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<i class="pi pi-leaf" style="color: var(--green-500); font-size: 1.4rem" />
|
||||||
|
<span class="brand-name">Gartenmanager</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-menu">
|
||||||
|
<router-link to="/beete" class="nav-link">
|
||||||
|
<i class="pi pi-th-large" /> Beete
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/pflanzen" class="nav-link">
|
||||||
|
<i class="pi pi-book" /> Pflanzen
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
<Dropdown
|
||||||
|
v-if="auth.tenants.length > 1"
|
||||||
|
:model-value="auth.activeTenantId"
|
||||||
|
:options="auth.tenants"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Tenant wählen"
|
||||||
|
class="tenant-selector"
|
||||||
|
@change="auth.setActiveTenant($event.value)"
|
||||||
|
/>
|
||||||
|
<span v-else-if="auth.activeTenant" class="tenant-name">
|
||||||
|
<i class="pi pi-building" /> {{ auth.activeTenant.name }}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-sign-out"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
title="Abmelden"
|
||||||
|
@click="handleLogout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
auth.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout { display: flex; flex-direction: column; min-height: 100vh; }
|
||||||
|
.navbar {
|
||||||
|
display: flex; align-items: center; gap: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.navbar-brand { display: flex; align-items: center; gap: 0.5rem; text-decoration: none; }
|
||||||
|
.brand-name { font-weight: 700; font-size: 1.1rem; color: var(--green-700); }
|
||||||
|
.navbar-menu { display: flex; gap: 0.5rem; flex: 1; }
|
||||||
|
.nav-link {
|
||||||
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.4rem 0.9rem; border-radius: 6px;
|
||||||
|
text-decoration: none; color: var(--text-color-secondary);
|
||||||
|
font-size: 0.95rem; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.nav-link:hover, .nav-link.router-link-active {
|
||||||
|
background: var(--green-50); color: var(--green-700);
|
||||||
|
}
|
||||||
|
.navbar-end { display: flex; align-items: center; gap: 0.75rem; margin-left: auto; }
|
||||||
|
.tenant-name { font-size: 0.85rem; color: var(--text-color-secondary); display: flex; align-items: center; gap: 0.3rem; }
|
||||||
|
.tenant-selector { font-size: 0.85rem; }
|
||||||
|
.main-content { flex: 1; padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||||
|
</style>
|
||||||
102
frontend/src/components/BedForm.vue
Normal file
102
frontend/src/components/BedForm.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name *</label>
|
||||||
|
<InputText v-model="form.name" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Breite (m) *</label>
|
||||||
|
<InputNumber v-model="form.width_m" :min="0.1" :max="99" :step="0.1" :min-fraction-digits="1" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Länge (m) *</label>
|
||||||
|
<InputNumber v-model="form.length_m" :min="0.1" :max="99" :step="0.1" :min-fraction-digits="1" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Lage *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.location"
|
||||||
|
:options="locationOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
required
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Bodentyp</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.soil_type"
|
||||||
|
:options="soilOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<Textarea v-model="form.notes" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<Button type="button" label="Abbrechen" severity="secondary" text @click="$emit('cancel')" />
|
||||||
|
<Button type="submit" label="Speichern" icon="pi pi-check" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import InputNumber from 'primevue/inputnumber'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
const props = defineProps({ initial: { type: Object, default: null } })
|
||||||
|
const emit = defineEmits(['save', 'cancel'])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
width_m: 1.0,
|
||||||
|
length_m: 1.0,
|
||||||
|
location: 'sonnig',
|
||||||
|
soil_type: 'normal',
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.initial, (val) => {
|
||||||
|
if (val) Object.assign(form, { name: val.name, width_m: Number(val.width_m), length_m: Number(val.length_m), location: val.location, soil_type: val.soil_type, notes: val.notes || '' })
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const locationOptions = [
|
||||||
|
{ label: 'Sonnig', value: 'sonnig' },
|
||||||
|
{ label: 'Halbschatten', value: 'halbschatten' },
|
||||||
|
{ label: 'Schatten', value: 'schatten' },
|
||||||
|
]
|
||||||
|
const soilOptions = [
|
||||||
|
{ label: 'Normal', value: 'normal' },
|
||||||
|
{ label: 'Sandig', value: 'sandig' },
|
||||||
|
{ label: 'Lehmig', value: 'lehmig' },
|
||||||
|
{ label: 'Humusreich', value: 'humusreich' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
emit('save', { ...form, notes: form.notes || null })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
label { font-size: 0.875rem; font-weight: 600; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
122
frontend/src/components/PlantForm.vue
Normal file
122
frontend/src/components/PlantForm.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Name *</label>
|
||||||
|
<InputText v-model="form.name" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Lateinischer Name</label>
|
||||||
|
<InputText v-model="form.latin_name" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Familie *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.family_id"
|
||||||
|
:options="plantsStore.families"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Familie wählen…"
|
||||||
|
required
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Nährstoffbedarf *</label>
|
||||||
|
<Dropdown v-model="form.nutrient_demand" :options="nutrientOptions" option-label="label" option-value="value" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Wasserbedarf *</label>
|
||||||
|
<Dropdown v-model="form.water_demand" :options="waterOptions" option-label="label" option-value="value" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Pflanzabstand (cm) *</label>
|
||||||
|
<InputNumber v-model="form.spacing_cm" :min="1" :max="500" required class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Beetruhezeit (Jahre)</label>
|
||||||
|
<InputNumber v-model="form.rest_years" :min="0" :max="10" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Aussaat ab (Monat)</label>
|
||||||
|
<Dropdown v-model="form.sowing_start_month" :options="monthOptions" option-label="label" option-value="value" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Aussaat bis (Monat)</label>
|
||||||
|
<Dropdown v-model="form.sowing_end_month" :options="monthOptions" option-label="label" option-value="value" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<Textarea v-model="form.notes" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<Button type="button" label="Abbrechen" severity="secondary" text @click="$emit('cancel')" />
|
||||||
|
<Button type="submit" label="Speichern" icon="pi pi-check" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive } from 'vue'
|
||||||
|
import { usePlantsStore } from '@/stores/plants'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import InputNumber from 'primevue/inputnumber'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'cancel'])
|
||||||
|
const plantsStore = usePlantsStore()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '', latin_name: '', family_id: null,
|
||||||
|
nutrient_demand: 'mittel', water_demand: 'mittel',
|
||||||
|
spacing_cm: 30, rest_years: 0,
|
||||||
|
sowing_start_month: 3, sowing_end_month: 5,
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!plantsStore.families.length) await plantsStore.fetchFamilies()
|
||||||
|
})
|
||||||
|
|
||||||
|
const nutrientOptions = [
|
||||||
|
{ label: 'Schwachzehrer', value: 'schwach' },
|
||||||
|
{ label: 'Mittelzehrer', value: 'mittel' },
|
||||||
|
{ label: 'Starkzehrer', value: 'stark' },
|
||||||
|
]
|
||||||
|
const waterOptions = [
|
||||||
|
{ label: 'Wenig', value: 'wenig' },
|
||||||
|
{ label: 'Mittel', value: 'mittel' },
|
||||||
|
{ label: 'Viel', value: 'viel' },
|
||||||
|
]
|
||||||
|
const monthOptions = [
|
||||||
|
{ label: 'Januar', value: 1 }, { label: 'Februar', value: 2 }, { label: 'März', value: 3 },
|
||||||
|
{ label: 'April', value: 4 }, { label: 'Mai', value: 5 }, { label: 'Juni', value: 6 },
|
||||||
|
{ label: 'Juli', value: 7 }, { label: 'August', value: 8 }, { label: 'September', value: 9 },
|
||||||
|
{ label: 'Oktober', value: 10 }, { label: 'November', value: 11 }, { label: 'Dezember', value: 12 },
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
emit('save', { ...form, latin_name: form.latin_name || null, notes: form.notes || null })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
label { font-size: 0.875rem; font-weight: 600; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
96
frontend/src/components/PlantingForm.vue
Normal file
96
frontend/src/components/PlantingForm.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Pflanze *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.plant_id"
|
||||||
|
:options="plantsStore.plants"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Pflanze wählen…"
|
||||||
|
filter
|
||||||
|
required
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Fläche (m²)</label>
|
||||||
|
<InputNumber v-model="form.area_m2" :min="0" :max="999" :step="0.1" :min-fraction-digits="0" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Stückzahl</label>
|
||||||
|
<InputNumber v-model="form.count" :min="1" :max="9999" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Gepflanzt am</label>
|
||||||
|
<Calendar v-model="form.planted_date" date-format="dd.mm.yy" show-icon class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Entfernt am</label>
|
||||||
|
<Calendar v-model="form.removed_date" date-format="dd.mm.yy" show-icon class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>Notizen</label>
|
||||||
|
<Textarea v-model="form.notes" rows="2" class="w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<Button type="button" label="Abbrechen" severity="secondary" text @click="$emit('cancel')" />
|
||||||
|
<Button type="submit" label="Hinzufügen" icon="pi pi-check" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive } from 'vue'
|
||||||
|
import { usePlantsStore } from '@/stores/plants'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
import InputNumber from 'primevue/inputnumber'
|
||||||
|
import Calendar from 'primevue/calendar'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'cancel'])
|
||||||
|
const plantsStore = usePlantsStore()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
plant_id: null,
|
||||||
|
area_m2: null,
|
||||||
|
count: null,
|
||||||
|
planted_date: null,
|
||||||
|
removed_date: null,
|
||||||
|
notes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!plantsStore.plants.length) await plantsStore.fetchPlants()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const payload = {
|
||||||
|
plant_id: form.plant_id,
|
||||||
|
area_m2: form.area_m2 || null,
|
||||||
|
count: form.count || null,
|
||||||
|
planted_date: form.planted_date ? form.planted_date.toISOString().split('T')[0] : null,
|
||||||
|
removed_date: form.removed_date ? form.removed_date.toISOString().split('T')[0] : null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
}
|
||||||
|
emit('save', payload)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
label { font-size: 0.875rem; font-weight: 600; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
18
frontend/src/main.js
Normal file
18
frontend/src/main.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import PrimeVue from 'primevue/config'
|
||||||
|
import ToastService from 'primevue/toastservice'
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(PrimeVue, { ripple: true })
|
||||||
|
app.use(ToastService)
|
||||||
|
app.use(ConfirmationService)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
48
frontend/src/router/index.js
Normal file
48
frontend/src/router/index.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/LoginView.vue'),
|
||||||
|
meta: { public: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/beete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/beete',
|
||||||
|
name: 'Beete',
|
||||||
|
component: () => import('@/views/BedsView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/beete/:id',
|
||||||
|
name: 'BeetDetail',
|
||||||
|
component: () => import('@/views/BedDetailView.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/pflanzen',
|
||||||
|
name: 'Pflanzen',
|
||||||
|
component: () => import('@/views/PlantsView.vue'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (!to.meta.public && !auth.isLoggedIn) {
|
||||||
|
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||||
|
}
|
||||||
|
if (to.name === 'Login' && auth.isLoggedIn) {
|
||||||
|
return { name: 'Beete' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
43
frontend/src/stores/auth.js
Normal file
43
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { authApi } from '@/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||||
|
const tenants = ref(JSON.parse(localStorage.getItem('tenants') || '[]'))
|
||||||
|
const activeTenantId = ref(localStorage.getItem('tenant_id') || null)
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
const activeTenant = computed(() =>
|
||||||
|
tenants.value.find((t) => t.id === activeTenantId.value) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
async function login(email, password) {
|
||||||
|
const { data } = await authApi.login(email, password)
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user))
|
||||||
|
localStorage.setItem('tenants', JSON.stringify(data.tenants))
|
||||||
|
user.value = data.user
|
||||||
|
tenants.value = data.tenants
|
||||||
|
// Auto-select first tenant
|
||||||
|
if (data.tenants.length > 0 && !activeTenantId.value) {
|
||||||
|
setActiveTenant(data.tenants[0].id)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveTenant(tenantId) {
|
||||||
|
activeTenantId.value = tenantId
|
||||||
|
localStorage.setItem('tenant_id', tenantId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.clear()
|
||||||
|
user.value = null
|
||||||
|
tenants.value = []
|
||||||
|
activeTenantId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, tenants, activeTenantId, isLoggedIn, activeTenant, login, logout, setActiveTenant }
|
||||||
|
})
|
||||||
78
frontend/src/stores/auth.test.js
Normal file
78
frontend/src/stores/auth.test.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
|
// localStorage is not available in jsdom by default without setup,
|
||||||
|
// so we mock it minimally
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store = {}
|
||||||
|
return {
|
||||||
|
getItem: (key) => store[key] ?? null,
|
||||||
|
setItem: (key, value) => { store[key] = String(value) },
|
||||||
|
removeItem: (key) => { delete store[key] },
|
||||||
|
clear: () => { store = {} },
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
|
||||||
|
|
||||||
|
// Mock the API module so tests don't make real HTTP calls
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
authApi: {
|
||||||
|
login: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useAuthStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('initializes with null user when localStorage is empty', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.tenants).toEqual([])
|
||||||
|
expect(store.activeTenantId).toBeNull()
|
||||||
|
expect(store.isLoggedIn).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isLoggedIn is true when user is set', () => {
|
||||||
|
localStorageMock.setItem('user', JSON.stringify({ id: '1', email: 'a@b.de' }))
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.isLoggedIn).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout clears state and localStorage', () => {
|
||||||
|
localStorageMock.setItem('user', JSON.stringify({ id: '1' }))
|
||||||
|
localStorageMock.setItem('tenants', JSON.stringify([{ id: 'tid' }]))
|
||||||
|
localStorageMock.setItem('tenant_id', 'tid')
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.logout()
|
||||||
|
|
||||||
|
expect(store.user).toBeNull()
|
||||||
|
expect(store.tenants).toEqual([])
|
||||||
|
expect(store.activeTenantId).toBeNull()
|
||||||
|
expect(localStorageMock.getItem('user')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setActiveTenant updates state and localStorage', () => {
|
||||||
|
const store = useAuthStore()
|
||||||
|
store.setActiveTenant('new-tenant-id')
|
||||||
|
expect(store.activeTenantId).toBe('new-tenant-id')
|
||||||
|
expect(localStorageMock.getItem('tenant_id')).toBe('new-tenant-id')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activeTenant returns matching tenant from list', () => {
|
||||||
|
const tenants = [
|
||||||
|
{ id: 'tid-1', name: 'Erster' },
|
||||||
|
{ id: 'tid-2', name: 'Zweiter' },
|
||||||
|
]
|
||||||
|
localStorageMock.setItem('tenants', JSON.stringify(tenants))
|
||||||
|
localStorageMock.setItem('tenant_id', 'tid-2')
|
||||||
|
|
||||||
|
const store = useAuthStore()
|
||||||
|
expect(store.activeTenant?.name).toBe('Zweiter')
|
||||||
|
})
|
||||||
|
})
|
||||||
70
frontend/src/stores/beds.js
Normal file
70
frontend/src/stores/beds.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { bedsApi, plantingsApi } from '@/api'
|
||||||
|
|
||||||
|
export const useBedsStore = defineStore('beds', () => {
|
||||||
|
const beds = ref([])
|
||||||
|
const currentBed = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchBeds() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await bedsApi.list()
|
||||||
|
beds.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBed(id) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await bedsApi.get(id)
|
||||||
|
currentBed.value = data
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBed(payload) {
|
||||||
|
const { data } = await bedsApi.create(payload)
|
||||||
|
beds.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBed(id, payload) {
|
||||||
|
const { data } = await bedsApi.update(id, payload)
|
||||||
|
const idx = beds.value.findIndex((b) => b.id === id)
|
||||||
|
if (idx !== -1) beds.value[idx] = data
|
||||||
|
if (currentBed.value?.id === id) currentBed.value = { ...currentBed.value, ...data }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBed(id) {
|
||||||
|
await bedsApi.delete(id)
|
||||||
|
beds.value = beds.value.filter((b) => b.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlanting(bedId, payload) {
|
||||||
|
const { data } = await plantingsApi.create(bedId, payload)
|
||||||
|
if (currentBed.value?.id === bedId) {
|
||||||
|
currentBed.value.plantings = [...(currentBed.value.plantings || []), data]
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlanting(bedId, plantingId) {
|
||||||
|
await plantingsApi.delete(plantingId)
|
||||||
|
if (currentBed.value?.id === bedId) {
|
||||||
|
currentBed.value.plantings = currentBed.value.plantings.filter((p) => p.id !== plantingId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
beds, currentBed, loading,
|
||||||
|
fetchBeds, fetchBed, createBed, updateBed, deleteBed,
|
||||||
|
createPlanting, deletePlanting,
|
||||||
|
}
|
||||||
|
})
|
||||||
44
frontend/src/stores/plants.js
Normal file
44
frontend/src/stores/plants.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { plantsApi } from '@/api'
|
||||||
|
|
||||||
|
export const usePlantsStore = defineStore('plants', () => {
|
||||||
|
const plants = ref([])
|
||||||
|
const families = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchPlants() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await plantsApi.list()
|
||||||
|
plants.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFamilies() {
|
||||||
|
const { data } = await plantsApi.families()
|
||||||
|
families.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPlant(payload) {
|
||||||
|
const { data } = await plantsApi.create(payload)
|
||||||
|
plants.value.push(data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlant(id, payload) {
|
||||||
|
const { data } = await plantsApi.update(id, payload)
|
||||||
|
const idx = plants.value.findIndex((p) => p.id === id)
|
||||||
|
if (idx !== -1) plants.value[idx] = data
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlant(id) {
|
||||||
|
await plantsApi.delete(id)
|
||||||
|
plants.value = plants.value.filter((p) => p.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plants, families, loading, fetchPlants, fetchFamilies, createPlant, updatePlant, deletePlant }
|
||||||
|
})
|
||||||
147
frontend/src/views/BedDetailView.vue
Normal file
147
frontend/src/views/BedDetailView.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="bedsStore.loading" class="loading-center">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="bed">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<router-link to="/beete" class="back-link"><i class="pi pi-arrow-left" /> Alle Beete</router-link>
|
||||||
|
<h2>{{ bed.name }}</h2>
|
||||||
|
<div class="bed-meta">
|
||||||
|
<Tag :value="locationLabel(bed.location)" :severity="locationSeverity(bed.location)" />
|
||||||
|
<span>{{ bed.area_m2 }} m² ({{ bed.width_m }} × {{ bed.length_m }} m)</span>
|
||||||
|
<span>{{ soilLabel(bed.soil_type) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button label="Bepflanzung hinzufügen" icon="pi pi-plus" @click="plantingDialogVisible = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<p v-if="bed.notes" class="notes">{{ bed.notes }}</p>
|
||||||
|
|
||||||
|
<!-- Plantings Table -->
|
||||||
|
<h3 class="section-title">Aktuelle Bepflanzung</h3>
|
||||||
|
<DataTable :value="bed.plantings || []" striped-rows responsive-layout="scroll">
|
||||||
|
<template #empty>Noch keine Bepflanzungen eingetragen.</template>
|
||||||
|
|
||||||
|
<Column header="Pflanze" sortable sort-field="plant.name">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="plant-cell">
|
||||||
|
<span class="plant-name">{{ data.plant.name }}</span>
|
||||||
|
<span class="plant-latin">{{ data.plant.latin_name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Familie">
|
||||||
|
<template #body="{ data }">{{ data.plant.family.name }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Fläche / Stück">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.area_m2">{{ data.area_m2 }} m²</span>
|
||||||
|
<span v-if="data.area_m2 && data.count"> · </span>
|
||||||
|
<span v-if="data.count">{{ data.count }} Stk.</span>
|
||||||
|
<span v-if="!data.area_m2 && !data.count" class="dim">–</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Nährstoffbedarf">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.plant.nutrient_demand" :severity="nutrientSeverity(data.plant.nutrient_demand)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="planted_date" header="Gepflanzt" sortable>
|
||||||
|
<template #body="{ data }">{{ data.planted_date ? formatDate(data.planted_date) : '–' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="removed_date" header="Entfernt" sortable>
|
||||||
|
<template #body="{ data }">{{ data.removed_date ? formatDate(data.removed_date) : 'aktuell' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Notiz">
|
||||||
|
<template #body="{ data }">{{ data.notes || '–' }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column style="width: 5rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Button icon="pi pi-trash" text severity="danger" @click="confirmDeletePlanting(data)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Add Planting Dialog -->
|
||||||
|
<Dialog v-model:visible="plantingDialogVisible" header="Bepflanzung hinzufügen" modal style="width: 460px">
|
||||||
|
<PlantingForm @save="handleAddPlanting" @cancel="plantingDialogVisible = false" />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="not-found">Beet nicht gefunden.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useBedsStore } from '@/stores/beds'
|
||||||
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
|
import PlantingForm from '@/components/PlantingForm.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: { type: String, required: true } })
|
||||||
|
const bedsStore = useBedsStore()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const bed = computed(() => bedsStore.currentBed)
|
||||||
|
const plantingDialogVisible = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => bedsStore.fetchBed(props.id))
|
||||||
|
|
||||||
|
async function handleAddPlanting(payload) {
|
||||||
|
try {
|
||||||
|
await bedsStore.createPlanting(props.id, payload)
|
||||||
|
toast.add({ severity: 'success', summary: 'Hinzugefügt', detail: 'Bepflanzung eingetragen.', life: 3000 })
|
||||||
|
plantingDialogVisible.value = false
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.detail || 'Fehler beim Speichern.', life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeletePlanting(planting) {
|
||||||
|
confirm.require({
|
||||||
|
message: `Bepflanzung „${planting.plant.name}" entfernen?`,
|
||||||
|
header: 'Bestätigung',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Entfernen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
await bedsStore.deletePlanting(props.id, planting.id)
|
||||||
|
toast.add({ severity: 'info', summary: 'Entfernt', detail: 'Bepflanzung wurde gelöscht.', life: 3000 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (d) => new Date(d).toLocaleDateString('de-DE')
|
||||||
|
const locationLabel = (v) => ({ sonnig: 'Sonnig', halbschatten: 'Halbschatten', schatten: 'Schatten' }[v] || v)
|
||||||
|
const locationSeverity = (v) => ({ sonnig: 'warning', halbschatten: 'info', schatten: 'secondary' }[v] || 'secondary')
|
||||||
|
const soilLabel = (v) => ({ normal: 'Normal', sandig: 'Sandig', lehmig: 'Lehmig', humusreich: 'Humusreich' }[v] || v)
|
||||||
|
const nutrientSeverity = (v) => ({ schwach: 'success', mittel: 'warning', stark: 'danger' }[v] || 'secondary')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||||
|
.back-link { display: flex; align-items: center; gap: 0.3rem; color: var(--text-color-secondary); font-size: 0.85rem; text-decoration: none; margin-bottom: 0.3rem; }
|
||||||
|
.back-link:hover { color: var(--green-700); }
|
||||||
|
h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.4rem; }
|
||||||
|
.bed-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; color: var(--text-color-secondary); }
|
||||||
|
.notes { color: var(--text-color-secondary); font-size: 0.875rem; margin-bottom: 1rem; font-style: italic; }
|
||||||
|
.section-title { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.plant-cell { display: flex; flex-direction: column; }
|
||||||
|
.plant-name { font-weight: 600; }
|
||||||
|
.plant-latin { font-size: 0.8rem; color: var(--text-color-secondary); font-style: italic; }
|
||||||
|
.dim { color: var(--text-color-secondary); }
|
||||||
|
.loading-center { display: flex; justify-content: center; padding: 3rem; }
|
||||||
|
.not-found { text-align: center; color: var(--text-color-secondary); padding: 3rem; }
|
||||||
|
</style>
|
||||||
136
frontend/src/views/BedsView.vue
Normal file
136
frontend/src/views/BedsView.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>Beete</h2>
|
||||||
|
<p class="subtitle">Übersicht aller Beete in diesem Tenant</p>
|
||||||
|
</div>
|
||||||
|
<Button label="Neues Beet" icon="pi pi-plus" @click="openCreateDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="bedsStore.beds"
|
||||||
|
:loading="bedsStore.loading"
|
||||||
|
striped-rows
|
||||||
|
hover
|
||||||
|
responsive-layout="scroll"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
<template #empty>Noch keine Beete vorhanden.</template>
|
||||||
|
|
||||||
|
<Column field="name" header="Name" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<router-link :to="`/beete/${data.id}`" class="bed-link">{{ data.name }}</router-link>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Größe (m²)" sortable sort-field="area_m2">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.area_m2 }} m²
|
||||||
|
<span class="dim">({{ data.width_m }} × {{ data.length_m }} m)</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="location" header="Lage" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="locationLabel(data.location)" :severity="locationSeverity(data.location)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="soil_type" header="Bodentyp" sortable>
|
||||||
|
<template #body="{ data }">{{ soilLabel(data.soil_type) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Aktionen" style="width: 8rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="actions">
|
||||||
|
<Button icon="pi pi-pencil" text severity="secondary" @click="openEditDialog(data)" />
|
||||||
|
<Button icon="pi pi-trash" text severity="danger" @click="confirmDelete(data)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Create/Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="dialogVisible"
|
||||||
|
:header="editingBed ? 'Beet bearbeiten' : 'Neues Beet'"
|
||||||
|
modal
|
||||||
|
style="width: 480px"
|
||||||
|
>
|
||||||
|
<BedForm :initial="editingBed" @save="handleSave" @cancel="dialogVisible = false" />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useBedsStore } from '@/stores/beds'
|
||||||
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import BedForm from '@/components/BedForm.vue'
|
||||||
|
|
||||||
|
const bedsStore = useBedsStore()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingBed = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => bedsStore.fetchBeds())
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
editingBed.value = null
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
function openEditDialog(bed) {
|
||||||
|
editingBed.value = bed
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(payload) {
|
||||||
|
try {
|
||||||
|
if (editingBed.value) {
|
||||||
|
await bedsStore.updateBed(editingBed.value.id, payload)
|
||||||
|
toast.add({ severity: 'success', summary: 'Gespeichert', detail: 'Beet aktualisiert.', life: 3000 })
|
||||||
|
} else {
|
||||||
|
await bedsStore.createBed(payload)
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Neues Beet angelegt.', life: 3000 })
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.detail || 'Speichern fehlgeschlagen.', life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(bed) {
|
||||||
|
confirm.require({
|
||||||
|
message: `Beet „${bed.name}" wirklich löschen?`,
|
||||||
|
header: 'Bestätigung',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
await bedsStore.deleteBed(bed.id)
|
||||||
|
toast.add({ severity: 'info', summary: 'Gelöscht', detail: 'Beet wurde entfernt.', life: 3000 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationLabel = (v) => ({ sonnig: 'Sonnig', halbschatten: 'Halbschatten', schatten: 'Schatten' }[v] || v)
|
||||||
|
const locationSeverity = (v) => ({ sonnig: 'warning', halbschatten: 'info', schatten: 'secondary' }[v] || 'secondary')
|
||||||
|
const soilLabel = (v) => ({ normal: 'Normal', sandig: 'Sandig', lehmig: 'Lehmig', humusreich: 'Humusreich' }[v] || v)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.page-header h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.15rem; }
|
||||||
|
.subtitle { color: var(--text-color-secondary); font-size: 0.875rem; }
|
||||||
|
.bed-link { color: var(--green-700); text-decoration: none; font-weight: 600; }
|
||||||
|
.bed-link:hover { text-decoration: underline; }
|
||||||
|
.dim { color: var(--text-color-secondary); font-size: 0.8rem; margin-left: 0.3rem; }
|
||||||
|
.actions { display: flex; gap: 0.25rem; }
|
||||||
|
.mt-3 { margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
104
frontend/src/views/LoginView.vue
Normal file
104
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-wrapper">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<i class="pi pi-leaf" style="font-size: 2.5rem; color: var(--green-500)" />
|
||||||
|
<h1>Gartenmanager</h1>
|
||||||
|
<p>Bitte melden Sie sich an</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<InputText
|
||||||
|
id="email"
|
||||||
|
v-model="form.email"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Passwort</label>
|
||||||
|
<Password
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
:feedback="false"
|
||||||
|
toggle-mask
|
||||||
|
:disabled="loading"
|
||||||
|
required
|
||||||
|
class="w-full"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message v-if="errorMsg" severity="error" :closable="false">{{ errorMsg }}</Message>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
label="Anmelden"
|
||||||
|
icon="pi pi-sign-in"
|
||||||
|
:loading="loading"
|
||||||
|
class="w-full mt-2"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import Password from 'primevue/password'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Message from 'primevue/message'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const form = ref({ email: '', password: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await auth.login(form.value.email, form.value.password)
|
||||||
|
const redirect = route.query.redirect || '/beete'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch (err) {
|
||||||
|
errorMsg.value = err.response?.data?.detail || 'Anmeldung fehlgeschlagen.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-wrapper {
|
||||||
|
min-height: 100vh; display: flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: var(--surface-ground);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%; max-width: 420px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
.login-header { text-align: center; margin-bottom: 2rem; }
|
||||||
|
.login-header h1 { font-size: 1.6rem; font-weight: 700; color: var(--green-700); margin: 0.5rem 0 0.25rem; }
|
||||||
|
.login-header p { color: var(--text-color-secondary); font-size: 0.9rem; }
|
||||||
|
.field { margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
label { font-size: 0.875rem; font-weight: 600; color: var(--text-color); }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
165
frontend/src/views/PlantsView.vue
Normal file
165
frontend/src/views/PlantsView.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>Pflanzenbibliothek</h2>
|
||||||
|
<p class="subtitle">Globale und eigene Pflanzen</p>
|
||||||
|
</div>
|
||||||
|
<Button label="Eigene Pflanze" icon="pi pi-plus" @click="openCreateDialog" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-bar">
|
||||||
|
<InputText v-model="filterText" placeholder="Suchen…" class="filter-input" />
|
||||||
|
<Dropdown
|
||||||
|
v-model="filterFamily"
|
||||||
|
:options="[{ id: null, name: 'Alle Familien' }, ...plantsStore.families]"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Familie"
|
||||||
|
class="filter-dropdown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="filteredPlants"
|
||||||
|
:loading="plantsStore.loading"
|
||||||
|
striped-rows
|
||||||
|
responsive-layout="scroll"
|
||||||
|
class="mt-3"
|
||||||
|
sort-field="name"
|
||||||
|
:sort-order="1"
|
||||||
|
>
|
||||||
|
<template #empty>Keine Pflanzen gefunden.</template>
|
||||||
|
|
||||||
|
<Column field="name" header="Name" sortable />
|
||||||
|
<Column field="latin_name" header="Lateinisch" sortable>
|
||||||
|
<template #body="{ data }"><i>{{ data.latin_name || '–' }}</i></template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Familie" sortable sort-field="family.name">
|
||||||
|
<template #body="{ data }">{{ data.family.name }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="nutrient_demand" header="Nährstoff" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.nutrient_demand" :severity="nutrientSeverity(data.nutrient_demand)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="water_demand" header="Wasser" sortable>
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.water_demand" :severity="waterSeverity(data.water_demand)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="spacing_cm" header="Abstand" sortable>
|
||||||
|
<template #body="{ data }">{{ data.spacing_cm }} cm</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Aussaat" sortable sort-field="sowing_start_month">
|
||||||
|
<template #body="{ data }">{{ monthName(data.sowing_start_month) }} – {{ monthName(data.sowing_end_month) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="rest_years" header="Ruhezeit" sortable>
|
||||||
|
<template #body="{ data }">{{ data.rest_years }} J.</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Quelle">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.tenant_id ? 'Eigene' : 'Global'" :severity="data.tenant_id ? 'info' : 'secondary'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column style="width: 5rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Button
|
||||||
|
v-if="data.tenant_id"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
severity="danger"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="dialogVisible" header="Eigene Pflanze hinzufügen" modal style="width: 520px">
|
||||||
|
<PlantForm @save="handleSave" @cancel="dialogVisible = false" />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { usePlantsStore } from '@/stores/plants'
|
||||||
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import Dropdown from 'primevue/dropdown'
|
||||||
|
import PlantForm from '@/components/PlantForm.vue'
|
||||||
|
|
||||||
|
const plantsStore = usePlantsStore()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const filterText = ref('')
|
||||||
|
const filterFamily = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([plantsStore.fetchPlants(), plantsStore.fetchFamilies()])
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredPlants = computed(() => {
|
||||||
|
let list = plantsStore.plants
|
||||||
|
if (filterText.value) {
|
||||||
|
const q = filterText.value.toLowerCase()
|
||||||
|
list = list.filter((p) => p.name.toLowerCase().includes(q) || (p.latin_name || '').toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
if (filterFamily.value) {
|
||||||
|
list = list.filter((p) => p.family.id === filterFamily.value)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(payload) {
|
||||||
|
try {
|
||||||
|
await plantsStore.createPlant(payload)
|
||||||
|
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Pflanze wurde hinzugefügt.', life: 3000 })
|
||||||
|
dialogVisible.value = false
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.detail || 'Fehler beim Speichern.', life: 5000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(plant) {
|
||||||
|
confirm.require({
|
||||||
|
message: `Pflanze „${plant.name}" wirklich löschen?`,
|
||||||
|
header: 'Bestätigung',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
await plantsStore.deletePlant(plant.id)
|
||||||
|
toast.add({ severity: 'info', summary: 'Gelöscht', detail: 'Pflanze entfernt.', life: 3000 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ['', 'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||||
|
const monthName = (m) => MONTHS[m] || m
|
||||||
|
const nutrientSeverity = (v) => ({ schwach: 'success', mittel: 'warning', stark: 'danger' }[v] || 'secondary')
|
||||||
|
const waterSeverity = (v) => ({ wenig: 'success', mittel: 'info', viel: 'warning' }[v] || 'secondary')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header { display: flex; align-items: flex-start; justify-content: space-between; }
|
||||||
|
.page-header h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.15rem; }
|
||||||
|
.subtitle { color: var(--text-color-secondary); font-size: 0.875rem; }
|
||||||
|
.filter-bar { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||||
|
.filter-input { min-width: 200px; }
|
||||||
|
.filter-dropdown { min-width: 180px; }
|
||||||
|
.mt-3 { margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
26
frontend/vite.config.js
Normal file
26
frontend/vite.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.{js,ts}'],
|
||||||
|
},
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: import.meta.env?.VITE_API_BASE_URL || 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user