14 Commits

Author SHA1 Message Date
Faultier314
79d8638f47 fix: use @example.com in test emails, .local rejected by email-validator
Some checks are pending
Tests / Backend Tests (push) Waiting to run
Tests / Frontend Tests (push) Waiting to run
2026-04-06 12:14:07 +02:00
Faultier314
951d052b1d fix: pin bcrypt==3.2.2 for passlib 1.7.4 compatibility
Some checks failed
Tests / Backend Tests (push) Failing after 54s
Tests / Frontend Tests (push) Successful in 46s
2026-04-06 12:11:40 +02:00
Faultier314
87cfdcb692 fix: use sync setup_database + NullPool per test to avoid event loop issues
Some checks failed
Tests / Backend Tests (push) Failing after 47s
Tests / Frontend Tests (push) Successful in 44s
2026-04-06 12:09:01 +02:00
Faultier314
de0e00cf31 fix: use session loop scope, drop event_loop fixture, add ESLint config
Some checks failed
Tests / Backend Tests (push) Failing after 1m3s
Tests / Frontend Tests (push) Successful in 48s
2026-04-06 12:05:59 +02:00
Faultier314
feb7fce4ab fix: rewrite conftest to avoid event loop mismatch in pytest-asyncio
Some checks failed
Tests / Backend Tests (push) Failing after 1m1s
Tests / Frontend Tests (push) Failing after 45s
2026-04-06 12:02:28 +02:00
Faultier314
e05cf90c5b fix: remove alembic from CI tests, drop_all before create_all in conftest
Some checks failed
Tests / Backend Tests (push) Failing after 1m2s
Tests / Frontend Tests (push) Failing after 40s
2026-04-06 10:27:23 +02:00
Faultier314
0c55d2cb49 fix: use postgres hostname for CI DB and fix node setup
Some checks failed
Tests / Backend Tests (push) Failing after 34s
Tests / Frontend Tests (push) Failing after 42s
2026-04-06 10:24:41 +02:00
Faultier314
4305d104e5 feat: add CI/CD pipelines, test suite, and ci/staging branch
Some checks failed
Tests / Backend Tests (push) Failing after 5m42s
Tests / Frontend Tests (push) Failing after 1m11s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 10:13:12 +02:00
Faultier314
5b00036951 docs: update CLAUDE.md and add git helper scripts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 07:56:33 +02:00
Faultier314
26e8b2cd0c fix: correct version to 0.3.0 (MAJOR only changes on user instruction)
Version: 0.3.0
2026-04-06 07:46:37 +02:00
Faultier314
834a3bf4d5 feat: Phase 1 complete – full working application
Backend (FastAPI):
- REST API: auth, plants, beds, plantings
- CRUD layer with CRUDBase
- Pydantic v2 schemas for all entities
- Alembic migration: complete schema + all enums
- Seed data: 28 global plants + 15 compatibilities

Frontend (Vue 3 + PrimeVue):
- Axios client with JWT interceptor + auto-refresh
- Pinia stores: auth, beds, plants
- Views: Login, Beds, BedDetail, PlantLibrary
- Components: AppLayout, BedForm, PlantingForm, PlantForm

Docker:
- docker-compose.yml (production)
- docker-compose.dev.yml (development with hot-reload)
- Nginx config with SPA fallback + API proxy
- Multi-stage frontend Dockerfile
- .env.example, .gitignore

Version: 1.0.0-alpha
2026-04-06 07:45:00 +02:00
Faultier314
905115d115 docs: update CLAUDE.md with actual techstack and architecture 2026-04-06 07:32:20 +02:00
Faultier314
f4b1f1e30c chore: update session-context with partial backend file inventory 2026-04-05 23:25:04 +02:00
Faultier314
b58edfc6eb chore: save session state – feature/phase-1 ready to implement
- Update session-context.md with exact resume point for next session
- Update settings.local.json with broader git permissions
- feature/grundstruktur merged to develop
- PAT authentication configured

Version: 0.2.3
2026-04-05 23:24:21 +02:00
89 changed files with 4600 additions and 119 deletions

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

View 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)"

View 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"

View File

