diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml new file mode 100644 index 0000000..35548ad --- /dev/null +++ b/.gitea/workflows/pipeline.yml @@ -0,0 +1,84 @@ +name: CI/CD + +on: + push: + branches: [main] + +jobs: + # --------------------------------------------------------------------- + # 1. Unit tests — moeten slagen voordat er iets gebouwd wordt + # --------------------------------------------------------------------- + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installeer dependencies + run: pip install -r requirements.txt + - name: Lint (ruff) + run: ruff check . + - name: Unit tests (pytest) + run: pytest tests/ -v + + # --------------------------------------------------------------------- + # 2. SonarQube — codekwaliteit-gate + # --------------------------------------------------------------------- + sonarqube: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # SonarQube wil de volledige git-historie zien + - name: SonarQube scan + uses: sonarsource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} + + # --------------------------------------------------------------------- + # 3. Image bouwen + Trivy security-scan + naar Gitea-registry pushen + # --------------------------------------------------------------------- + build-and-scan: + runs-on: ubuntu-latest + needs: [test, sonarqube] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -t gitea.thuis.local/willem/glucose-dashboard:${{ gitea.sha }} . + + - name: Trivy scan + uses: aquasecurity/trivy-action@master + with: + image-ref: gitea.thuis.local/willem/glucose-dashboard:${{ gitea.sha }} + severity: CRITICAL,HIGH + exit-code: "1" # laat de pipeline falen bij kritieke kwetsbaarheden + + - name: Login bij Gitea-registry + run: echo "${{ secrets.GITEA_TOKEN }}" | docker login gitea.thuis.local -u willem --password-stdin + + - name: Tag en push + run: | + docker tag gitea.thuis.local/willem/glucose-dashboard:${{ gitea.sha }} gitea.thuis.local/willem/glucose-dashboard:latest + docker push gitea.thuis.local/willem/glucose-dashboard:${{ gitea.sha }} + docker push gitea.thuis.local/willem/glucose-dashboard:latest + + # --------------------------------------------------------------------- + # 4. Deploy — SSH naar de LXC en herstart met de nieuwe image + # --------------------------------------------------------------------- + deploy: + runs-on: ubuntu-latest + needs: build-and-scan + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/glucose-dashboard + docker compose pull + docker compose up -d diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbdf951 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Systeem-dependency voor de HEALTHCHECK hieronder +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8501 + +HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1 + +ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..43e2672 --- /dev/null +++ b/app.py @@ -0,0 +1,34 @@ +from datetime import date + +import streamlit as st + +from data_processing import filter_by_date, hourly_average, load_data + +st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="centered") + +st.title("Glucose per uur") + +df = load_data() + +min_date = df["timestamp"].dt.date.min() if not df.empty else date.today() +max_date = df["timestamp"].dt.date.max() if not df.empty else date.today() + +selected_date = st.date_input( + "Kies een datum", + value=max_date, + min_value=min_date, + max_value=max_date, +) + +day_data = filter_by_date(df, selected_date) + +if day_data.empty: + st.info("Geen metingen gevonden voor deze datum.") +else: + hourly = hourly_average(day_data) + st.bar_chart( + hourly.set_index("hour")["glucose_value"], + x_label="Uur van de dag", + y_label="Gemiddelde glucosewaarde", + ) + st.caption(f"{len(day_data)} metingen op {selected_date.strftime('%d-%m-%Y')}") diff --git a/data/glucose_readings.csv b/data/glucose_readings.csv new file mode 100644 index 0000000..453268b --- /dev/null +++ b/data/glucose_readings.csv @@ -0,0 +1,193 @@ +timestamp,glucose_value +2026-07-22 00:00:00,6.7 +2026-07-22 00:15:00,6.7 +2026-07-22 00:30:00,7.3 +2026-07-22 00:45:00,7.9 +2026-07-22 01:00:00,7.5 +2026-07-22 01:15:00,7.7 +2026-07-22 01:30:00,8.5 +2026-07-22 01:45:00,8.3 +2026-07-22 02:00:00,7.8 +2026-07-22 02:15:00,8.2 +2026-07-22 02:30:00,7.7 +2026-07-22 02:45:00,7.6 +2026-07-22 03:00:00,7.7 +2026-07-22 03:15:00,6.6 +2026-07-22 03:30:00,6.4 +2026-07-22 03:45:00,6.6 +2026-07-22 04:00:00,6.1 +2026-07-22 04:15:00,6.3 +2026-07-22 04:30:00,5.6 +2026-07-22 04:45:00,5.1 +2026-07-22 05:00:00,6.0 +2026-07-22 05:15:00,5.2 +2026-07-22 05:30:00,5.1 +2026-07-22 05:45:00,4.5 +2026-07-22 06:00:00,4.8 +2026-07-22 06:15:00,5.1 +2026-07-22 06:30:00,4.7 +2026-07-22 06:45:00,5.4 +2026-07-22 07:00:00,5.2 +2026-07-22 07:15:00,5.5 +2026-07-22 07:30:00,5.7 +2026-07-22 07:45:00,6.9 +2026-07-22 08:00:00,6.5 +2026-07-22 08:15:00,6.4 +2026-07-22 08:30:00,7.4 +2026-07-22 08:45:00,6.8 +2026-07-22 09:00:00,7.6 +2026-07-22 09:15:00,7.0 +2026-07-22 09:30:00,7.4 +2026-07-22 09:45:00,8.0 +2026-07-22 10:00:00,8.3 +2026-07-22 10:15:00,8.0 +2026-07-22 10:30:00,7.8 +2026-07-22 10:45:00,7.6 +2026-07-22 11:00:00,7.0 +2026-07-22 11:15:00,7.0 +2026-07-22 11:30:00,6.9 +2026-07-22 11:45:00,7.2 +2026-07-22 12:00:00,6.6 +2026-07-22 12:15:00,5.5 +2026-07-22 12:30:00,6.1 +2026-07-22 12:45:00,5.5 +2026-07-22 13:00:00,5.2 +2026-07-22 13:15:00,5.5 +2026-07-22 13:30:00,5.5 +2026-07-22 13:45:00,5.4 +2026-07-22 14:00:00,4.7 +2026-07-22 14:15:00,4.9 +2026-07-22 14:30:00,5.2 +2026-07-22 14:45:00,5.6 +2026-07-22 15:00:00,5.2 +2026-07-22 15:15:00,5.6 +2026-07-22 15:30:00,5.5 +2026-07-22 15:45:00,5.7 +2026-07-22 16:00:00,6.8 +2026-07-22 16:15:00,7.3 +2026-07-22 16:30:00,7.0 +2026-07-22 16:45:00,7.7 +2026-07-22 17:00:00,7.7 +2026-07-22 17:15:00,7.5 +2026-07-22 17:30:00,8.0 +2026-07-22 17:45:00,8.6 +2026-07-22 18:00:00,8.0 +2026-07-22 18:15:00,8.6 +2026-07-22 18:30:00,6.8 +2026-07-22 18:45:00,8.1 +2026-07-22 19:00:00,7.6 +2026-07-22 19:15:00,7.2 +2026-07-22 19:30:00,7.1 +2026-07-22 19:45:00,6.0 +2026-07-22 20:00:00,6.4 +2026-07-22 20:15:00,6.4 +2026-07-22 20:30:00,6.5 +2026-07-22 20:45:00,5.5 +2026-07-22 21:00:00,5.1 +2026-07-22 21:15:00,5.1 +2026-07-22 21:30:00,5.5 +2026-07-22 21:45:00,5.2 +2026-07-22 22:00:00,4.8 +2026-07-22 22:15:00,5.2 +2026-07-22 22:30:00,5.2 +2026-07-22 22:45:00,5.6 +2026-07-22 23:00:00,5.2 +2026-07-22 23:15:00,5.5 +2026-07-22 23:30:00,5.8 +2026-07-22 23:45:00,5.6 +2026-07-23 00:00:00,6.6 +2026-07-23 00:15:00,6.9 +2026-07-23 00:30:00,7.1 +2026-07-23 00:45:00,7.2 +2026-07-23 01:00:00,7.0 +2026-07-23 01:15:00,7.6 +2026-07-23 01:30:00,7.7 +2026-07-23 01:45:00,7.7 +2026-07-23 02:00:00,7.9 +2026-07-23 02:15:00,8.1 +2026-07-23 02:30:00,8.6 +2026-07-23 02:45:00,7.8 +2026-07-23 03:00:00,7.7 +2026-07-23 03:15:00,7.3 +2026-07-23 03:30:00,6.3 +2026-07-23 03:45:00,6.8 +2026-07-23 04:00:00,6.5 +2026-07-23 04:15:00,7.2 +2026-07-23 04:30:00,5.8 +2026-07-23 04:45:00,5.8 +2026-07-23 05:00:00,5.4 +2026-07-23 05:15:00,4.8 +2026-07-23 05:30:00,5.6 +2026-07-23 05:45:00,5.3 +2026-07-23 06:00:00,5.3 +2026-07-23 06:15:00,4.7 +2026-07-23 06:30:00,5.7 +2026-07-23 06:45:00,4.7 +2026-07-23 07:00:00,5.7 +2026-07-23 07:15:00,6.5 +2026-07-23 07:30:00,5.5 +2026-07-23 07:45:00,6.0 +2026-07-23 08:00:00,6.5 +2026-07-23 08:15:00,6.6 +2026-07-23 08:30:00,6.5 +2026-07-23 08:45:00,7.4 +2026-07-23 09:00:00,7.1 +2026-07-23 09:15:00,7.9 +2026-07-23 09:30:00,7.5 +2026-07-23 09:45:00,8.6 +2026-07-23 10:00:00,7.7 +2026-07-23 10:15:00,7.8 +2026-07-23 10:30:00,8.2 +2026-07-23 10:45:00,7.3 +2026-07-23 11:00:00,7.7 +2026-07-23 11:15:00,7.9 +2026-07-23 11:30:00,6.4 +2026-07-23 11:45:00,6.9 +2026-07-23 12:00:00,6.6 +2026-07-23 12:15:00,6.5 +2026-07-23 12:30:00,5.4 +2026-07-23 12:45:00,5.1 +2026-07-23 13:00:00,5.6 +2026-07-23 13:15:00,5.4 +2026-07-23 13:30:00,5.2 +2026-07-23 13:45:00,5.2 +2026-07-23 14:00:00,4.7 +2026-07-23 14:15:00,5.1 +2026-07-23 14:30:00,5.2 +2026-07-23 14:45:00,5.0 +2026-07-23 15:00:00,6.2 +2026-07-23 15:15:00,5.9 +2026-07-23 15:30:00,5.4 +2026-07-23 15:45:00,6.5 +2026-07-23 16:00:00,6.1 +2026-07-23 16:15:00,7.1 +2026-07-23 16:30:00,7.5 +2026-07-23 16:45:00,7.0 +2026-07-23 17:00:00,7.9 +2026-07-23 17:15:00,7.9 +2026-07-23 17:30:00,8.2 +2026-07-23 17:45:00,8.7 +2026-07-23 18:00:00,7.9 +2026-07-23 18:15:00,7.7 +2026-07-23 18:30:00,7.5 +2026-07-23 18:45:00,7.4 +2026-07-23 19:00:00,7.5 +2026-07-23 19:15:00,7.5 +2026-07-23 19:30:00,7.2 +2026-07-23 19:45:00,7.1 +2026-07-23 20:00:00,6.5 +2026-07-23 20:15:00,6.8 +2026-07-23 20:30:00,5.8 +2026-07-23 20:45:00,6.8 +2026-07-23 21:00:00,5.7 +2026-07-23 21:15:00,4.9 +2026-07-23 21:30:00,4.7 +2026-07-23 21:45:00,5.2 +2026-07-23 22:00:00,4.9 +2026-07-23 22:15:00,5.3 +2026-07-23 22:30:00,5.3 +2026-07-23 22:45:00,5.2 +2026-07-23 23:00:00,5.1 +2026-07-23 23:15:00,5.1 +2026-07-23 23:30:00,5.7 +2026-07-23 23:45:00,6.5 diff --git a/data_processing.py b/data_processing.py new file mode 100644 index 0000000..99c3ef5 --- /dev/null +++ b/data_processing.py @@ -0,0 +1,52 @@ +""" +Data processing logic voor het glucose-dashboard. + +Bewust gescheiden van app.py: Streamlit-UI-code kun je niet zomaar los +unit-testen (die heeft een draaiende Streamlit-context nodig). Door de +logica (inladen, aggregeren) in een los module te zetten, kun je die +logica wel gewoon met pytest testen, en blijft app.py alleen de UI-laag. +""" + +from pathlib import Path + +import pandas as pd + +DATA_PATH = Path(__file__).parent / "data" / "glucose_readings.csv" + + +def load_data(path: Path = DATA_PATH) -> pd.DataFrame: + """Laad de ruwe metingen in. + + Verwacht kolommen: 'timestamp' (parsebaar als datetime) en + 'glucose_value' (numeriek). Vervang deze functie later door je + eigen databron (database, CGM-export, API-call, etc.) — de rest + van de app blijft dan ongewijzigd werken. + """ + df = pd.read_csv(path, parse_dates=["timestamp"]) + return df + + +def filter_by_date(df: pd.DataFrame, selected_date) -> pd.DataFrame: + """Filter de dataset op een enkele kalenderdag.""" + return df[df["timestamp"].dt.date == selected_date] + + +def hourly_average(df: pd.DataFrame) -> pd.DataFrame: + """Groepeer per uur (0-23) en bereken de gemiddelde waarde. + + Geeft een DataFrame terug met kolommen 'hour' en 'glucose_value', + ÊÊn rij per uur waarin daadwerkelijk data zit. + """ + if df.empty: + return pd.DataFrame(columns=["hour", "glucose_value"]) + + working = df.copy() + working["hour"] = working["timestamp"].dt.hour + result = ( + working.groupby("hour")["glucose_value"] + .mean() + .round(1) + .reset_index() + .sort_values("hour") + ) + return result diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dd6eb51 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + glucose-dashboard: + image: gitea.thuis.local/willem/glucose-dashboard:latest + container_name: glucose-dashboard + restart: unless-stopped + ports: + - "8501:8501" + volumes: + - ./data:/app/data # zet hier je eigen databron neer i.p.v. de voorbeelddata diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71cd39e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +streamlit>=1.38,<2.0 +pandas>=2.2,<3.0 +pytest>=8.0,<9.0 +ruff>=0.6,<1.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..7c9f4dc --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.projectKey=glucose-dashboard +sonar.projectName=Glucose Dashboard +sonar.sources=. +sonar.exclusions=data/**,tests/** +sonar.tests=tests +sonar.python.version=3.12 diff --git a/tests/test_data_processing.py b/tests/test_data_processing.py new file mode 100644 index 0000000..d7da5e6 --- /dev/null +++ b/tests/test_data_processing.py @@ -0,0 +1,52 @@ +import pandas as pd +import pytest + +from data_processing import filter_by_date, hourly_average + + +@pytest.fixture +def sample_df(): + return pd.DataFrame( + { + "timestamp": pd.to_datetime( + [ + "2026-07-22 08:05", + "2026-07-22 08:40", + "2026-07-22 09:10", + "2026-07-23 08:05", # andere dag, moet uitgefilterd worden + ] + ), + "glucose_value": [6.0, 8.0, 7.0, 12.0], + } + ) + + +def test_filter_by_date_keeps_only_selected_day(sample_df): + result = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) + assert len(result) == 3 + assert (result["timestamp"].dt.date == pd.Timestamp("2026-07-22").date()).all() + + +def test_hourly_average_computes_mean_per_hour(sample_df): + filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) + result = hourly_average(filtered) + + hour_8 = result.loc[result["hour"] == 8, "glucose_value"].iloc[0] + hour_9 = result.loc[result["hour"] == 9, "glucose_value"].iloc[0] + + assert hour_8 == pytest.approx(7.0) # gemiddelde van 6.0 en 8.0 + assert hour_9 == pytest.approx(7.0) + + +def test_hourly_average_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "glucose_value"]) + result = hourly_average(empty) + assert result.empty + assert list(result.columns) == ["hour", "glucose_value"] + + +def test_hourly_average_only_returns_hours_with_data(sample_df): + filtered = filter_by_date(sample_df, pd.Timestamp("2026-07-22").date()) + result = hourly_average(filtered) + # alleen uur 8 en 9 hebben data op deze dag, dus precies 2 rijen + assert len(result) == 2