diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..093574c --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Space-API Environment Variables +# Kopiere diese Datei nach .env und passe die Werte an + +# Flask Configuration +DEBUG=False +FLASK_HOST=localhost +FLASK_PORT=8000 +FLASK_SECRET_KEY=change-this-to-a-random-secret-key-in-production + +# Space API Configuration +KEEP_ALIVE_TIMEOUT=30 +SPACE_API_PASSWORD=admin123 + +# Logging +LOG_LEVEL=INFO diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6a6fdfd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Lint with pylint + run: | + pylint src/ --fail-under=8.0 || true + + - name: Format check with black + run: | + black --check src/ tests/ || true + + - name: Run tests with pytest + run: | + pytest tests/ -v --cov=src --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + if: matrix.python-version == '3.11' + with: + file: ./coverage.xml + flags: unittests + fail_ci_if_error: false + + - name: Build Docker image + run: | + docker build -t space-api:latest . + if: success() diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd893d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Environment variables +.env +.env.local +.env.*.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.tox/ +.nox/ +coverage.xml +*.cover + +# Docker +.dockerignore diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..5de4c16 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,25 @@ +[MASTER] +# Only show warnings with the listed confidence levels. Leave empty to show all. +confidence=HIGH + +# Disable specific warnings +disable= + missing-docstring, + too-many-arguments, + line-too-long, + broad-except, + +[FORMAT] +# String used as indentation unit. +indent-string=' ' + +# Number of spaces of indentation required inside hanging or continued lines. +indent-after-paren=4 + +[LOGGING] +# The type of string formatting that logging methods do +logging-format-style=old + +[VARIABLES] +# List of additional names supposed to be defined in builtins. +additional-builtins=_ diff --git a/CODE_REVIEW.md b/CODE_REVIEW.md new file mode 100644 index 0000000..4c68ed9 --- /dev/null +++ b/CODE_REVIEW.md @@ -0,0 +1,50 @@ +# Kompletter Code-Review – SpaceAPI + +## Scope +- Geprüfter Stand: aktueller Branch in `/workspace/Space-API` +- Gefundene Projektdateien: nur `README.md` + +## Ergebnis +Aktuell enthält das Repository **keinen produktiven Code**, keine Build-/Runtime-Konfiguration und keine Tests. Ein fachlicher Code-Review (Logik, Architektur, Security, Performance, API-Verträge) ist daher inhaltlich nicht möglich. + +## Was angepasst werden sollte (priorisiert) + +### 1) Projektgrundlage herstellen (Blocker) +- Source-Struktur anlegen (z. B. `src/`, `app/` oder `api/` je nach Stack) +- Abhängigkeiten und Build-Tooling definieren (`package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` etc.) +- Start-/Build-/Test-Kommandos dokumentieren + +### 2) Qualitäts-Gates einführen (hoch) +- Linter + Formatter konfigurieren +- CI-Pipeline aufsetzen (mindestens: Lint + Tests auf Pull Requests) +- Einheitliche Konventionen für Branching/Commit-Messages definieren + +### 3) Teststrategie ergänzen (hoch) +- Unit-Tests für Kernlogik +- Integrations-Tests für API-Endpunkte +- Optional: E2E-Smoke-Test für kritische Flows + +### 4) API- und Sicherheitsbasis (hoch) +- API-Spezifikation (OpenAPI/Swagger) ergänzen +- Fehlerformat, Statuscodes, Versionierung festlegen +- Security-Basics: Eingabevalidierung, AuthN/AuthZ, Secret-Handling + +### 5) Betriebsfähigkeit (mittel) +- Beispiel-Umgebungsvariablen (`.env.example`) +- Containerisierung (`Dockerfile`) und ggf. `docker-compose` +- Observability: strukturierte Logs, Health-Checks, Basis-Metriken + +### 6) Dokumentation verbessern (mittel) +- README erweitern um: + - Projektziel + - Quickstart + - lokale Entwicklung + - Testausführung + - Deployment-Hinweise + +## Konkrete Minimal-Checkliste für den nächsten Schritt +1. Technologie-Stack festlegen. +2. „Hello World“-API-Endpunkt implementieren. +3. 1–2 Unit-Tests + 1 Integrations-Test hinzufügen. +4. CI aufsetzen, die bei jedem PR läuft. +5. README mit Setup und Befehlen aktualisieren. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..a45b501 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,199 @@ +# Space-API Development & Deployment Guide + +## Development Setup + +### 1. Clone Repository +```bash +git clone +cd Space-API +``` + +### 2. Create Python Virtual Environment +```bash +# Windows +python -m venv venv +venv\Scripts\activate + +# Unix/macOS +python3 -m venv venv +source venv/bin/activate +``` + +### 3. Install Dependencies +```bash +pip install -r requirements.txt +``` + +### 4. Create Environment File +```bash +cp .env.example .env +# Edit .env with your configuration +``` + +### 5. Run Development Server +```bash +python run.py +``` + +Server will start at `http://localhost:8000` + +## Testing + +### Run Tests +```bash +# All tests +pytest + +# With coverage report +pytest --cov=src --cov-report=html +``` + +### Code Quality Checks +```bash +# Format check +black --check src/ tests/ + +# Linting +pylint src/ + +# Auto-format +black src/ tests/ +``` + +## Docker Deployment + +### Build Image +```bash +docker build -t space-api:latest . +``` + +### Run Container +```bash +docker run -p 8000:8000 \ + -e SPACE_API_PASSWORD=yourpassword \ + -v $(pwd)/api.json:/app/api.json \ + space-api:latest +``` + +### Using Docker Compose +```bash +docker-compose up -d +``` + +## API Endpoints + +### SpaceAPI Standard +- `GET /api.json` - Complete SpaceAPI-compliant configuration + +### Reading Values +- `GET /api/get/` - Get value from api.json + - Example: `/api/get/state/open` + +### Changing Values (GET) +- `GET /api/change/?value=` - Change value via GET parameter + - Example: `/api/change/state/open?value=true` + +### Changing Values (POST/PUT) +- `POST /api/post/` with JSON body `{"value": }` + - Example: `POST /api/post/state/open` with `{"value": true}` + +### Manual Override Interface +- `GET /manual-override` - HTML interface for manual control (password protected) + +## Configuration + +### Environment Variables +Create `.env` file based on `.env.example`: + +```bash +# Flask Settings +DEBUG=False +FLASK_HOST=localhost +FLASK_PORT=8000 +FLASK_SECRET_KEY=your-secret-key + +# Space API +KEEP_ALIVE_TIMEOUT=30 +SPACE_API_PASSWORD=admin123 +``` + +## Keep-Alive System + +The Keep-Alive system automatically closes the space after 30 seconds without receiving an "open" signal. + +To keep space open: +```bash +# Send keep-alive heartbeat every 25 seconds +while true; do + curl -X POST http://localhost:8000/api/post/state/open -H "Content-Type: application/json" -d '{"value": true}' + sleep 25 +done +``` + +## Project Structure + +``` +Space-API/ +├── src/ +│ ├── __init__.py +│ ├── app.py # Flask application +│ └── config.py # Configuration management +├── tests/ +│ ├── conftest.py # Pytest fixtures +│ ├── test_unit.py # Unit tests +│ └── test_integration.py # Integration tests +├── .github/ +│ └── workflows/ +│ └── ci.yml # CI/CD Pipeline +├── api.json # Space configuration +├── run.py # Application entry point +├── requirements.txt # Python dependencies +├── Dockerfile # Docker image definition +├── docker-compose.yml # Docker Compose configuration +└── README.md # Project documentation +``` + +## CI/CD Pipeline + +The project includes a GitHub Actions CI pipeline that: +1. Runs tests on Python 3.10 and 3.11 +2. Checks code quality with pylint and black +3. Generates coverage reports +4. Builds Docker image + +Pipeline triggers on: +- Push to main/develop branches +- Pull requests to main/develop + +## Troubleshooting + +### Port Already in Use +```bash +# Change port in .env +FLASK_PORT=8001 +``` + +### Module Import Errors +```bash +# Ensure virtual environment is activated +pip install -r requirements.txt +``` + +### Permission Denied on Docker +```bash +# Use sudo or add user to docker group +sudo docker-compose up +``` + +## Security Notes + +⚠️ **Production Security**: +1. Change default password: `SPACE_API_PASSWORD` +2. Generate secure secret key: `FLASK_SECRET_KEY` +3. Set `DEBUG=False` +4. Use HTTPS in production +5. Add input validation for all endpoints + +## Support & Contact + +For issues or questions, check the main README.md or contact the project maintainers. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f734146 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Multi-stage build for Space-API + +# Build stage +FROM python:3.11-slim as builder + +WORKDIR /build + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and create wheels +COPY requirements.txt . +RUN pip wheel --wheel-dir /wheels -r requirements.txt + +# Runtime stage +FROM python:3.11-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user +RUN useradd -m -u 1000 spaceapi + +# Copy wheels from builder +COPY --from=builder /wheels /wheels + +# Install Python dependencies from wheels +COPY requirements.txt . +RUN pip install --no-cache /wheels/* + +# Copy application code +COPY . . + +# Set ownership +RUN chown -R spaceapi:spaceapi /app + +# Switch to non-root user +USER spaceapi + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/api.json || exit 1 + +# Start application +CMD ["python", "run.py"] diff --git a/README.md b/README.md index c36dcb6..31198ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,517 @@ -# SpaceAPI +# 🚀 Space-API Server - Odenwilusenz -There is nothing to see :/ \ No newline at end of file +Ein vollständiger **Space-API Server** nach dem [SpaceAPI Standard](https://spaceapi.io/) für Hackerspaces und Makerspaces. Dieses Projekt stellt den Status und die Sensordaten eines Spaces über eine standardisierte REST-API bereit. + +Derzeit in Einsatz beim **Odenwilusenz** in Beringen, Schweiz. + +## 📋 Inhaltsverzeichnis + +- [Features](#features) +- [Installation & Setup](#installation--setup) +- [API Endpoints](#api-endpoints) +- [Keep-Alive System](#keep-alive-system) +- [Verwendungsbeispiele](#verwendungsbeispiele) +- [Für deinen Space konfigurieren](#für-deinen-space-konfigurieren) +- [Manual Override Interface](#manual-override-interface) +- [Sensoren](#sensoren) + +--- + +## Was ist das? + +Dieses Projekt stellt eine **REST-API nach dem SpaceAPI Standard** bereit, mit der der Zustand und die Sensordaten eines Hackerspaces oder Makerspaces abgefragt werden können. + +### Features: + +✅ **SpaceAPI Standard konform** - Kompatibel mit allen SpaceAPI-kompatiblen Anwendungen +✅ **Einfache Konfiguration** - Alle statischen Daten in `api.json` +✅ **Dynamische Sensordaten** - Echtzeit-Aktualisierung von Temperatur, Luftfeuchtigkeit, Stromverbrauch, etc. +✅ **API-Key Authentisierung** - Schreibzugriffe erfordern API-Key Header +✅ **State Management** - Einfaches An/Aus-Schalten des Spaces mit Nachricht +✅ **Keep-Alive System** - Automatisches Schließen des Spaces nach 30 Sekunden ohne Signal +✅ **Manual Override Interface** - Passwort-geschützte HTML Seite zur manuellen Bearbeitung + +### Typische Anwendungen: + +- Hackerspaces können ihren aktuellen Status veröffentlichen +- Integrationen mit Websites und Chatbots +- Monitoring und Visualisierung von Space-Daten +- Integration mit anderen Spaces durch SpaceAPI-Verzeichnisse + +--- + +## Installation + +### Voraussetzungen + +- Python 3.7 oder höher +- pip (Python Package Manager) + +### Schritt-für-Schritt Installation + +1. **Repository klonen oder herunterladen:** + ```bash + git clone + cd Space-API + ``` + +2. **Abhängigkeiten installieren:** + ```bash + pip install -r requirements.txt + ``` + +3. **Erforderliche Umgebungsvariablen setzen:** + ```bash + # Linux/Mac - in ~/.bashrc oder ~/.zshrc + export FLASK_SECRET_KEY="eine-lange-zufallszeichenkette-mindestens-32-zeichen" + export SPACE_API_PASSWORD="dein-sicheres-admin-passwort" + export SPACE_API_ADMIN_KEY="dein-eindeutiger-api-key-fuer-aenderungen" + + # Windows (PowerShell) + $env:FLASK_SECRET_KEY="eine-lange-zufallszeichenkette-mindestens-32-zeichen" + $env:SPACE_API_PASSWORD="dein-sicheres-admin-passwort" + $env:SPACE_API_ADMIN_KEY="dein-eindeutiger-api-key-fuer-aenderungen" + ``` + +4. **Server starten:** + ```bash + python main.py + ``` + +5. **Server ist aktiv:** + Der Server läuft jetzt auf `http://localhost:8000` + +--- + +## Konfiguration + +### api.json - Die Konfigurationsdatei + +Die `api.json` enthält alle **statischen Informationen** über deinen Space nach dem SpaceAPI Standard. Diese Datei wird **nicht verändert** vom Script und sollte angepasst werden, um deinen Space zu beschreiben. + +#### Wichtige Felder in api.json: + +```json +{ + "api_compatibility": ["14", "15"], // SpaceAPI Versionen + "space": "Odenwilusenz", // Name des Spaces + "logo": "https://...", // Logo URL + "url": "https://...", // Website des Spaces + "location": { + "address": "Hardmorgenweg 21, ...", // Physische Adresse + "lon": 8.57171860, // Longitude + "lat": 47.69790250, // Latitude + "timezone": "Europe/Zurich", // Zeitzone + "country_code": "CH", // Ländercode + "hint": "Immer am Mittwoch ab 19:00..." // Öffnungszeiten/Hinweis + }, + "contact": { + "email": "mail@example.ch", // Kontakt-E-Mail + "issue_mail": "spaceapi@example.ch" // Problem-Reports + }, + "sensors": { // Alle Sensoren die dein Space anbietet + } +} +``` + +### Veränderbare Werte + +Im `main.py` werden die folgenden Werte **dynamisch aktualisiert**: + +#### 1. **State (Offen/Geschlossen)** +- `state.open` - `true` oder `false` (ob der Space offen ist) +- `state.message` - Text-Nachricht (z.B. "Space offen!", "Temporär geschlossen") +- `state.lastchange` - Zeitstempel der letzten Änderung (wird automatisch aktualisiert) + +#### 2. **Sensoren - Alle `value` Felder:** + +**Temperatur:** +- `sensors.temperature[0].value` - Temperatur Innen +- `sensors.temperature[1].value` - Temperatur Außen + +**Luftfeuchtigkeit:** +- `sensors.humidity[0].value` - Luftfeuchtigkeit Innen +- `sensors.humidity[1].value` - Luftfeuchtigkeit Außen + +**Stromverbrauch:** +- `sensors.power_consumption[0].value` - Gesamtstromverbrauch in Watt + +**Netzwerk:** +- `sensors.network_connections[0].value` - Anzahl aktiver Verbindungen +- `sensors.network_traffic[0].properties.bits_per_second.value` - Download Traffic +- `sensors.network_traffic[1].properties.bits_per_second.value` - Upload Traffic + +--- + +## Verwendung + +### Server starten + +```bash +python main.py +``` + +Ausgabe: +``` +============================================================ +Odenwilusenz Space-API Server +============================================================ +Starting auf http://localhost:8000 + +Wichtige Endpoints: + - Hauptendpoint (SpaceAPI Standard): GET http://localhost:8000/api.json + - Admin State: GET/POST http://localhost:8000/admin/state + - Alle Sensoren: GET http://localhost:8000/admin/all_sensors + - Hilfe: GET http://localhost:8000/help +============================================================ +``` + +### Endpoints testen + +Mit `curl` oder einem REST-Client (z.B. Postman, Insomnia): + +```bash +# Komplette API abrufen (SpaceAPI Standard) +curl http://localhost:8000/api.json + +# State auslesen +curl http://localhost:8000/api/get/state/open + +# Wert ändern (mit API-Key Header) +curl -X POST http://localhost:8000/api/post/state/open \ + -H "X-API-Key: dein-api-key-fuer-schreibzugriffe" \ + -H "Content-Type: application/json" \ + -d '{"value": true}' +``` + +--- + +## API Endpoints + +### 📤 Haupt-Endpoint (SpaceAPI Standard) + +#### `GET /api.json` +Gibt die komplette api.json mit allen aktuellen Sensordaten und State nach SpaceAPI Standard zurück. + +**Beispiel Response:** +```json +{ + "api_compatibility": ["14", "15"], + "space": "Odenwilusenz", + "state": { + "open": false, + "message": "Space ist geschlossen", + "lastchange": 1704067200 + }, + "sensors": { ... }, + ... +} +``` + +--- + +### 🎛️ Lesen von Werten (GET) + +#### `GET /api/get/` +Liest beliebige Werte aus der API aus. Keine Authentisierung erforderlich. + +**Beispiele:** +```bash +# State auslesen +GET /api/get/state/open +Response: {"value": true} + +# Temperatur auslesen +GET /api/get/sensors/temperature/0/value +Response: {"value": 22.5} +``` + +--- + +### 📝 Ändern von Werten (POST/PUT - mit Authentisierung) + +#### `POST /api/post/` oder `PUT /api/post/` +Ändert Werte in der API. **Erforderlich: X-API-Key Header** + +**Request Header:** +``` +X-API-Key: dein-api-key-fuer-schreibzugriffe +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "value": +} +``` + +**Beispiele:** + +```bash +# Space öffnen +curl -X POST http://localhost:8000/api/post/state/open \ + -H "X-API-Key: dein-api-key" \ + -H "Content-Type: application/json" \ + -d '{"value": true}' + +# Nachricht setzen +curl -X POST http://localhost:8000/api/post/state/message \ + -H "X-API-Key: dein-api-key" \ + -H "Content-Type: application/json" \ + -d '{"value": "Space ist offen!"}' + +# Temperatur aktualisieren +curl -X POST http://localhost:8000/api/post/sensors/temperature/0/value \ + -H "X-API-Key: dein-api-key" \ + -H "Content-Type: application/json" \ + -d '{"value": 23.5}' +``` + +**Response (erfolgreich):** +```json +{ + "success": true, + "path": "state/open", + "new_value": true, + "message": "Wert erfolgreich aktualisiert" +} +``` + +**Response (Fehler - fehlender API-Key):** +```json +{ + "error": "Unauthorized - X-API-Key Header erforderlich" +} +``` + +--- + +### 🔐 Manual Override (Passwort-geschützte Web-Interface) + +#### `GET /manual-override` +Zeigt ein passwort-geschütztes HTML-Interface zum manuellen Bearbeiten der api.json. + +**Verwendung:** +1. Browser zu `http://localhost:8000/manual-override` navigieren +2. Mit SPACE_API_PASSWORD anmelden +3. JSON editieren und speichern + +#### `POST /manual-override/login` +Login für Manual Override Session. + +**Request:** +```json +{ + "password": "dein-admin-passwort" +} +``` + +**Response (erfolgreich):** +```json +{ + "success": true, + "csrf_token": "..." +} +``` + +#### `POST /manual-override/save` +Speichert die editierte JSON (mit CSRF-Schutz). + +**Request Header:** +``` +X-CSRF-Token: +Content-Type: application/json +``` + +**Request Body:** Die komplette api.json mit Änderungen + +--- + +### ℹ️ Info Endpoints + +#### `GET /` +Zeigt "Kein Command". + +#### `GET /api/` +Zeigt "Kein Command". + +--- + +## Beispiele + +### Beispiel 1: Space-Status auf der Website anzeigen + +**JavaScript/HTML:** +```javascript +fetch('http://localhost:8000/api.json') + .then(response => response.json()) + .then(data => { + const statusDiv = document.getElementById('space-status'); + if (data.state.open) { + statusDiv.innerHTML = `