@@ -1,7 +1,7 @@
# Session-Kontext
> Claude liest diese Datei zu Beginn jeder Session.
> Claude aktualisiert sie am Ende jeder Session (Version, Branch, offene Arbeit).
> Claude aktualisiert sie am Ende jeder Session.
---
@@ -9,34 +9,55 @@
| Feld | Wert |
|---|---|
| **Version** | 0.2.1 |
| **Aktiver Branch** | feature/grundstruktur |
| **Version** | 0.3.0 |
| **Aktiver Branch** | feature/phase-1 |
| **Basis-Branch** | develop |
| **Zuletzt geändert** | 2026-04-05 |
| **Zuletzt geändert** | 2026-04-06 |
## Offene Arbeit
## Phase 1 Status: ABGESCHLOSSEN ✓
- [ ] Techstack festlegen
- [ ] feature/grundstruktur → develop mergen (wenn Techstack entschieden)
Alle Dateien implementiert und gepusht. System ist startbereit.
## Zuletzt abgeschlossen
## Offene Arbeit nächste Session
- Repo-Infrastruktur aufgebaut (CLAUDE.md, Standards, Branching, README, PR-Template)
- .gitattributes, bump.sh, new-feature.sh, session-context.md eingeführt
- Branch Protection + Squash-Merge serverseitig konfiguriert
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>
# Aktueller Branch
git branch --show-current
# Status
git status
```

View File

@@ -1,14 +1,7 @@
{
"permissions": {
"allow": [
"Bash(curl -s -o /dev/null -w \"%{http_code}\" https://tea.jr-family.de/api/v1/repos/Admin/gartenmanager)",
"Bash(git -C c:/Projekte/Home/gartenmanager config --get credential.helper)",
"Bash(git credential:*)",
"Bash(python3 -m json.tool)",
"Bash(git -C c:/Projekte/Home/gartenmanager credential fill)",
"Bash(python3 -c \"import sys,json; r=json.load\\(sys.stdin\\); print\\('allow_squash_merge:', r.get\\('allow_squash_merge'\\), '| default_merge_style:', r.get\\('default_merge_style'\\)\\)\")",
"Bash(bash .claude/scripts/bump.sh patch \"Add autonomous branch-switching rule to workflow docs\")",
"Bash(bash .claude/scripts/bump.sh patch \"Update project plan: finalize phases, techstack and architecture decisions\")"
"Bash"
]
}
}

16
.env.example Normal file
View 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

View File

@@ -0,0 +1,58 @@
name: Build & Publish
on:
push:
branches:
- develop
- main
env:
REGISTRY: tea.jr-family.de
BACKEND_IMAGE: tea.jr-family.de/admin/gartenmanager-backend
FRONTEND_IMAGE: tea.jr-family.de/admin/gartenmanager-frontend
jobs:
publish:
name: Build & Push Images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read version
id: version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Compute image tags
id: tags
run: |
VERSION="${{ steps.version.outputs.version }}"
BRANCH="${GITHUB_REF_NAME}"
if [ "$BRANCH" = "main" ]; then
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION} ${{ env.BACKEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION} ${{ env.FRONTEND_IMAGE }}:latest" >> $GITHUB_OUTPUT
else
echo "backend_tags=${{ env.BACKEND_IMAGE }}:${VERSION}-dev ${{ env.BACKEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
echo "frontend_tags=${{ env.FRONTEND_IMAGE }}:${VERSION}-dev ${{ env.FRONTEND_IMAGE }}:dev" >> $GITHUB_OUTPUT
fi
- name: Login to Gitea registry
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} \
-u ${{ gitea.actor }} --password-stdin
- name: Build & push backend
run: |
docker build -t placeholder ./backend
for tag in ${{ steps.tags.outputs.backend_tags }}; do
docker tag placeholder $tag
docker push $tag
done
- name: Build & push frontend
run: |
docker build -t placeholder ./frontend
for tag in ${{ steps.tags.outputs.frontend_tags }}; do
docker tag placeholder $tag
docker push $tag
done

83
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,83 @@
name: Tests
on:
push:
branches:
- 'ci/**'
- develop
pull_request:
branches:
- develop
- main
jobs:
backend:
name: Backend Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: gartenmanager_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
cache-dependency-path: |
backend/requirements.txt
backend/requirements-test.txt
- name: Install dependencies
run: pip install -r backend/requirements.txt -r backend/requirements-test.txt
- name: Run tests
working-directory: backend
env:
DATABASE_URL: postgresql+asyncpg://test:test@postgres:5432/gartenmanager_test
SECRET_KEY: ci-test-secret-key-min-32-characters
run: pytest tests/ -v --cov=app --cov-report=xml --cov-report=term-missing
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: backend-coverage
path: backend/coverage.xml
frontend:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: frontend
run: npm install
- name: Lint
working-directory: frontend
run: npm run lint
- name: Unit tests
working-directory: frontend
run: npm run test
- name: Build check
working-directory: frontend
run: npm run build

31
.gitignore vendored Normal file
View 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

View File

@@ -4,6 +4,36 @@ 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

134
CLAUDE.md
View File

@@ -2,52 +2,108 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Projekt
## Verhaltensregeln
**Gartenmanager** Anwendung zur Verwaltung von Gartenaktivitäten (Pflanzen, Beete, Aussaatkalender, Aufgaben, Bewässerung).
- 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
## Dokumente wo was steht
## Sprache & Konventionen
| Dokument | Inhalt |
|---|---|
| [docs/development-standards.md](docs/development-standards.md) | **Alle Regeln:** Branching, Versionierung, Workflow, Coding, Testing |
| [docs/project-structure.md](docs/project-structure.md) | **Alle Module & Funktionen** hier zuerst lesen, bevor Quellcode geöffnet wird |
| [docs/branching-strategy.md](docs/branching-strategy.md) | Branch-Diagramm |
| [CHANGELOG.md](CHANGELOG.md) | Versionshistorie |
| [VERSION](VERSION) | Aktuelle Versionsnummer |
| [.claude/session-context.md](.claude/session-context.md) | **Sessionstart hier lesen:** aktiver Branch, Version, offene Arbeit |
| [.claude/scripts/](.claude/scripts/) | Automatisierungsscripts (bump, new-feature) |
- **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
## CI/CD Workflow
> Befehle eintragen, sobald Build-System definiert ist.
**Pflicht vor jedem Merge nach `develop`:**
```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>
# Version bumpen + commit + push (patch/minor/major)
bash .claude/scripts/bump.sh patch "Beschreibung der Änderung"
# Neuen Feature-Branch erstellen
bash .claude/scripts/new-feature.sh <name>
# 1. Auf ci/staging mergen und pushen
git checkout ci/staging
git merge feature/<name>
git push
# 2. Warten bis alle Actions grün sind (.gitea/workflows/test.yml)
# 3. Erst dann nach develop mergen
```
**Gitea Actions:**
- `test.yml` läuft auf `ci/**`, `develop`, PRs nach `develop`/`main`
- `publish.yml` baut + pusht Container bei Push nach `develop` und `main`
- Registry: `tea.jr-family.de` | Images: `admin/gartenmanager-backend`, `admin/gartenmanager-frontend`
- develop → Tag `:dev` | main → Tag `:latest` + Versionsnummer
## Scripts (immer diese verwenden)
```bash
bash .claude/scripts/git-commit.sh "message" # stage all + commit + push
bash .claude/scripts/git-switch.sh <branch> # branch wechseln
bash .claude/scripts/git-switch.sh <branch> from <base> # neuer branch aus base
bash .claude/scripts/git-pr.sh create <head> <base> "titel" # PR erstellen
bash .claude/scripts/git-pr.sh merge <nr> squash # PR mergen
bash .claude/scripts/git-pr.sh list # offene PRs
bash .claude/scripts/bump.sh [patch|minor] "beschreibung" # version + commit + push
bash .claude/scripts/new-feature.sh [feature|fix] <name> # branch aus develop
```
## Architektur
```
backend/app/
core/config.py Settings (DATABASE_URL, SECRET_KEY, ...)
core/security.py JWT (Access 30min / Refresh 7d), Passwort-Hashing
core/deps.py get_current_user, get_tenant_context, require_min_role()
db/session.py async Engine + get_session()
models/ SQLAlchemy ORM (UUID-PKs): User, Tenant, Plant, Bed, BedPlanting
schemas/ Pydantic v2 Create/Update/Read je Entität
crud/ CRUDBase + spezialisierte Klassen, nur DB-Zugriff
api/v1/ Router: auth, plants, beds, plantings
seeds/initial_data.py 28 Pflanzen + 15 Kompatibilitäten (idempotent)
frontend/src/
api/client.js Axios + JWT-Interceptor + Auto-Refresh bei 401
stores/ Pinia: auth, beds, plants
router/index.js Auth-Guard, /login /beete /beete/:id /pflanzen
views/ LoginView, BedsView, BedDetailView, PlantsView
components/ AppLayout, BedForm, PlantingForm, PlantForm
```
**Tenant-Kontext:** Header `X-Tenant-ID` bei allen Nicht-Auth-Requests.
**Rollen:** `READ_ONLY` < `READ_WRITE` < `TENANT_ADMIN` < `is_superadmin`
## Pflichtregeln
1. **Nie direkt nach `main`** nur PR, nur auf Anweisung
2. **Arbeit in `feature/`, `fix/` oder `chore/`** unter `develop`
3. **Nach jeder Änderung:** `bump.sh` ausführen (MINOR bei Features, PATCH bei Fixes)
4. **MAJOR-Version** niemals selbstständig erhöhen
5. **Vor Merge/PR:** CHANGELOG, README, `docs/project-structure.md` prüfen
6. **In `develop` mergen erst** wenn alle Tests grün sind
## Testing
- Integrationstests testen gegen echte DB (keine DB-Mocks)
- Backend: pytest in `backend/tests/` | Frontend: Vitest neben Quelldateien oder `__tests__/`
- Run: `docker compose exec backend pytest` / `cd frontend && npm run test`
## Build
```bash
docker compose -f docker-compose.dev.yml up # Dev (hot-reload)
docker compose up -d # Prod
docker compose exec backend alembic upgrade head
docker compose exec backend python -m app.seeds.initial_data
cd frontend && npm run dev
```

View File

@@ -1 +1 @@
0.2.3
0.3.0

6
backend/Dockerfile Normal file
View 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
View 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
View 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()

View 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
View File

View File

View File

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

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

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

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

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

View File

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

View 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

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

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

View File

13
backend/app/db/base.py Normal file
View 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
View 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
View 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}

View File

66
backend/app/models/bed.py Normal file
View 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
View 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"
)

View 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

View 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"
)

View 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"
)

View 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",
]

View 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"

View 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] = []

View 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

View 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

View 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

View 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

View File

View File

@@ -0,0 +1,171 @@
"""
Seed-Daten: Globale Pflanzenfamilien, Pflanzen und Kompatibilitäten.
Idempotent kann mehrfach ausgeführt werden ohne Fehler.
"""
import asyncio
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.models.plant import (
CompatibilityRating,
NutrientDemand,
Plant,
PlantCompatibility,
PlantFamily,
WaterDemand,
)
FAMILIES = [
{"name": "Solanaceae", "latin_name": "Solanaceae"},
{"name": "Kreuzblütler", "latin_name": "Brassicaceae"},
{"name": "Doldenblütler", "latin_name": "Apiaceae"},
{"name": "Hülsenfrüchtler", "latin_name": "Fabaceae"},
{"name": "Kürbisgewächse", "latin_name": "Cucurbitaceae"},
{"name": "Korbblütler", "latin_name": "Asteraceae"},
{"name": "Lauchgewächse", "latin_name": "Alliaceae"},
{"name": "Gänsefußgewächse", "latin_name": "Amaranthaceae"},
{"name": "Lippenblütler", "latin_name": "Lamiaceae"},
{"name": "Süßgräser", "latin_name": "Poaceae"},
]
# (name, latin_name, family_name, nutrient, water, spacing_cm, sow_start, sow_end, rest_years)
PLANTS = [
# Solanaceae
("Tomate", "Solanum lycopersicum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 60, 3, 4, 3),
("Paprika", "Capsicum annuum", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 2, 3, 3),
("Aubergine", "Solanum melongena", "Solanaceae", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 2, 3, 3),
# Kreuzblütler
("Brokkoli", "Brassica oleracea var. italica", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 45, 3, 4, 4),
("Weißkohl", "Brassica oleracea var. capitata", "Kreuzblütler", NutrientDemand.STARK, WaterDemand.MITTEL, 50, 3, 4, 4),
("Kohlrabi", "Brassica oleracea var. gongylodes", "Kreuzblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 22, 3, 7, 3),
("Radieschen", "Raphanus sativus", "Kreuzblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 5, 3, 8, 2),
# Doldenblütler
("Möhre", "Daucus carota", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.WENIG, 5, 3, 7, 3),
("Petersilie", "Petroselinum crispum", "Doldenblütler", NutrientDemand.MITTEL, WaterDemand.MITTEL, 18, 3, 5, 2),
("Sellerie", "Apium graveolens", "Doldenblütler", NutrientDemand.STARK, WaterDemand.VIEL, 28, 2, 3, 3),
# Hülsenfrüchtler
("Buschbohne", "Phaseolus vulgaris", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 12, 5, 7, 3),
("Erbse", "Pisum sativum", "Hülsenfrüchtler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 8, 3, 5, 3),
# Kürbisgewächse
("Gurke", "Cucumis sativus", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 60, 4, 5, 2),
("Zucchini", "Cucurbita pepo", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 90, 4, 5, 2),
("Kürbis", "Cucurbita maxima", "Kürbisgewächse", NutrientDemand.STARK, WaterDemand.VIEL, 180, 4, 5, 2),
# Korbblütler
("Kopfsalat", "Lactuca sativa", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 22, 3, 8, 2),
("Feldsalat", "Valerianella locusta", "Korbblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 8, 8, 9, 2),
# Lauchgewächse
("Zwiebel", "Allium cepa", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.WENIG, 12, 3, 4, 3),
("Lauch", "Allium porrum", "Lauchgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 2, 3, 3),
("Knoblauch", "Allium sativum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 10, 11, 4),
("Schnittlauch", "Allium schoenoprasum", "Lauchgewächse", NutrientDemand.SCHWACH, WaterDemand.WENIG, 10, 3, 4, 3),
# Gänsefußgewächse
("Mangold", "Beta vulgaris var. cicla", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 28, 3, 6, 3),
("Spinat", "Spinacia oleracea", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 12, 3, 9, 3),
("Rote Bete", "Beta vulgaris var. conditiva", "Gänsefußgewächse", NutrientDemand.MITTEL, WaterDemand.MITTEL, 10, 4, 6, 3),
# Lippenblütler
("Basilikum", "Ocimum basilicum", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.MITTEL, 20, 4, 5, 2),
("Thymian", "Thymus vulgaris", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
("Minze", "Mentha spicata", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.VIEL, 30, 3, 4, 2),
("Oregano", "Origanum vulgare", "Lippenblütler", NutrientDemand.SCHWACH, WaterDemand.WENIG, 25, 3, 4, 2),
]
# (plant_a_name, plant_b_name, rating, reason)
COMPATIBILITIES = [
("Tomate", "Basilikum", CompatibilityRating.GUT, "Basilikum fördert das Tomatenwachstum und hält Schädlinge fern."),
("Tomate", "Möhre", CompatibilityRating.GUT, "Gute Nachbarn, gegenseitige Förderung."),
("Tomate", "Petersilie", CompatibilityRating.GUT, "Petersilie stärkt die Tomaten."),
("Tomate", "Brokkoli", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
("Tomate", "Weißkohl", CompatibilityRating.SCHLECHT, "Kohl hemmt das Tomatenwachstum."),
("Möhre", "Zwiebel", CompatibilityRating.GUT, "Zwiebeln halten die Möhrenfliege fern."),
("Möhre", "Lauch", CompatibilityRating.GUT, "Lauch schützt die Möhre vor der Möhrenfliege."),
("Möhre", "Erbse", CompatibilityRating.GUT, "Erbsen lockern den Boden für Möhren."),
("Gurke", "Buschbohne", CompatibilityRating.GUT, "Klassische gute Nachbarschaft."),
("Weißkohl", "Sellerie", CompatibilityRating.GUT, "Sellerie hält die Kohlfliege fern."),
("Brokkoli", "Sellerie", CompatibilityRating.GUT, "Sellerie hält Kohlschädlinge fern."),
("Spinat", "Radieschen", CompatibilityRating.GUT, "Platzsparende Kombination, gegenseitig förderlich."),
("Zwiebel", "Buschbohne", CompatibilityRating.SCHLECHT, "Zwiebeln hemmen das Bohnenwachstum."),
("Zwiebel", "Erbse", CompatibilityRating.SCHLECHT, "Zwiebeln und Hülsenfrüchtler vertragen sich nicht."),
("Zwiebel", "Weißkohl", CompatibilityRating.SCHLECHT, "Konkurrenz um Nährstoffe."),
]
async def seed_initial_data(db: AsyncSession) -> None:
# 1. Plant families
family_map: dict[str, PlantFamily] = {}
for f in FAMILIES:
result = await db.execute(select(PlantFamily).where(PlantFamily.name == f["name"]))
existing = result.scalar_one_or_none()
if existing:
family_map[f["name"]] = existing
else:
obj = PlantFamily(id=uuid.uuid4(), **f)
db.add(obj)
await db.flush()
family_map[f["name"]] = obj
# 2. Global plants
plant_map: dict[str, Plant] = {}
for (name, latin, family_name, nutrient, water, spacing, sow_start, sow_end, rest) in PLANTS:
result = await db.execute(
select(Plant).where(Plant.name == name, Plant.tenant_id == None) # noqa: E711
)
existing = result.scalar_one_or_none()
if existing:
plant_map[name] = existing
else:
obj = Plant(
id=uuid.uuid4(),
tenant_id=None,
family_id=family_map[family_name].id,
name=name,
latin_name=latin,
nutrient_demand=nutrient,
water_demand=water,
spacing_cm=spacing,
sowing_start_month=sow_start,
sowing_end_month=sow_end,
rest_years=rest,
)
db.add(obj)
await db.flush()
plant_map[name] = obj
# 3. Compatibilities (both directions)
for (name_a, name_b, rating, reason) in COMPATIBILITIES:
if name_a not in plant_map or name_b not in plant_map:
continue
id_a = plant_map[name_a].id
id_b = plant_map[name_b].id
result = await db.execute(
select(PlantCompatibility).where(
PlantCompatibility.plant_id_a == id_a,
PlantCompatibility.plant_id_b == id_b,
)
)
if not result.scalar_one_or_none():
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_a, plant_id_b=id_b, rating=rating, reason=reason))
# Reverse direction
result = await db.execute(
select(PlantCompatibility).where(
PlantCompatibility.plant_id_a == id_b,
PlantCompatibility.plant_id_b == id_a,
)
)
if not result.scalar_one_or_none():
db.add(PlantCompatibility(id=uuid.uuid4(), plant_id_a=id_b, plant_id_b=id_a, rating=rating, reason=reason))
await db.commit()
print("Seed-Daten erfolgreich eingespielt.")
async def main() -> None:
async with AsyncSessionLocal() as db:
await seed_initial_data(db)
if __name__ == "__main__":
asyncio.run(main())

3
backend/pytest.ini Normal file
View File

@@ -0,0 +1,3 @@
[pytest]
asyncio_mode = auto
testpaths = tests

View File

@@ -0,0 +1,4 @@
pytest==8.2.0
pytest-asyncio==0.23.6
pytest-cov==5.0.0
httpx==0.27.0

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
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
bcrypt==3.2.2
python-multipart==0.0.9
greenlet==3.0.3

View File

125
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,125 @@
import asyncio
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.pool import NullPool
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from app.core.security import get_password_hash
from app.db.base import Base
from app.db.session import get_session
from app.main import app
from app.models.plant import PlantFamily
from app.models.tenant import Tenant
from app.models.user import TenantRole, User, UserTenant
def _make_engine():
return create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
def _make_session_factory(engine):
return async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(scope="session")
def setup_database():
"""Sync session fixture: Schema einmal aufbauen, am Ende abräumen."""
async def _up():
engine = _make_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
await engine.dispose()
async def _down():
engine = _make_engine()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
asyncio.run(_up())
yield
asyncio.run(_down())
@pytest_asyncio.fixture
async def db(setup_database):
engine = _make_engine()
async with _make_session_factory(engine)() as session:
yield session
await engine.dispose()
@pytest_asyncio.fixture
async def client(setup_database):
async def _override():
engine = _make_engine()
async with _make_session_factory(engine)() as session:
yield session
await engine.dispose()
app.dependency_overrides[get_session] = _override
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def test_tenant(db: AsyncSession):
tenant = Tenant(
id=uuid.uuid4(),
name="Test Tenant",
slug=f"test-{uuid.uuid4().hex[:8]}",
)
db.add(tenant)
await db.commit()
yield tenant
@pytest_asyncio.fixture
async def test_family(db: AsyncSession):
family = PlantFamily(
id=uuid.uuid4(),
name=f"Testfamilie-{uuid.uuid4().hex[:6]}",
latin_name=f"Familia testus {uuid.uuid4().hex[:6]}",
)
db.add(family)
await db.commit()
yield family
@pytest_asyncio.fixture
async def test_user(db: AsyncSession, test_tenant: Tenant):
email = f"user-{uuid.uuid4().hex[:6]}@example.com"
password = "testpass123"
user = User(
id=uuid.uuid4(),
email=email,
hashed_password=get_password_hash(password),
full_name="Test User",
is_superadmin=False,
)
db.add(user)
await db.flush()
db.add(UserTenant(user_id=user.id, tenant_id=test_tenant.id, role=TenantRole.READ_WRITE))
await db.commit()
user._plain_password = password # type: ignore[attr-defined]
yield user
@pytest_asyncio.fixture
async def auth_headers(client: AsyncClient, test_user: User, test_tenant: Tenant):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password}, # type: ignore[attr-defined]
)
assert resp.status_code == 200
token = resp.json()["access_token"]
return {
"Authorization": f"Bearer {token}",
"X-Tenant-ID": str(test_tenant.id),
}

View File

@@ -0,0 +1,53 @@
async def test_login_success(client, test_user):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(client, test_user):
resp = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": "wrongpassword"},
)
assert resp.status_code == 401
async def test_login_unknown_email(client):
resp = await client.post(
"/api/v1/auth/login",
json={"email": "nobody@example.com", "password": "irrelevant"},
)
assert resp.status_code == 401
async def test_me(client, auth_headers):
resp = await client.get("/api/v1/auth/me", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "email" in data
async def test_me_unauthenticated(client):
resp = await client.get("/api/v1/auth/me")
assert resp.status_code == 401
async def test_token_refresh(client, test_user):
login = await client.post(
"/api/v1/auth/login",
json={"email": test_user.email, "password": test_user._plain_password},
)
refresh_token = login.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token" in resp.json()

View File

@@ -0,0 +1,95 @@
import uuid
async def test_get_beds(client, auth_headers):
resp = await client.get("/api/v1/beds", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_get_beds_unauthenticated(client):
resp = await client.get("/api/v1/beds")
assert resp.status_code == 401
async def test_create_bed(client, auth_headers):
resp = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Testbeet",
"width_m": "1.5",
"length_m": "3.0",
"location": "sonnig",
"soil_type": "normal",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Testbeet"
assert data["tenant_id"] is not None
async def test_get_bed_by_id(client, auth_headers):
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Einzelbeet",
"width_m": "2.0",
"length_m": "4.0",
"location": "halbschatten",
},
)
bed_id = create.json()["id"]
resp = await client.get(f"/api/v1/beds/{bed_id}", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == bed_id
assert "plantings" in data
async def test_get_bed_not_found(client, auth_headers):
resp = await client.get(f"/api/v1/beds/{uuid.uuid4()}", headers=auth_headers)
assert resp.status_code == 404
async def test_update_bed(client, auth_headers):
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "Altbeet",
"width_m": "1.0",
"length_m": "2.0",
"location": "sonnig",
},
)
bed_id = create.json()["id"]
resp = await client.put(
f"/api/v1/beds/{bed_id}",
headers=auth_headers,
json={"name": "Neubeet"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Neubeet"
async def test_delete_bed_requires_tenant_admin(client, auth_headers):
# test_user has READ_WRITE, not TENANT_ADMIN → delete should fail
create = await client.post(
"/api/v1/beds",
headers=auth_headers,
json={
"name": "ZuLoeschenBeet",
"width_m": "1.0",
"length_m": "1.0",
"location": "schatten",
},
)
bed_id = create.json()["id"]
resp = await client.delete(f"/api/v1/beds/{bed_id}", headers=auth_headers)
assert resp.status_code == 403

View File

@@ -0,0 +1,6 @@
async def test_health(client):
resp = await client.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert "version" in data

View File

@@ -0,0 +1,114 @@
import uuid
async def test_get_plants(client, auth_headers):
resp = await client.get("/api/v1/plants", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_get_plants_unauthenticated(client):
resp = await client.get("/api/v1/plants")
assert resp.status_code == 401
async def test_get_plant_families(client, auth_headers):
resp = await client.get("/api/v1/plant-families", headers=auth_headers)
assert resp.status_code == 200
assert isinstance(resp.json(), list)
async def test_create_plant(client, auth_headers, test_family):
resp = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Testtomate",
"family_id": str(test_family.id),
"nutrient_demand": "stark",
"water_demand": "viel",
"spacing_cm": 50,
"sowing_start_month": 3,
"sowing_end_month": 5,
"rest_years": 3,
},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Testtomate"
assert data["tenant_id"] is not None
return data["id"]
async def test_get_plant_by_id(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Testsalat",
"family_id": str(test_family.id),
"nutrient_demand": "mittel",
"water_demand": "mittel",
"spacing_cm": 30,
"sowing_start_month": 3,
"sowing_end_month": 6,
"rest_years": 0,
},
)
plant_id = create.json()["id"]
resp = await client.get(f"/api/v1/plants/{plant_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["id"] == plant_id
async def test_get_plant_not_found(client, auth_headers):
resp = await client.get(f"/api/v1/plants/{uuid.uuid4()}", headers=auth_headers)
assert resp.status_code == 404
async def test_update_plant(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "Altname",
"family_id": str(test_family.id),
"nutrient_demand": "schwach",
"water_demand": "wenig",
"spacing_cm": 20,
"sowing_start_month": 4,
"sowing_end_month": 6,
"rest_years": 1,
},
)
plant_id = create.json()["id"]
resp = await client.put(
f"/api/v1/plants/{plant_id}",
headers=auth_headers,
json={"name": "Neuname"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Neuname"
async def test_delete_plant(client, auth_headers, test_family):
create = await client.post(
"/api/v1/plants",
headers=auth_headers,
json={
"name": "ZuLoeschenPflanze",
"family_id": str(test_family.id),
"nutrient_demand": "mittel",
"water_demand": "mittel",
"spacing_cm": 25,
"sowing_start_month": 4,
"sowing_end_month": 7,
"rest_years": 0,
},
)
plant_id = create.json()["id"]
resp = await client.delete(f"/api/v1/plants/{plant_id}", headers=auth_headers)
assert resp.status_code == 204

41
docker-compose.dev.yml Normal file
View 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
View 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:

View File

@@ -10,71 +10,138 @@
```
gartenmanager/
├── .claude/ # Claude-Tooling (kein Projektcode)
│ ├── scripts/
│ ├── bump.sh # Version bumpen + commit + push
│ └── new-feature.sh # Feature-Branch erstellen
│ └── session-context.md # Sessionstart-Kontext
├── .gitea/
── PULL_REQUEST_TEMPLATE.md
├── docs/
│ ├── branching-strategy.md
├── development-standards.md
└── project-structure.md # dieses Dokument
├── .gitattributes
├── .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 Quellcode-Verzeichnisse entstehen, hier ergänzen.
---
## 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.
---
## Modulübersicht
## Datenbankschema (Kurzform)
> Noch kein Anwendungscode vorhanden. Sobald Module/Komponenten entstehen:
>
> ```
> Modulname | Datei(en) | Zweck | Exportierte Funktionen
> ```
>
> **Format pro Funktion:**
> `funktionsname(param: Typ): Rückgabetyp` Ein-Satz-Beschreibung
```
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
## API-Routen Übersicht
| Entität | Felder (geplant) | Beziehungen |
|---|---|---|
| `Plant` | name, sowingStart, sowingEnd, waterInterval, spacing | gehört zu Bed |
| `Bed` | name, width, length, location | enthält viele Plants |
| `SowingCalendar` | year, plantId, sowDate, plantDate | referenziert Plant |
| `Task` | title, dueDate, done, bedId? | optional zu Bed |
| `WateringSchedule` | bedId/plantId, intervalDays, lastWatered | referenziert Bed oder Plant |
---
## Datenhaltung
> Noch festzulegen (SQLite, PostgreSQL, lokale Dateien …).
---
## Schnittstellen / API
> Noch festzulegen. Hier Endpunkte mit Kurzbeschreibung eintragen:
>
> ```
> GET /api/plants alle Pflanzen
> POST /api/plants neue Pflanze anlegen
> ...
> ```
---
## Konfiguration
> Relevante Umgebungsvariablen und Konfigurationsdateien hier auflisten.
```
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
```

20
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,20 @@
/* eslint-env node */
module.exports = {
root: true,
env: {
browser: true,
es2022: true,
},
extends: [
'plugin:vue/vue3-recommended',
'eslint:recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'vue/multi-word-component-names': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
}

13
frontend/Dockerfile Normal file
View 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
View 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
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API-Proxy zum Backend
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Health endpoint proxy
location /health {
proxy_pass http://backend:8000/health;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
}

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "gartenmanager-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext .vue,.js,.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"axios": "^1.6.8",
"pinia": "^2.1.7",
"primevue": "^3.53.0",
"primeicons": "^6.0.1",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"@vue/test-utils": "^2.4.6",
"vite": "^5.2.0",
"vitest": "^1.6.0",
"jsdom": "^24.1.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0"
}
}

