Compare commits
13 Commits
3dceae930c
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b00036951 | ||
|
|
26e8b2cd0c | ||
|
|
834a3bf4d5 | ||
|
|
905115d115 | ||
|
|
f4b1f1e30c | ||
|
|
b58edfc6eb | ||
|
|
5d9d517d18 | ||
|
|
d1831955c7 | ||
|
|
1abf4a647d | ||
|
|
c2eb905d62 | ||
|
|
6af5df32f6 | ||
|
|
80c73595d2 | ||
|
|
cd7a3f7414 |
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 -->
|
||||
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
|
||||
107
CLAUDE.md
107
CLAUDE.md
@@ -2,46 +2,89 @@
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| [docs/development-standards.md](docs/development-standards.md) | Allgemeine Entwicklungsstandards (Coding Style, Git, Testing) |
|
||||
| [docs/project-structure.md](docs/project-structure.md) | Projektstruktur und Architekturübersicht |
|
||||
- **Code-Bezeichner, Commit-Messages:** Englisch, Imperativ (`Add`, `Fix`, `Refactor`)
|
||||
- **Dokumentation & Kommentare:** Deutsch
|
||||
- **Commit-Format:** `<type>: <short description>` (max. 72 Zeichen)
|
||||
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
|
||||
|
||||
> Befehle eintragen, sobald Build-System definiert ist.
|
||||
## Scripts (immer diese verwenden)
|
||||
|
||||
```bash
|
||||
# Abhängigkeiten installieren
|
||||
# <install-command>
|
||||
|
||||
# Entwicklungsserver starten
|
||||
# <dev-command>
|
||||
|
||||
# Tests ausführen
|
||||
# <test-command>
|
||||
|
||||
# Einzelnen Test ausführen
|
||||
# <single-test-command>
|
||||
|
||||
# Linting
|
||||
# <lint-command>
|
||||
|
||||
# Build für Produktion
|
||||
# <build-command>
|
||||
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
|
||||
```
|
||||
|
||||
## Wichtige Konventionen
|
||||
## Architektur
|
||||
|
||||
- Sprache: Deutsch für Domänenkonzepte (Pflanzen, Beet, Aussaat …), Englisch für Code-Bezeichner und Commit-Messages
|
||||
- Alle Dateipfade in den `docs/`-Dokumenten aktuell halten, wenn sich die Struktur ändert
|
||||
```
|
||||
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())
|
||||
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
|
||||
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:
|
||||
@@ -13,17 +13,27 @@ Allgemein gültige Standards für die Arbeit in diesem Repository.
|
||||
|
||||
---
|
||||
|
||||
## Git
|
||||
## Git & Branching
|
||||
|
||||
### Branching
|
||||
### Struktur
|
||||
|
||||
```
|
||||
main – stabiler Produktionsstand
|
||||
feature/<name> – neue Features
|
||||
fix/<name> – Bugfixes
|
||||
chore/<name> – Wartung, Abhängigkeiten, Konfiguration
|
||||
main
|
||||
└── develop
|
||||
├── feature/<name> – neue Features
|
||||
├── 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
|
||||
|
||||
Format: `<type>: <short description>` (max. 72 Zeichen)
|
||||
@@ -37,7 +47,31 @@ Format: `<type>: <short description>` (max. 72 Zeichen)
|
||||
| `docs` | Nur Dokumentation |
|
||||
| `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)
|
||||
- Keine Mocks für die Datenbank in Integrationstests
|
||||
- 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.
|
||||
> Bei strukturellen Änderungen bitte hier aktualisieren.
|
||||
> **Token-Sparmaßnahme:** Dieses Dokument ist die erste Anlaufstelle.
|
||||
> Vor dem Öffnen von Quellcode hier nachschlagen.
|
||||
> Bei jeder Änderung an Funktionen, Modulen oder der Verzeichnisstruktur sofort aktualisieren.
|
||||
|
||||
---
|
||||
|
||||
## Verzeichnisübersicht
|
||||
## Verzeichnisstruktur
|
||||
|
||||
```
|
||||
gartenmanager/
|
||||
├── CLAUDE.md # Guidance für Claude Code
|
||||
├── docs/
|
||||
│ ├── development-standards.md # Allgemeine Entwicklungsstandards
|
||||
│ └── project-structure.md # Dieses Dokument
|
||||
│
|
||||
│ (weitere Verzeichnisse entstehen mit dem Projekt)
|
||||
├── .claude/ # Claude-Tooling (kein Projektcode)
|
||||
│ ├── scripts/bump.sh # Version bumpen + commit + push
|
||||
│ ├── scripts/new-feature.sh # Feature-Branch erstellen
|
||||
│ └── session-context.md # Sessionstart-Kontext
|
||||
├── .gitea/PULL_REQUEST_TEMPLATE.md
|
||||
├── 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:
|
||||
>
|
||||
> - **Monolith** (z. B. Full-Stack-Framework wie Laravel, Django, Rails)
|
||||
> - **Frontend + Backend getrennt** (z. B. Vue/React + REST/GraphQL API)
|
||||
> - **Mobile App** (z. B. Flutter, React Native)
|
||||
```
|
||||
users → id, email, hashed_password, is_superadmin
|
||||
tenants → id, name, slug
|
||||
user_tenants → user_id, tenant_id, role
|
||||
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:
|
||||
|
||||
| Konzept | Beschreibung |
|
||||
|---|---|
|
||||
| `Plant` / Pflanze | Eine Pflanzenart mit Eigenschaften (Aussaatzeit, Abstand, Wasserbedarf …) |
|
||||
| `Bed` / Beet | Ein physischer Gartenbereich, dem Pflanzen zugeordnet werden |
|
||||
| `SowingCalendar` / Aussaatkalender | Zeitplan für Aussaat und Pflanzung je Pflanze und Jahr |
|
||||
| `Task` / Aufgabe | Eine zu erledigende Gartenarbeit mit Fälligkeitsdatum |
|
||||
| `WateringSchedule` / Bewässerungsplan | Regelmäßige Bewässerungsintervalle je Beet oder Pflanze |
|
||||
|
||||
---
|
||||
|
||||
## Datenhaltung
|
||||
|
||||
> Noch festzulegen (SQLite, PostgreSQL, lokale Dateien …).
|
||||
|
||||
---
|
||||
|
||||
## Schnittstellen
|
||||
|
||||
> Noch festzulegen.
|
||||
```
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/refresh
|
||||
GET /api/v1/auth/me
|
||||
GET /api/v1/plant-families
|
||||
GET /api/v1/plants
|
||||
GET /api/v1/plants/{id}
|
||||
POST /api/v1/plants (READ_WRITE+)
|
||||
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)
|
||||
POST /api/v1/beds (READ_WRITE+)
|
||||
PUT /api/v1/beds/{id} (READ_WRITE+)
|
||||
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+)
|
||||
DELETE /api/v1/plantings/{id} (READ_WRITE+)
|
||||
GET /health
|
||||
```
|
||||
|
||||
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;
|
||||
}
|
||||
25
frontend/package.json
Normal file
25
frontend/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"vite": "^5.2.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 }
|
||||
})
|
||||
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>
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
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