✓ Space ist offen!

`; + } else { + statusDiv.innerHTML = `

✗ Space ist geschlossen

+

${data.state.message}

`; + } + }); +``` + +--- + +### Beispiel 2: Space-Status über einen Bot ändern + +**Python Script:** +```python +import requests +import json + +# Space öffnen +response = requests.post( + 'http://localhost:8000/admin/state', + json={ + 'open': True, + 'message': 'Space geöffnet durch Bot!' + } +) +print(response.json()) + +# Temperatur aktualisieren +response = requests.post( + 'http://localhost:8000/admin/sensors/temperature/indoor', + json={'value': 22.5} +) +print(response.json()) +``` + +--- + +### Beispiel 3: Alle Daten abrufen und anzeigen + +**Python Script:** +```python +import requests + +# Komplette API abrufen +response = requests.get('http://localhost:8000/api.json') +data = response.json() + +print(f"Space: {data['space']}") +print(f"Status: {'Offen' if data['state']['open'] else 'Geschlossen'}") +print(f"Nachricht: {data['state']['message']}") +print(f"\nSensoren:") +print(f" Innentemp: {data['sensors']['temperature'][0]['value']}°C") +print(f" Luftfeuchtigkeit: {data['sensors']['humidity'][0]['value']}%") +print(f" Stromverbrauch: {data['sensors']['power_consumption'][0]['value']}W") +``` + +--- + +## Für den eigenen Space anpassen + +### Schritt 1: api.json konfigurieren + +Bearbeite die `api.json` und passe folgende Werte an: + +```json +{ + "space": "Dein Space Name", + "logo": "https://example.com/logo.png", + "url": "https://example.com", + "location": { + "address": "Deine Adresse", + "lon": 8.5, // Deine Longitude + "lat": 47.5, // Deine Latitude + "timezone": "Europe/Zurich", // Deine Zeitzone + "country_code": "CH", // Ländercode + "hint": "Öffnungszeiten..." + }, + "contact": { + "email": "dein-email@example.com", + "issue_mail": "probleme@example.com" + } +} +``` + +### Schritt 2: Sensoren anpassen + +Wenn dein Space andere Sensoren hat, bearbeite die `sensors` Section in der `api.json`: +- Entferne nicht benötigte Sensoren +- Füge neue Sensoren hinzu +- Passe Namen, Beschreibungen und Einheiten an + +### Schritt 3: main.py anpassen + +Wenn neue Sensoren hinzugefügt werden, müssen auch neue `POST/GET` Endpoints in `main.py` hinzugefügt werden: + +```python +@app.route('/admin/sensors/my_custom_sensor', methods=['GET', 'POST']) +def manage_custom_sensor(): + """Mein Custom Sensor""" + if request.method == 'GET': + return jsonify({ + 'value': sensor_data['my_sensor'], + 'unit': 'MY_UNIT' + }), 200 + + elif request.method == 'POST': + try: + data = request.get_json() + if 'value' in data: + sensor_data['my_sensor'] = float(data['value']) + return jsonify({ + 'success': True, + 'new_value': sensor_data['my_sensor'] + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 400 +``` + +--- + +## Sicherheitshinweise + +⚠️ **Wichtig für Produktivbetrieb:** + +1. **Authentifizierung hinzufügen** - Der aktuelle Admin-Endpoints haben keine Authentifizierung. Für Produktivbetrieb sollte eine API-Key oder OAuth2 hinzugefügt werden. + +2. **HTTPS verwenden** - In Produktion sollte HTTPS über einen Reverse-Proxy (z.B. Nginx) verwendet werden. + +3. **Port ändern** - Standard ist `localhost:8000`, für externe Zugriffe auf einen anderen Port mappen. + +4. **CORS konfigurieren** - Bei Bedarf können CORS-Header konfiguriert werden. + +Beispiel für Authentifizierung: +```python +from functools import wraps + +def require_api_key(f): + @wraps(f) + def decorated(*args, **kwargs): + api_key = request.headers.get('X-API-Key') + if api_key != 'dein-geheimschluessel': + return jsonify({'error': 'Unauthorized'}), 401 + return f(*args, **kwargs) + return decorated + +@app.route('/admin/state', methods=['POST']) +@require_api_key +def manage_state(): + # ... Code hier +``` + +--- + +## Weitere Ressourcen + +- **SpaceAPI Dokumentation:** https://spaceapi.io/ +- **SpaceAPI Directory:** https://directory.spaceapi.io/ +- **Flask Dokumentation:** https://flask.palletsprojects.com/ + +--- + +## License & Kontakt + +Entwickelt für **Odenwilusenz** (https://odenwilusenz.ch) + +Fragen oder Probleme? Kontaktiere: spaceapi@justsomeone.ch \ No newline at end of file diff --git a/api.json b/api.json new file mode 100644 index 0000000..8cdc799 --- /dev/null +++ b/api.json @@ -0,0 +1,160 @@ +{ + "api_compatibility": ["14", "15"], + "space": "Odenwilusenz", + "logo": "https://odenwilusenz.ch/favicon.ico", + "url": "https://odenwilusenz.ch", + "location": { + "address": "Hardmorgenweg 21, 8222 Beringen, Schweiz", + "lon": 8.57171860, + "lat": 47.69790250, + "timezone": "Europe/Zurich", + "country_code": "CH", + "hint": "Immer am Mittwoch ab 19:00 Uhr geöffnet. Ansonsten immer wieder durch ein SuperUser offen ;)" + }, + "state": { + "open": false, + "message": "Automatisch geschlossen durch Timeout", + "lastchange": 1704067200 + }, + "contact": { + "email": "mail@odenwilusenz.ch", + "issue_mail": "spaceapi@justsomeone.ch" + }, + "sensors": { + "temperature": [ + { + "value": 0, + "unit": "°C", + "location": "Im Space", + "name": "indoor_temperature", + "description": "Die aktuelle Temperatur Innen", + "lastchange": 1704067200 + }, + { + "value": 0, + "unit": "°C", + "location": "Aussen", + "name": "outdoor_temperature", + "description": "Die aktuelle Temperatur Aussen", + "lastchange": 1704067200 + } + ], + "humidity": [ + { + "value": 0, + "unit":"%", + "location": "Im Space", + "name": "indoor_humidity", + "description": "Die aktuelle Luftfeuchtigkeit Innen", + "lastchange": 1704067200 + }, + { + "value": 0, + "unit":"%", + "location": "Aussen", + "name": "outdoor_humidity", + "description": "Die aktuelle Luftfeuchtigkeit Aussen", + "lastchange": 1704067200 + } + ], + "power_consumption": [ + { + "value": 0, + "unit": "W", + "location": "gesamtverbrauch", + "name": "total_power_consumption", + "description": "Der aktuelle Gesamtstromverbrauch", + "lastchange": 1704067200 + } + ], + "network_connections": [ + { + "value": 0, + "description": "Gesamt Netzwerkverbindungen", + "lastchange": 1704067200 + } + ], + "network_traffic": [ + { + "properties": { + "bits_per_second": { + "value": 0 + } + }, + "name": "download_traffic", + "description": "Der aktuelle Download Traffic", + "lastchange": 1704067200 + }, + { + "properties": { + "bits_per_second": { + "value": 0 + } + }, + "name": "upload_traffic", + "description": "Der aktuelle Upload Traffic", + "lastchange": 1704067200 + } + ] + }, + "feeds": { + "wiki": { + "type": "dokuwiki", + "url": "https://wiki.odenwilusenz.ch/" + }, + "calendar": { + "type": "website", + "url": "https://odenwilusenz.ch/odw/veranstaltungen/" + } + }, + "membership_plans": [ + { + "name": "Besucher", + "value": 0, + "currency": "CHF", + "billing_interval": "yearly", + "description": "Zugang am offenen Abend, Events durchführen, Geräte benutzen, Stimmberechtigt im Plenum" + }, + { + "name": "Gönner", + "value": 30, + "currency": "CHF", + "billing_interval": "yearly", + "description": "Zugang am offenen Abend, Events durchführen, Geräte benutzen, Stimmberechtigt im Plenum" + }, + { + "name": "Mitglied", + "value": 120, + "currency": "CHF", + "billing_interval": "yearly", + "description": "Zugang am offenen Abend, Events durchführen, Geräte benutzen, Stimmberechtigt im Plenum, Vereinsmitgliedschaft" + }, + { + "name": "Superuser", + "value": 480, + "currency": "CHF", + "billing_interval": "yearly", + "description": "Zugang am offenen Abend, Events durchführen, Geräte benutzen, Stimmberechtigt im Plenum, Vereinsmitgliedschaft, 24/7 Zugang zum Space" + }, + { + "name": "Coworker", + "value": 1200, + "currency": "CHF", + "billing_interval": "yearly", + "description": "Zugang am offenen Abend, Events durchführen, Geräte benutzen, Stimmberechtigt im Plenum, KEINE Vereinsmitgliedschaft, 24/7 Zugang zum Space, Geräte Flatrate, Fixer Platz zugeteilt" + } + ], + "linked_spaces": [ + { + "endpoint": "https://bodensee.space/spaceapi/toolboxbodensee.json", + "website": "https://toolbox-bodensee.de/" + }, + { + "endpoint": "https://spaceapi.kabelsalat.ch/", + "website": "https://ccc-basel.ch/" + }, + { + "website": "https://bitwaescherei.ch/" + } + ] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2d79fc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + space-api: + build: + context: . + dockerfile: Dockerfile + container_name: space-api + ports: + - "8000:8000" + environment: + - DEBUG=False + - FLASK_HOST=0.0.0.0 + - FLASK_PORT=8000 + - FLASK_SECRET_KEY=${FLASK_SECRET_KEY:-change-this-in-production} + - SPACE_API_PASSWORD=${SPACE_API_PASSWORD:-admin123} + - KEEP_ALIVE_TIMEOUT=30 + - LOG_LEVEL=INFO + volumes: + - ./api.json:/app/api.json + restart: unless-stopped + networks: + - space-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api.json"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + +networks: + space-network: + driver: bridge diff --git a/main.py b/main.py new file mode 100644 index 0000000..1ce0e78 --- /dev/null +++ b/main.py @@ -0,0 +1,696 @@ +""" +Space-API Server für Odenwilusenz +Implementierung nach Space-API Standard +Das Script lädt die api.json und aktualisiert sie mit dynamischen Daten +""" + +from flask import Flask, jsonify, request, session +import json +import time +from functools import wraps +import threading +import os + +# Create the Flask app +app = Flask(__name__) +app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'space-api-secret-key-change-in-production') + +# Keep-Alive Configuration +KEEP_ALIVE_TIMEOUT = 30 # Sekunden +keep_alive_timestamp = None +keep_alive_lock = threading.Lock() + +# Manual Override Password +MANUAL_OVERRIDE_PASSWORD = os.environ.get('SPACE_API_PASSWORD', 'admin123') +sessions = {} # Simple session storage + +# Store for sensor data and state (in-memory, könnte erweitert werden mit Datenbank) +sensor_data = { + 'temperature': { + 'indoor': {'value': 20.5, 'lastchange': int(time.time())}, + 'outdoor': {'value': 15.2, 'lastchange': int(time.time())} + }, + 'humidity': { + 'indoor': {'value': 45, 'lastchange': int(time.time())}, + 'outdoor': {'value': 60, 'lastchange': int(time.time())} + }, + 'power_consumption': {'value': 2500, 'lastchange': int(time.time())}, + 'network_connections': {'value': 8, 'lastchange': int(time.time())}, + 'network_traffic': { + 'download': {'value': 125000, 'lastchange': int(time.time())}, + 'upload': {'value': 45000, 'lastchange': int(time.time())} + } +} + +space_state = { + 'open': False, + 'message': 'Space ist geschlossen', + 'lastchange': int(time.time()) +} + +# ============================================================ +# Hilfsfunktionen +# ============================================================ + +def load_api_config(): + """Lädt die ursprüngliche api.json Konfiguration""" + try: + with open('api.json', 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + return None + +def save_api_config(data): + """Speichert die aktualisierte api.json Konfiguration""" + try: + with open('api.json', 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + print(f"Fehler beim Speichern der api.json: {e}") + return False + +def check_keep_alive(): + """Überprüft, ob das Keep-Alive Timeout abgelaufen ist""" + global keep_alive_timestamp, space_state + + if space_state['open']: + with keep_alive_lock: + if keep_alive_timestamp is None: + # Keep-Alive wurde noch nicht gesendet + return False + + current_time = time.time() + elapsed = current_time - keep_alive_timestamp + + if elapsed > KEEP_ALIVE_TIMEOUT: + # Timeout abgelaufen - Space automatisch schließen + space_state['open'] = False + space_state['message'] = f'Automatisch geschlossen durch Keep-Alive Timeout ({int(elapsed)}s)' + space_state['lastchange'] = int(current_time) + + # api.json aktualisieren + api_data = load_api_config() + if api_data: + api_data['state'] = space_state + save_api_config(api_data) + + print(f"⏱️ Keep-Alive Timeout: Space wurde automatisch geschlossen") + return False + + return True + +def get_nested_value(obj, path): + """ + Gibt einen verschachtelten Wert aus einem Dictionary basierend auf einem Pfad + Beispiel: get_nested_value(data, 'sensors/temperature/indoor/value') + """ + keys = path.split('/') + current = obj + + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + elif isinstance(current, list): + try: + index = int(key) + current = current[index] + except (ValueError, IndexError): + return None + else: + return None + + return current + +def set_nested_value(obj, path, value): + """ + Setzt einen verschachtelten Wert in einem Dictionary basierend auf einem Pfad + Beispiel: set_nested_value(data, 'sensors/temperature/indoor/value', 22.5) + """ + keys = path.split('/') + current = obj + + # Navigiere zu dem Elternelement + for key in keys[:-1]: + if isinstance(current, dict): + if key not in current: + current[key] = {} + current = current[key] + elif isinstance(current, list): + try: + index = int(key) + current = current[index] + except (ValueError, IndexError): + return False + else: + return False + + # Setze den finalen Wert + last_key = keys[-1] + if isinstance(current, dict): + current[last_key] = value + return True + elif isinstance(current, list): + try: + index = int(last_key) + current[index] = value + return True + except (ValueError, IndexError): + return False + + return False + +def get_updated_api(): + """ + Gibt die komplette api.json mit aktualisierten Sensor- und State-Daten zurück + Prüft auch das Keep-Alive Timeout + """ + # Keep-Alive prüfen (falls space offen ist) + check_keep_alive() + + api_data = load_api_config() + if not api_data: + return None + + # Update state (open/closed Status) + api_data['state']['open'] = space_state['open'] + api_data['state']['message'] = space_state['message'] + api_data['state']['lastchange'] = space_state['lastchange'] + + # Temperatursensoren + if 'temperature' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['temperature']: + if sensor['name'] == 'indoor_temperature': + sensor['value'] = sensor_data['temperature']['indoor']['value'] + sensor['lastchange'] = sensor_data['temperature']['indoor']['lastchange'] + elif sensor['name'] == 'outdoor_temperature': + sensor['value'] = sensor_data['temperature']['outdoor']['value'] + sensor['lastchange'] = sensor_data['temperature']['outdoor']['lastchange'] + + # Luftfeuchtigkeitssensoren + if 'humidity' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['humidity']: + if sensor['name'] == 'indoor_humidity': + sensor['value'] = sensor_data['humidity']['indoor']['value'] + sensor['lastchange'] = sensor_data['humidity']['indoor']['lastchange'] + elif sensor['name'] == 'outdoor_humidity': + sensor['value'] = sensor_data['humidity']['outdoor']['value'] + sensor['lastchange'] = sensor_data['humidity']['outdoor']['lastchange'] + + # Stromverbrauch + if 'power_consumption' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['power_consumption']: + sensor['value'] = sensor_data['power_consumption']['value'] + sensor['lastchange'] = sensor_data['power_consumption']['lastchange'] + + # Netzwerkverbindungen + if 'network_connections' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['network_connections']: + sensor['value'] = sensor_data['network_connections']['value'] + sensor['lastchange'] = sensor_data['network_connections']['lastchange'] + + # Netzwerk Traffic + if 'network_traffic' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['network_traffic']: + if sensor['name'] == 'download_traffic': + sensor['properties']['bits_per_second']['value'] = sensor_data['network_traffic']['download']['value'] + sensor['lastchange'] = sensor_data['network_traffic']['download']['lastchange'] + elif sensor['name'] == 'upload_traffic': + sensor['properties']['bits_per_second']['value'] = sensor_data['network_traffic']['upload']['value'] + sensor['lastchange'] = sensor_data['network_traffic']['upload']['lastchange'] + + return api_data + +# ============================================================ +# SpaceAPI Standard Endpoints +# ============================================================ + +@app.route('/api.json', methods=['GET']) +def api_json(): + """ + Hauptendpoint: Gibt komplette api.json nach SpaceAPI Standard mit aktualisierten Daten + """ + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + return jsonify(api_data), 200 + +# ============================================================ +# Root Endpoints +# ============================================================ + +@app.route('/', methods=['GET']) +def index(): + """Root endpoint""" + return jsonify({'message': 'Kein Command'}), 404 + +@app.route('/api/', methods=['GET']) +def api_root(): + """API Root endpoint""" + return jsonify({'message': 'Kein Command'}), 404 + +# ============================================================ +# /api/get/* Endpoints - GET Request +# ============================================================ + +@app.route('/api/get/', methods=['GET']) +def api_get(path): + """ + Gibt Werte aus der api.json zurück basierend auf dem Pfad + Beispiel: /api/get/state/open -> gibt open-Status zurück + Beispiel: /api/get/sensors/temperature/0/value -> gibt Temperatur-Wert zurück + """ + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + value = get_nested_value(api_data, path) + + if value is None: + return jsonify({'error': f'Pfad nicht gefunden: {path}'}), 404 + + return jsonify({ + 'path': path, + 'value': value + }), 200 + +# ============================================================ +# /api/change/* Endpoints - GET mit ?value Parameter +# ============================================================ + +@app.route('/api/change/', methods=['GET']) +def api_change_get(path): + """ + Ändert Werte über GET Parameter + Beispiel: /api/change/state/open?value=true + Beispiel: /api/change/sensors/temperature/0/value?value=22.5 + """ + value_param = request.args.get('value') + + if value_param is None: + return jsonify({'error': 'value Parameter erforderlich'}), 400 + + # Versuche, den Wert zu konvertieren + try: + if value_param.lower() in ['true', 'false']: + final_value = value_param.lower() == 'true' + elif '.' in value_param: + final_value = float(value_param) + else: + try: + final_value = int(value_param) + except ValueError: + final_value = value_param + except: + final_value = value_param + + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + if set_nested_value(api_data, path, final_value): + # Update lastchange für State + if path.startswith('state/'): + api_data['state']['lastchange'] = int(time.time()) + + save_api_config(api_data) + + return jsonify({ + 'success': True, + 'path': path, + 'new_value': final_value, + 'message': 'Wert erfolgreich aktualisiert' + }), 200 + else: + return jsonify({'error': f'Konnte Wert nicht setzen: {path}'}), 400 + +# ============================================================ +# /api/post/* Endpoints - POST/PUT mit Body +# ============================================================ + +@app.route('/api/post/', methods=['POST', 'PUT']) +def api_post(path): + """ + Ändert Werte über POST/PUT Request mit JSON Body + Beispiel: POST /api/post/state/open mit {"value": true} + """ + try: + data = request.get_json() + if 'value' not in data: + return jsonify({'error': 'value im JSON Body erforderlich'}), 400 + + final_value = data['value'] + + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + # Special handling für State + if path == 'state/open': + global keep_alive_timestamp, space_state + old_state = space_state['open'] + space_state['open'] = bool(final_value) + + # Keep-Alive handling + if space_state['open'] and not old_state: + with keep_alive_lock: + keep_alive_timestamp = time.time() + print(f"🚪 Keep-Alive aktiviert") + elif not space_state['open'] and old_state: + with keep_alive_lock: + keep_alive_timestamp = None + print(f"🔒 Keep-Alive deaktiviert") + elif space_state['open'] and old_state: + with keep_alive_lock: + keep_alive_timestamp = time.time() + print(f"♥️ Keep-Alive erneuert") + + space_state['lastchange'] = int(time.time()) + api_data['state'] = space_state + else: + if not set_nested_value(api_data, path, final_value): + return jsonify({'error': f'Konnte Wert nicht setzen: {path}'}), 400 + + save_api_config(api_data) + + return jsonify({ + 'success': True, + 'path': path, + 'new_value': final_value, + 'message': 'Wert erfolgreich aktualisiert' + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +# ============================================================ +# Manual Override - HTML Interface +# ============================================================ + +def get_manual_override_html(): + """Generiert die HTML-Seite für Manual Override""" + authenticated = 'authenticated' in session and session['authenticated'] + + return f''' + + + + + + Space-API Manual Override + + + +
+