23
frontend/src/App.vue Normal file
View 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>

View 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
View 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}`),
}

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

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

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

View 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 ()</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
View 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')

View 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

View 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 }
})

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from './auth'
// localStorage is not available in jsdom by default without setup,
// so we mock it minimally
const localStorageMock = (() => {
let store = {}
return {
getItem: (key) => store[key] ?? null,
setItem: (key, value) => { store[key] = String(value) },
removeItem: (key) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
// Mock the API module so tests don't make real HTTP calls
vi.mock('@/api', () => ({
authApi: {
login: vi.fn(),
},
}))
describe('useAuthStore', () => {
beforeEach(() => {
localStorageMock.clear()
setActivePinia(createPinia())
})
it('initializes with null user when localStorage is empty', () => {
const store = useAuthStore()
expect(store.user).toBeNull()
expect(store.tenants).toEqual([])
expect(store.activeTenantId).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
it('isLoggedIn is true when user is set', () => {
localStorageMock.setItem('user', JSON.stringify({ id: '1', email: 'a@b.de' }))
const store = useAuthStore()
expect(store.isLoggedIn).toBe(true)
})
it('logout clears state and localStorage', () => {
localStorageMock.setItem('user', JSON.stringify({ id: '1' }))
localStorageMock.setItem('tenants', JSON.stringify([{ id: 'tid' }]))
localStorageMock.setItem('tenant_id', 'tid')
const store = useAuthStore()
store.logout()
expect(store.user).toBeNull()
expect(store.tenants).toEqual([])
expect(store.activeTenantId).toBeNull()
expect(localStorageMock.getItem('user')).toBeNull()
})
it('setActiveTenant updates state and localStorage', () => {
const store = useAuthStore()
store.setActiveTenant('new-tenant-id')
expect(store.activeTenantId).toBe('new-tenant-id')
expect(localStorageMock.getItem('tenant_id')).toBe('new-tenant-id')
})
it('activeTenant returns matching tenant from list', () => {
const tenants = [
{ id: 'tid-1', name: 'Erster' },
{ id: 'tid-2', name: 'Zweiter' },
]
localStorageMock.setItem('tenants', JSON.stringify(tenants))
localStorageMock.setItem('tenant_id', 'tid-2')
const store = useAuthStore()
expect(store.activeTenant?.name).toBe('Zweiter')
})
})

View 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,
}
})

View 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 }
})

View 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 }} ({{ 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>

View 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 }}
<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>

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

View File

@@ -0,0 +1,165 @@
<template>
<div>
<div class="page-header">
<div>
<h2>Pflanzenbibliothek</h2>
<p class="subtitle">Globale und eigene Pflanzen</p>
</div>
<Button label="Eigene Pflanze" icon="pi pi-plus" @click="openCreateDialog" />
</div>
<div class="filter-bar">
<InputText v-model="filterText" placeholder="Suchen…" class="filter-input" />
<Dropdown
v-model="filterFamily"
:options="[{ id: null, name: 'Alle Familien' }, ...plantsStore.families]"
option-label="name"
option-value="id"
placeholder="Familie"
class="filter-dropdown"
/>
</div>
<DataTable
:value="filteredPlants"
:loading="plantsStore.loading"
striped-rows
responsive-layout="scroll"
class="mt-3"
sort-field="name"
:sort-order="1"
>
<template #empty>Keine Pflanzen gefunden.</template>
<Column field="name" header="Name" sortable />
<Column field="latin_name" header="Lateinisch" sortable>
<template #body="{ data }"><i>{{ data.latin_name || '' }}</i></template>
</Column>
<Column header="Familie" sortable sort-field="family.name">
<template #body="{ data }">{{ data.family.name }}</template>
</Column>
<Column field="nutrient_demand" header="Nährstoff" sortable>
<template #body="{ data }">
<Tag :value="data.nutrient_demand" :severity="nutrientSeverity(data.nutrient_demand)" />
</template>
</Column>
<Column field="water_demand" header="Wasser" sortable>
<template #body="{ data }">
<Tag :value="data.water_demand" :severity="waterSeverity(data.water_demand)" />
</template>
</Column>
<Column field="spacing_cm" header="Abstand" sortable>
<template #body="{ data }">{{ data.spacing_cm }} cm</template>
</Column>
<Column header="Aussaat" sortable sort-field="sowing_start_month">
<template #body="{ data }">{{ monthName(data.sowing_start_month) }} {{ monthName(data.sowing_end_month) }}</template>
</Column>
<Column field="rest_years" header="Ruhezeit" sortable>
<template #body="{ data }">{{ data.rest_years }} J.</template>
</Column>
<Column header="Quelle">
<template #body="{ data }">
<Tag :value="data.tenant_id ? 'Eigene' : 'Global'" :severity="data.tenant_id ? 'info' : 'secondary'" />
</template>
</Column>
<Column style="width: 5rem">
<template #body="{ data }">
<Button
v-if="data.tenant_id"
icon="pi pi-trash"
text
severity="danger"
@click="confirmDelete(data)"
/>
</template>
</Column>
</DataTable>
<Dialog v-model:visible="dialogVisible" header="Eigene Pflanze hinzufügen" modal style="width: 520px">
<PlantForm @save="handleSave" @cancel="dialogVisible = false" />
</Dialog>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { usePlantsStore } from '@/stores/plants'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Dropdown from 'primevue/dropdown'
import PlantForm from '@/components/PlantForm.vue'
const plantsStore = usePlantsStore()
const confirm = useConfirm()
const toast = useToast()
const dialogVisible = ref(false)
const filterText = ref('')
const filterFamily = ref(null)
onMounted(async () => {
await Promise.all([plantsStore.fetchPlants(), plantsStore.fetchFamilies()])
})
const filteredPlants = computed(() => {
let list = plantsStore.plants
if (filterText.value) {
const q = filterText.value.toLowerCase()
list = list.filter((p) => p.name.toLowerCase().includes(q) || (p.latin_name || '').toLowerCase().includes(q))
}
if (filterFamily.value) {
list = list.filter((p) => p.family.id === filterFamily.value)
}
return list
})
function openCreateDialog() {
dialogVisible.value = true
}
async function handleSave(payload) {
try {
await plantsStore.createPlant(payload)
toast.add({ severity: 'success', summary: 'Erstellt', detail: 'Pflanze wurde hinzugefügt.', life: 3000 })
dialogVisible.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.detail || 'Fehler beim Speichern.', life: 5000 })
}
}
function confirmDelete(plant) {
confirm.require({
message: `Pflanze „${plant.name}" wirklich löschen?`,
header: 'Bestätigung',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
acceptClass: 'p-button-danger',
accept: async () => {
await plantsStore.deletePlant(plant.id)
toast.add({ severity: 'info', summary: 'Gelöscht', detail: 'Pflanze entfernt.', life: 3000 })
},
})
}
const MONTHS = ['', 'Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const monthName = (m) => MONTHS[m] || m
const nutrientSeverity = (v) => ({ schwach: 'success', mittel: 'warning', stark: 'danger' }[v] || 'secondary')
const waterSeverity = (v) => ({ wenig: 'success', mittel: 'info', viel: 'warning' }[v] || 'secondary')
</script>
<style scoped>
.page-header { display: flex; align-items: flex-start; justify-content: space-between; }
.page-header h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.15rem; }
.subtitle { color: var(--text-color-secondary); font-size: 0.875rem; }
.filter-bar { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
.filter-input { min-width: 200px; }
.filter-dropdown { min-width: 180px; }
.mt-3 { margin-top: 1rem; }
</style>

26
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.test.{js,ts}'],
},
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: import.meta.env?.VITE_API_BASE_URL || 'http://localhost:8000',
changeOrigin: true,
},
},
},
})