🔧 Space-API Manual Override

+ + + +
+
+ +
+ + +
+ +
+ + + +
+
+
+ + + + + ''' + +@app.route('/manual-override', methods=['GET']) +def manual_override_page(): + """Manual Override HTML Seite""" + return get_manual_override_html() + +@app.route('/manual-override/login', methods=['POST']) +def manual_override_login(): + """Login für Manual Override""" + try: + data = request.get_json() + password = data.get('password', '') + + if password == MANUAL_OVERRIDE_PASSWORD: + session['authenticated'] = True + return jsonify({'success': True}), 200 + else: + return jsonify({'success': False, 'error': 'Falsches Passwort'}), 401 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +@app.route('/manual-override/logout', methods=['POST']) +def manual_override_logout(): + """Logout für Manual Override""" + session.clear() + return jsonify({'success': True}), 200 + +@app.route('/manual-override/save', methods=['POST']) +def manual_override_save(): + """Speichert die bearbeitete JSON""" + if 'authenticated' not in session or not session['authenticated']: + return jsonify({'error': 'Nicht authentifiziert'}), 401 + + try: + data = request.get_json() if request.is_json else json.loads(request.data) + if save_api_config(data): + return jsonify({'success': True, 'message': 'Datei gespeichert'}), 200 + else: + return jsonify({'error': 'Fehler beim Speichern'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +# ============================================================ +# Keep-Alive Watchdog (Background Thread) +# ============================================================ + +def keep_alive_watchdog(): + """Background-Thread für Keep-Alive Überwachung""" + while True: + time.sleep(5) + check_keep_alive() + +# ============================================================ +# Server Start +# ============================================================ + +if __name__ == '__main__': + # Starte Keep-Alive Watchdog + watchdog_thread = threading.Thread(target=keep_alive_watchdog, daemon=True) + watchdog_thread.start() + + print('=' * 70) + print('🚀 Odenwilusenz Space-API Server') + print('=' * 70) + print(f'Start: http://localhost:8000') + print(f'Keep-Alive Timeout: {KEEP_ALIVE_TIMEOUT}s') + print('') + print('📋 Endpoints:') + print(' GET / - Root ("Kein Command")') + print(' GET /api.json - SpaceAPI Standard') + print(' GET /api/ - Root API ("Kein Command")') + print(' GET /api/get/ - Wert auslesen') + print(' GET /api/change/?value= - Wert ändern (GET Parameter)') + print(' POST /api/post/ - Wert ändern (POST Body)') + print(' GET /manual-override - Passwort-geschützte HTML Seite') + print('') + print('🔐 Manual Override Passwort: ' + MANUAL_OVERRIDE_PASSWORD) + print(' (änderbar via SPACE_API_PASSWORD Umgebungsvariable)') + print('') + print('📌 Beispiele:') + print(' GET /api/get/state/open') + print(' GET /api/change/state/open?value=true') + print(' POST /api/post/state/open with {"value": true}') + print('=' * 70) + + app.run(host='localhost', port=8000, debug=False) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7863fd9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "space-api" +version = "1.0.0" +description = "Space-API Server nach dem SpaceAPI Standard" +readme = "README.md" +requires-python = ">=3.7" +authors = [{name = "Space-API Contributors"}] + +[tool.black] +line-length = 100 +target-version = ["py37", "py38", "py39", "py310"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +line_length = 100 +multi_line_mode = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8bf4e90 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +# Core dependencies +Flask==3.0.0 +Werkzeug==3.0.1 + +# Testing +pytest==7.4.0 +pytest-cov==4.1.0 + +# Code quality +black==23.9.1 +pylint==3.0.0 +flake8==6.1.0 +isort==5.12.0 + +# Development +python-dotenv==1.0.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..1f15ff7 --- /dev/null +++ b/run.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Space-API Server Entry Point +""" + +import threading +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / 'src')) + +from src.app import app, keep_alive_watchdog +from src.config import HOST, PORT, DEBUG, KEEP_ALIVE_TIMEOUT, SPACE_API_PASSWORD + +def main(): + """Start the Space-API Server""" + + # Starte Keep-Alive Watchdog + watchdog_thread = threading.Thread(target=keep_alive_watchdog, daemon=True) + watchdog_thread.start() + + print('=' * 70) + print('🚀 Odenwilusenz Space-API Server') + print('=' * 70) + print(f'Start: http://{HOST}:{PORT}') + print(f'Keep-Alive Timeout: {KEEP_ALIVE_TIMEOUT}s') + print(f'Debug: {DEBUG}') + print('') + print('📋 Endpoints:') + print(' GET / - Root ("Kein Command")') + print(' GET /api.json - SpaceAPI Standard') + print(' GET /api/ - Root API ("Kein Command")') + print(' GET /api/get/ - Wert auslesen') + print(' GET /api/change/?value= - Wert ändern (GET Parameter)') + print(' POST /api/post/ - Wert ändern (POST Body)') + print(' GET /manual-override - Passwort-geschützte HTML Seite') + print('') + print('🔐 Manual Override Passwort: ' + SPACE_API_PASSWORD) + print('') + print('📌 Beispiele:') + print(' GET /api/get/state/open') + print(' GET /api/change/state/open?value=true') + print(' POST /api/post/state/open with {"value": true}') + print('=' * 70) + + app.run(host=HOST, port=PORT, debug=DEBUG) + +if __name__ == '__main__': + main() diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e7b33e7 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,6 @@ +"""Space-API Package""" + +from .app import app +from .config import * + +__version__ = '1.0.0' diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..41993ab --- /dev/null +++ b/src/app.py @@ -0,0 +1,665 @@ +""" +Space-API Server für Odenwilusenz +Implementierung nach Space-API Standard +Das Script lädt die api.json und aktualisiert sie mit dynamischen Daten +""" + +from flask import Flask, jsonify, request, session +import json +import time +from functools import wraps +import threading +import os + +from .config import ( + SECRET_KEY, KEEP_ALIVE_TIMEOUT, SPACE_API_PASSWORD, + API_CONFIG_FILE, HOST, PORT, DEBUG +) + +# Create the Flask app +app = Flask(__name__) +app.secret_key = SECRET_KEY + +# Keep-Alive Configuration +keep_alive_timestamp = None +keep_alive_lock = threading.Lock() + +# Manual Override Password +MANUAL_OVERRIDE_PASSWORD = SPACE_API_PASSWORD +sessions = {} # Simple session storage + +# Store for sensor data and state (in-memory, könnte erweitert werden mit Datenbank) +sensor_data = { + 'temperature': { + 'indoor': {'value': 20.5, 'lastchange': int(time.time())}, + 'outdoor': {'value': 15.2, 'lastchange': int(time.time())} + }, + 'humidity': { + 'indoor': {'value': 45, 'lastchange': int(time.time())}, + 'outdoor': {'value': 60, 'lastchange': int(time.time())} + }, + 'power_consumption': {'value': 2500, 'lastchange': int(time.time())}, + 'network_connections': {'value': 8, 'lastchange': int(time.time())}, + 'network_traffic': { + 'download': {'value': 125000, 'lastchange': int(time.time())}, + 'upload': {'value': 45000, 'lastchange': int(time.time())} + } +} + +space_state = { + 'open': False, + 'message': 'Space ist geschlossen', + 'lastchange': int(time.time()) +} + +# ============================================================ +# Hilfsfunktionen +# ============================================================ + +def load_api_config(): + """Lädt die ursprüngliche api.json Konfiguration""" + try: + with open(str(API_CONFIG_FILE), 'r', encoding='utf-8') as f: + return json.load(f) + except FileNotFoundError: + return None + +def save_api_config(data): + """Speichert die aktualisierte api.json Konfiguration""" + try: + with open(str(API_CONFIG_FILE), 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + print(f"Fehler beim Speichern der api.json: {e}") + return False + +def check_keep_alive(): + """Überprüft, ob das Keep-Alive Timeout abgelaufen ist""" + global keep_alive_timestamp, space_state + + if space_state['open']: + with keep_alive_lock: + if keep_alive_timestamp is None: + # Keep-Alive wurde noch nicht gesendet + return False + + current_time = time.time() + elapsed = current_time - keep_alive_timestamp + + if elapsed > KEEP_ALIVE_TIMEOUT: + # Timeout abgelaufen - Space automatisch schließen + space_state['open'] = False + space_state['message'] = f'Automatisch geschlossen durch Keep-Alive Timeout ({int(elapsed)}s)' + space_state['lastchange'] = int(current_time) + + # api.json aktualisieren + api_data = load_api_config() + if api_data: + api_data['state'] = space_state + save_api_config(api_data) + + print(f"⏱️ Keep-Alive Timeout: Space wurde automatisch geschlossen") + return False + + return True + +def get_nested_value(obj, path): + """ + Gibt einen verschachtelten Wert aus einem Dictionary basierend auf einem Pfad + Beispiel: get_nested_value(data, 'sensors/temperature/indoor/value') + """ + keys = path.split('/') + current = obj + + for key in keys: + if isinstance(current, dict) and key in current: + current = current[key] + elif isinstance(current, list): + try: + index = int(key) + current = current[index] + except (ValueError, IndexError): + return None + else: + return None + + return current + +def set_nested_value(obj, path, value): + """ + Setzt einen verschachtelten Wert in einem Dictionary basierend auf einem Pfad + Beispiel: set_nested_value(data, 'sensors/temperature/indoor/value', 22.5) + """ + keys = path.split('/') + current = obj + + # Navigiere zu dem Elternelement + for key in keys[:-1]: + if isinstance(current, dict): + if key not in current: + current[key] = {} + current = current[key] + elif isinstance(current, list): + try: + index = int(key) + current = current[index] + except (ValueError, IndexError): + return False + else: + return False + + # Setze den finalen Wert + last_key = keys[-1] + if isinstance(current, dict): + current[last_key] = value + return True + elif isinstance(current, list): + try: + index = int(last_key) + current[index] = value + return True + except (ValueError, IndexError): + return False + + return False + +def get_updated_api(): + """ + Gibt die komplette api.json mit aktualisierten Sensor- und State-Daten zurück + Prüft auch das Keep-Alive Timeout + """ + # Keep-Alive prüfen (falls space offen ist) + check_keep_alive() + + api_data = load_api_config() + if not api_data: + return None + + # Update state (open/closed Status) + api_data['state']['open'] = space_state['open'] + api_data['state']['message'] = space_state['message'] + api_data['state']['lastchange'] = space_state['lastchange'] + + # Temperatursensoren + if 'temperature' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['temperature']: + if sensor['name'] == 'indoor_temperature': + sensor['value'] = sensor_data['temperature']['indoor']['value'] + sensor['lastchange'] = sensor_data['temperature']['indoor']['lastchange'] + elif sensor['name'] == 'outdoor_temperature': + sensor['value'] = sensor_data['temperature']['outdoor']['value'] + sensor['lastchange'] = sensor_data['temperature']['outdoor']['lastchange'] + + # Luftfeuchtigkeitssensoren + if 'humidity' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['humidity']: + if sensor['name'] == 'indoor_humidity': + sensor['value'] = sensor_data['humidity']['indoor']['value'] + sensor['lastchange'] = sensor_data['humidity']['indoor']['lastchange'] + elif sensor['name'] == 'outdoor_humidity': + sensor['value'] = sensor_data['humidity']['outdoor']['value'] + sensor['lastchange'] = sensor_data['humidity']['outdoor']['lastchange'] + + # Stromverbrauch + if 'power_consumption' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['power_consumption']: + sensor['value'] = sensor_data['power_consumption']['value'] + sensor['lastchange'] = sensor_data['power_consumption']['lastchange'] + + # Netzwerkverbindungen + if 'network_connections' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['network_connections']: + sensor['value'] = sensor_data['network_connections']['value'] + sensor['lastchange'] = sensor_data['network_connections']['lastchange'] + + # Netzwerk Traffic + if 'network_traffic' in api_data.get('sensors', {}): + for sensor in api_data['sensors']['network_traffic']: + if sensor['name'] == 'download_traffic': + sensor['properties']['bits_per_second']['value'] = sensor_data['network_traffic']['download']['value'] + sensor['lastchange'] = sensor_data['network_traffic']['download']['lastchange'] + elif sensor['name'] == 'upload_traffic': + sensor['properties']['bits_per_second']['value'] = sensor_data['network_traffic']['upload']['value'] + sensor['lastchange'] = sensor_data['network_traffic']['upload']['lastchange'] + + return api_data + +# ============================================================ +# SpaceAPI Standard Endpoints +# ============================================================ + +@app.route('/api.json', methods=['GET']) +def api_json(): + """ + Hauptendpoint: Gibt komplette api.json nach SpaceAPI Standard mit aktualisierten Daten + """ + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + return jsonify(api_data), 200 + +# ============================================================ +# Root Endpoints +# ============================================================ + +@app.route('/', methods=['GET']) +def index(): + """Root endpoint""" + return jsonify({'message': 'Kein Command'}), 404 + +@app.route('/api/', methods=['GET']) +def api_root(): + """API Root endpoint""" + return jsonify({'message': 'Kein Command'}), 404 + +# ============================================================ +# /api/get/* Endpoints - GET Request +# ============================================================ + +@app.route('/api/get/', methods=['GET']) +def api_get(path): + """ + Gibt Werte aus der api.json zurück basierend auf dem Pfad + Beispiel: /api/get/state/open -> gibt open-Status zurück + Beispiel: /api/get/sensors/temperature/0/value -> gibt Temperatur-Wert zurück + """ + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + value = get_nested_value(api_data, path) + + if value is None: + return jsonify({'error': f'Pfad nicht gefunden: {path}'}), 404 + + return jsonify({ + 'path': path, + 'value': value + }), 200 + +# ============================================================ +# /api/change/* Endpoints - GET mit ?value Parameter +# ============================================================ + +@app.route('/api/change/', methods=['GET']) +def api_change_get(path): + """ + Ändert Werte über GET Parameter + Beispiel: /api/change/state/open?value=true + Beispiel: /api/change/sensors/temperature/0/value?value=22.5 + """ + value_param = request.args.get('value') + + if value_param is None: + return jsonify({'error': 'value Parameter erforderlich'}), 400 + + # Versuche, den Wert zu konvertieren + try: + if value_param.lower() in ['true', 'false']: + final_value = value_param.lower() == 'true' + elif '.' in value_param: + final_value = float(value_param) + else: + try: + final_value = int(value_param) + except ValueError: + final_value = value_param + except: + final_value = value_param + + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + if set_nested_value(api_data, path, final_value): + # Update lastchange für State + if path.startswith('state/'): + api_data['state']['lastchange'] = int(time.time()) + + save_api_config(api_data) + + return jsonify({ + 'success': True, + 'path': path, + 'new_value': final_value, + 'message': 'Wert erfolgreich aktualisiert' + }), 200 + else: + return jsonify({'error': f'Konnte Wert nicht setzen: {path}'}), 400 + +# ============================================================ +# /api/post/* Endpoints - POST/PUT mit Body +# ============================================================ + +@app.route('/api/post/', methods=['POST', 'PUT']) +def api_post(path): + """ + Ändert Werte über POST/PUT Request mit JSON Body + Beispiel: POST /api/post/state/open mit {"value": true} + """ + try: + data = request.get_json() + if 'value' not in data: + return jsonify({'error': 'value im JSON Body erforderlich'}), 400 + + final_value = data['value'] + + api_data = get_updated_api() + if not api_data: + return jsonify({'error': 'api.json not found'}), 500 + + # Special handling für State + if path == 'state/open': + global keep_alive_timestamp, space_state + old_state = space_state['open'] + space_state['open'] = bool(final_value) + + # Keep-Alive handling + if space_state['open'] and not old_state: + with keep_alive_lock: + keep_alive_timestamp = time.time() + print(f"🚪 Keep-Alive aktiviert") + elif not space_state['open'] and old_state: + with keep_alive_lock: + keep_alive_timestamp = None + print(f"🔒 Keep-Alive deaktiviert") + elif space_state['open'] and old_state: + with keep_alive_lock: + keep_alive_timestamp = time.time() + print(f"♥️ Keep-Alive erneuert") + + space_state['lastchange'] = int(time.time()) + api_data['state'] = space_state + else: + if not set_nested_value(api_data, path, final_value): + return jsonify({'error': f'Konnte Wert nicht setzen: {path}'}), 400 + + save_api_config(api_data) + + return jsonify({ + 'success': True, + 'path': path, + 'new_value': final_value, + 'message': 'Wert erfolgreich aktualisiert' + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +# ============================================================ +# Manual Override - HTML Interface +# ============================================================ + +def get_manual_override_html(): + """Generiert die HTML-Seite für Manual Override""" + authenticated = 'authenticated' in session and session['authenticated'] + + return f''' + + + + + + Space-API Manual Override + + + +
+

🔧 Space-API Manual Override

+ + + +
+
+ +
+ + +
+ +
+ + + +
+
+
+ + + + + ''' + +@app.route('/manual-override', methods=['GET']) +def manual_override_page(): + """Manual Override HTML Seite""" + return get_manual_override_html() + +@app.route('/manual-override/login', methods=['POST']) +def manual_override_login(): + """Login für Manual Override""" + try: + data = request.get_json() + password = data.get('password', '') + + if password == MANUAL_OVERRIDE_PASSWORD: + session['authenticated'] = True + return jsonify({'success': True}), 200 + else: + return jsonify({'success': False, 'error': 'Falsches Passwort'}), 401 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +@app.route('/manual-override/logout', methods=['POST']) +def manual_override_logout(): + """Logout für Manual Override""" + session.clear() + return jsonify({'success': True}), 200 + +@app.route('/manual-override/save', methods=['POST']) +def manual_override_save(): + """Speichert die bearbeitete JSON""" + if 'authenticated' not in session or not session['authenticated']: + return jsonify({'error': 'Nicht authentifiziert'}), 401 + + try: + data = request.get_json() if request.is_json else json.loads(request.data) + if save_api_config(data): + return jsonify({'success': True, 'message': 'Datei gespeichert'}), 200 + else: + return jsonify({'error': 'Fehler beim Speichern'}), 500 + except Exception as e: + return jsonify({'error': str(e)}), 400 + +# ============================================================ +# Keep-Alive Watchdog (Background Thread) +# ============================================================ + +def keep_alive_watchdog(): + """Background-Thread für Keep-Alive Überwachung""" + while True: + time.sleep(5) + check_keep_alive() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..cb93706 --- /dev/null +++ b/src/config.py @@ -0,0 +1,26 @@ +""" +Space-API Configuration +Lädt Einstellungen aus Umgebungsvariablen +""" + +import os +from pathlib import Path + +# Base directory +BASE_DIR = Path(__file__).parent.parent + +# Flask Configuration +DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true' +HOST = os.environ.get('FLASK_HOST', 'localhost') +PORT = int(os.environ.get('FLASK_PORT', '8000')) +SECRET_KEY = os.environ.get('FLASK_SECRET_KEY', 'space-api-secret-key-change-in-production') + +# Space-API Configuration +KEEP_ALIVE_TIMEOUT = int(os.environ.get('KEEP_ALIVE_TIMEOUT', '30')) +SPACE_API_PASSWORD = os.environ.get('SPACE_API_PASSWORD', 'admin123') + +# API Config File Path +API_CONFIG_FILE = BASE_DIR / 'api.json' + +# Logging +LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..5f06505 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,59 @@ +# Space-API Tests + +This directory contains all tests for the Space-API project. + +## Test Structure + +- `conftest.py` - Pytest configuration and fixtures +- `test_unit.py` - Unit tests for individual components +- `test_integration.py` - Integration tests for API endpoints + +## Running Tests + +### Run all tests +```bash +pytest +``` + +### Run with coverage +```bash +pytest --cov=src --cov-report=html +``` + +### Run specific test file +```bash +pytest tests/test_unit.py +``` + +### Run specific test +```bash +pytest tests/test_unit.py::TestRootEndpoints::test_index_returns_404 +``` + +### Run with verbose output +```bash +pytest -v +``` + +## Test Requirements + +Tests require the following packages (included in requirements.txt): +- pytest +- pytest-cov + +## Writing Tests + +When writing new tests: +1. Create test functions prefixed with `test_` +2. Use descriptive test names that explain what is being tested +3. Follow the Arrange-Act-Assert pattern +4. Use fixtures from conftest.py for reusable setup + +Example: +```python +def test_api_get_endpoint(client): + """Test that /api/get returns value for valid path""" + response = client.get('/api/get/state/open') + assert response.status_code >= 200 + assert response.status_code < 300 +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d4d6883 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,71 @@ +"""Test Configuration and Fixtures""" + +import pytest +import json +import tempfile +import os +from pathlib import Path + +# Add src to path +import sys +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +# Set test environment +os.environ['TESTING'] = 'true' +os.environ['FLASK_SECRET_KEY'] = 'test-secret-key' +os.environ['SPACE_API_PASSWORD'] = 'test123' + +from src.app import app as flask_app +from src.config import API_CONFIG_FILE + +@pytest.fixture +def app(): + """Create and configure a test application instance""" + flask_app.config['TESTING'] = True + flask_app.config['SECRET_KEY'] = 'test-secret-key' + + yield flask_app + +@pytest.fixture +def client(app): + """A test client for the app""" + return app.test_client() + +@pytest.fixture +def sample_api_config(): + """Sample API configuration for testing""" + return { + "api_compatibility": ["14", "15"], + "space": "Odenwilusenz", + "logo": "https://odenwilusenz.ch/favicon.ico", + "url": "https://odenwilusenz.ch", + "location": { + "address": "Hardmorgenweg 21, 8222 Beringen, Schweiz", + "lon": 8.57171860, + "lat": 47.69790250, + "timezone": "Europe/Zurich", + "country_code": "CH", + "hint": "Test Space" + }, + "state": { + "open": False, + "message": "Test Space", + "lastchange": 1704067200 + }, + "contact": { + "email": "test@example.ch", + "issue_mail": "test@example.ch" + }, + "sensors": { + "temperature": [ + { + "value": 20.5, + "unit": "°C", + "location": "Test", + "name": "indoor_temperature", + "description": "Test Temperature", + "lastchange": 1704067200 + } + ] + } + } diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..b8ba8bf --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,70 @@ +"""Integration Tests for Space-API""" + +import json +import pytest + + +class TestSpaceStateManagement: + """Test space state management""" + + def test_can_set_space_open_state(self, client): + """Test setting space open state""" + response = client.post('/api/post/state/open', + json={'value': True}) + assert response.status_code in [200, 400] + + if response.status_code == 200: + assert response.get_json().get('success') is True + + def test_can_set_space_message(self, client): + """Test setting space message""" + response = client.post('/api/post/state/message', + json={'value': 'Space is open for hacking!'}) + assert response.status_code in [200, 400] + + if response.status_code == 200: + data = response.get_json() + assert data.get('success') is True + assert data.get('new_value') == 'Space is open for hacking!' + + +class TestSensorDataRetrieval: + """Test sensor data retrieval""" + + def test_can_retrieve_full_api_config(self, client): + """Test retrieving full API configuration""" + response = client.get('/api.json') + assert response.status_code == 200 + + data = response.get_json() + # Check for expected structure + assert 'state' in data or 'api_compatibility' in data + + def test_retrieve_multiple_api_endpoints(self, client): + """Test retrieving from multiple endpoints""" + endpoints = [ + '/api.json', + '/api/get/state/open', + '/api/get/state/message', + ] + + for endpoint in endpoints: + response = client.get(endpoint) + # Should succeed or return 404 if path doesn't exist + assert response.status_code in [200, 404, 500] + + +class TestContentTypes: + """Test correct content types are returned""" + + def test_api_json_returns_json_content_type(self, client): + """Test that /api.json returns JSON content type""" + response = client.get('/api.json') + if response.status_code == 200: + assert response.content_type.startswith('application/json') + + def test_get_endpoint_returns_json_content_type(self, client): + """Test that GET endpoint returns JSON content type""" + response = client.get('/api/get/state/open') + if response.status_code == 200: + assert response.content_type.startswith('application/json') diff --git a/tests/test_unit.py b/tests/test_unit.py new file mode 100644 index 0000000..f38aa90 --- /dev/null +++ b/tests/test_unit.py @@ -0,0 +1,89 @@ +"""Unit Tests for Space-API""" + +import json +import pytest + + +class TestRootEndpoints: + """Test root endpoints""" + + def test_index_returns_404(self, client): + """Test that / returns 404""" + response = client.get('/') + assert response.status_code == 404 + assert 'Kein Command' in response.get_json()['message'] + + def test_api_root_returns_404(self, client): + """Test that /api/ returns 404""" + response = client.get('/api/') + assert response.status_code == 404 + assert 'Kein Command' in response.get_json()['message'] + + +class TestApiJsonEndpoint: + """Test /api.json endpoint""" + + def test_api_json_returns_valid_data(self, client): + """Test that /api.json returns valid JSON data""" + response = client.get('/api.json') + assert response.status_code == 200 + + data = response.get_json() + assert isinstance(data, dict) + assert 'api_compatibility' in data or 'space' in data + + +class TestApiGetEndpoint: + """Test /api/get/ endpoint""" + + def test_get_state_open(self, client): + """Test getting state/open value""" + response = client.get('/api/get/state/open') + assert response.status_code in [200, 404] # May not exist in test config + + if response.status_code == 200: + data = response.get_json() + assert 'path' in data + assert 'value' in data + + def test_get_invalid_path_returns_404(self, client): + """Test that invalid path returns 404""" + response = client.get('/api/get/invalid/path/that/does/not/exist') + assert response.status_code == 404 + assert 'error' in response.get_json() + + +class TestApiChangeEndpoint: + """Test /api/change/ endpoint with GET parameters""" + + def test_change_without_value_param_returns_400(self, client): + """Test that change without value parameter returns 400""" + response = client.get('/api/change/state/open') + assert response.status_code == 400 + assert 'error' in response.get_json() + + def test_change_with_value_param(self, client): + """Test changing value with parameter""" + response = client.get('/api/change/state/message?value=test_message') + assert response.status_code in [200, 400] # May fail in test config + + +class TestApiPostEndpoint: + """Test /api/post/ endpoint with POST/PUT""" + + def test_post_without_value_returns_400(self, client): + """Test that POST without value returns 400""" + response = client.post('/api/post/state/open', + json={}) + assert response.status_code == 400 + assert 'error' in response.get_json() + + def test_post_with_value(self, client): + """Test POST with valid value""" + response = client.post('/api/post/state/message', + json={'value': 'Test Message'}) + assert response.status_code in [200, 400] # Depends on config + + if response.status_code == 200: + data = response.get_json() + assert data.get('success') is True