From 169559034666182c3e647d7e066de8016d792868 Mon Sep 17 00:00:00 2001 From: ghost Date: Sat, 6 Dec 2025 17:39:27 +0100 Subject: [PATCH] Refactor: Project structure to MVVM. Add: Basic scaffolding for data service and gui. Add: Build and Utility scripts. --- .flake8 | 3 + .gitignore | 5 + README.md | 68 ++++++--- docs/agent_instructions.md | 34 +++++ docs/architecture.md | 35 +++++ pyproject.toml | 14 +- scripts/activate-pycache.sh | 14 ++ scripts/bootstrap.sh | 35 +++++ scripts/build.sh | 34 +++++ scripts/clean.sh | 44 ++++++ scripts/run-app.sh | 22 +++ scripts/run-qa.sh | 27 ++++ src/study-dashboard/di_container.py | 20 --- src/study-dashboard/gui/__init__.py | 0 src/study-dashboard/gui/main_view.py | 19 --- src/study-dashboard/main.py | 31 ---- src/study-dashboard/services/__init__.py | 0 src/study-dashboard/services/data_provider.py | 26 ---- .../__init__.py | 0 src/study_dashboard/di_container.py | 33 +++++ src/study_dashboard/main.py | 28 ++++ src/study_dashboard/services/__init__.py | 16 +++ src/study_dashboard/services/database.py | 133 ++++++++++++++++++ src/study_dashboard/services/models.py | 30 ++++ .../services/models/__init__.py | 9 ++ .../services/models/appointment_record.py | 15 ++ .../services/models/exam_record.py | 16 +++ .../services/models/module_record.py | 15 ++ src/study_dashboard/services/repository.py | 28 ++++ .../services/sqlite_repository.py | 82 +++++++++++ src/study_dashboard/view_models/__init__.py | 5 + .../view_models/main_view_model.py | 86 +++++++++++ src/study_dashboard/views/__init__.py | 5 + src/study_dashboard/views/main_view.py | 110 +++++++++++++++ tests/conftest.py | 8 ++ tests/test_services.py | 48 +++++++ tests/test_view_model.py | 117 +++++++++++++++ 37 files changed, 1094 insertions(+), 121 deletions(-) create mode 100644 .flake8 create mode 100644 docs/agent_instructions.md create mode 100644 docs/architecture.md create mode 100755 scripts/activate-pycache.sh create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/build.sh create mode 100755 scripts/clean.sh create mode 100755 scripts/run-app.sh create mode 100755 scripts/run-qa.sh delete mode 100644 src/study-dashboard/di_container.py delete mode 100644 src/study-dashboard/gui/__init__.py delete mode 100644 src/study-dashboard/gui/main_view.py delete mode 100644 src/study-dashboard/main.py delete mode 100644 src/study-dashboard/services/__init__.py delete mode 100644 src/study-dashboard/services/data_provider.py rename src/{study-dashboard => study_dashboard}/__init__.py (100%) create mode 100644 src/study_dashboard/di_container.py create mode 100644 src/study_dashboard/main.py create mode 100644 src/study_dashboard/services/__init__.py create mode 100644 src/study_dashboard/services/database.py create mode 100644 src/study_dashboard/services/models.py create mode 100644 src/study_dashboard/services/models/__init__.py create mode 100644 src/study_dashboard/services/models/appointment_record.py create mode 100644 src/study_dashboard/services/models/exam_record.py create mode 100644 src/study_dashboard/services/models/module_record.py create mode 100644 src/study_dashboard/services/repository.py create mode 100644 src/study_dashboard/services/sqlite_repository.py create mode 100644 src/study_dashboard/view_models/__init__.py create mode 100644 src/study_dashboard/view_models/main_view_model.py create mode 100644 src/study_dashboard/views/__init__.py create mode 100644 src/study_dashboard/views/main_view.py create mode 100644 tests/conftest.py create mode 100644 tests/test_view_model.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..e4e5eb3 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, W503 diff --git a/.gitignore b/.gitignore index 957ef0d..f7e2e93 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ downloads/ eggs/ .eggs/ +bin/ lib/ lib64/ parts/ @@ -187,6 +188,7 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder .vscode/ +.github/ # Ruff stuff: .ruff_cache/ @@ -240,3 +242,6 @@ object_script.*.Debug *.prl *.qmlc *.jsc + +# SQLite +study.db diff --git a/README.md b/README.md index 4ea5b34..c3eb005 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,63 @@ # Study Dashboard -## Pakete -- PySide6 -- dependency-injector +PySide6 based dashboard to keep track of study progress, built with a lightweight MVVM architecture, dependency injection and a local SQLite database that bootstraps itself with example data. +> **Copilot agents:** read `.github/copilot-instructions.md` before making changes for a condensed list of mandatory workflows. -## Einrichten der Entwicklungsumgebung +## Stack Overview +- PySide6 for the GUI (widgets, signals/slots, Designer integration) +- dependency-injector for composing services, repositories and view models +- SQLite (local file `study.db` inside the repo) for persistence +- pytest, mypy, flake8 and black for TDD-inspired workflows and quality gates +## Development Environment (`venv`) ```bash -# Projekt herunterladen +# Clone the project git clone https://git.ghostnet.selfhost.eu/spektr/study-dashboard.git cd study-dashboard -# Virtuelle Umgebung erstellen -python -m venv .venv - -# Aktivieren (Linux/macOS) -source .venv/bin/activate - -# Aktivieren (Windows) -.venv\Scripts\activate - -# Abhängigkeiten aktualisieren -pip install -r requirements.txt +# Create/refresh the virtual environment and install requirements +bash scripts/bootstrap.sh ``` -## Starten +The script creates `.venv` if necessary, upgrades `pip`, and installs everything from `requirements.txt`. Re-run it whenever dependencies change. + +## Running the Application +Run the dashboard (the script takes care of `PYTHONPYCACHEPREFIX` and `PYTHONPATH`): + ```bash -python -m src.study-dashboard.main +bash scripts/run-app.sh ``` + +On startup the app ensures the SQLite database exists, creates the schema if required and seeds demo modules, exams and calendar entries. + +## Tests & Quality +```bash +# optional pytest args are forwarded, e.g. "-k view_model" +bash scripts/run-qa.sh +``` +`run-qa.sh` executes pytest, mypy, flake8 and black (check mode) with the correct environment so caches stay under `bin/`. + +## Cleanup +If another agent or IDE command ran outside the helper scripts and created `__pycache__` folders inside `src/` or `tests/`, clean them up with: + +```bash +bash scripts/clean.sh +``` + +The script removes any stray bytecode caches or `*.pyc` files under the tracked source tree without touching other temporary files in the project root. + +## Generated Files & Deployment + +- All temporary build artefacts live under `bin/`, which is ignored by git. The helper scripts (and `scripts/activate-pycache.sh` for manual setups) automatically create `bin/pycache` and set `PYTHONPYCACHEPREFIX` so no `__pycache__` folders appear in `src/` or `tests/`. +- PySide deployment can be prepared with the spec at `deploy/pysidedeploy.spec`. A typical run looks like: + + ```bash + bash scripts/build.sh + ``` + + Pass additional flags after the script name if you need to tweak the deploy call. The spec already points `exec_directory` to `bin/deploy`, keeping executables outside the tracked source tree. +- Avoid installing the project in editable mode to keep `*.egg-info` files out of `src/`. Use `pip install -r requirements.txt` instead. + +## Architecture Notes +See `docs/architecture.md` for the MVVM layer diagram, DI wiring and database bootstrap details. diff --git a/docs/agent_instructions.md b/docs/agent_instructions.md new file mode 100644 index 0000000..46d1c26 --- /dev/null +++ b/docs/agent_instructions.md @@ -0,0 +1,34 @@ +# Study Dashboard – Agent Instructions + +This document summarizes the project conventions and provides a hand-off for the next sprint. When starting new work, follow these steps before touching any code: + +1. **Read the docs**: review `README.md`, `docs/architecture.md`, and this file to understand the stack (PySide6, MVVM, dependency-injector, SQLite seed DB). +2. **Prepare the env**: run `bash scripts/bootstrap.sh` to create/update `.venv`. Use the helper scripts (`bash scripts/run-app.sh`, `bash scripts/run-qa.sh`) so env vars and caches are handled automatically. +3. **Run tests by default**: `bash scripts/run-qa.sh` executes pytest, mypy, flake8, and black in one go (forward extra pytest args as needed). Add or extend tests alongside code. + +## Guiding Principles + +- **Architecture**: MVVM with a thin PySide6 view (`gui/main_view.py`), a Qt-based ViewModel (`gui/main_view_model.py`), and a repository layer backed by SQLite. All data access goes through `StudyRepository`. +- **Dependency injection**: extend `src/study_dashboard/di_container.py` when adding new services or ViewModels. Create providers for configs beforehand. +- **Database**: the bootstrapper auto-creates `study.db` with seed data. When schema changes, update `services/database.py` and adjust tests. +- **Generated files**: everything temporary belongs under `bin/` (already git-ignored). The PySide deploy spec lives in `deploy/pysidedeploy.spec`, `bash scripts/build.sh` wraps it and writes output to `bin/deploy`. +- **Cleanup**: if a task bypassed the helper scripts and left `__pycache__` folders in `src/` or `tests/`, run `bash scripts/clean.sh` before committing. +- **UI language**: keep user-facing strings in German. +- **Style**: enforce PEP8 via `flake8 src tests`. Keep docstrings and comments concise; prefer descriptive identifiers. + +## Workflow Expectations + +- For every feature: rely on the helper scripts (`run-app`, `run-qa`, `build`) to keep commands consistent, use `bash scripts/clean.sh` whenever cached artefacts slip into tracked folders, and update documentation/tests alongside code. +- Update documentation if behavior or setup changes (`README.md` or `docs/architecture.md`). +- Prefer smaller commits tied to single concerns. + +## Next Sprint Placeholder + +When kicking off a new sprint: + +1. Identify the next feature or bug fix. +2. Write a brief plan (e.g., extend ViewModel for KPI charts, add editing dialogs, etc.). +3. List tasks in order inside the todo tool / project tracker. +4. Execute while keeping the principles above in mind. + +Add future objectives below as they are defined. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a0acf1b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,35 @@ +# Study Dashboard Architecture + +## Goals +- Embrace MVVM to keep views (Qt widgets) dumb and push logic into view models. +- Use dependency injection for explicit wiring of repositories, services and view models. +- Keep persistence simple with a local SQLite file that the app can create and seed automatically. +- Maintain TDD-friendly seams so repositories and view models stay unit-testable without the GUI. + +## Layers +| Layer | Responsibilities | +|--------------|--------------------------------------------------------------------------------------------------| +| View (PySide6)| Defines widgets/layouts, binds to view-model signals/slots, contains German UI text. | +| ViewModel | Exposes observable properties and commands (Qt signals/slots), orchestrates services asynchronously if needed. | +| Services | Aggregate domain operations (e.g., progress tracking, calendar queries) built on repositories. | +| Repositories | Talk to SQLite using `sqlite3`, handle schema migrations/bootstrap and raw queries. | +| Infrastructure | Dependency injection container, configuration (paths, environment), logging, bootstrap. | + +## Dependency Injection Flow +1. `ApplicationContainer` defines providers for configuration (DB path), SQLite connections, repositories, services and view models. +2. `main.py` initialises the container, triggers database bootstrap (create file, apply schema, seed demo data), then builds the QApplication + `MainView`. +3. The view receives its view model via DI so it can subscribe to signals and send commands without knowing about repositories. + +## Database Bootstrap +- Database file: `study.db` at repo root (configurable via container if needed). +- Tables: `modules`, `exams`, `appointments` (calendar tile). +- On first launch the bootstrapper: + 1. Creates the file and tables if they do not exist. + 2. Inserts demo data (e.g., three modules with credits/status, two upcoming exams, two appointments). +- Seeding runs idempotently: existing rows are left intact to avoid wiping user data. + +## Testing Approach +- Repository tests use a temporary SQLite database (in-memory or tmp directory) to validate schema + CRUD logic. +- View model tests stub repositories/services via dependency injection to simulate responses and assert emitted signals. +- GUI level tests stay minimal for now; focus on logic-heavy layers for fast feedback. +- Aim for “test-first” when touching new behavior, mirroring a TDD workflow. diff --git a/pyproject.toml b/pyproject.toml index 59222f5..a41262a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,18 +7,15 @@ name = "study-dashboard" version = "0.1.0" description = "Dashboard zur Nachverfolgung des Studienfortschritts" authors = [ - { - name = "Marcel König", - email = "marcel.koenig@iu-study.org" - } + { name = "Marcel König", email = "marcel.koenig@iu-study.org" } ] dependencies = [ "PySide6 >= 6.4.0", "dependency-injector >= 4.41.0" ] -required-python = ">=3.9" +requires-python = ">=3.9" -[project.optional.dependencies] +[project.optional-dependencies] dev = [ "pytest >= 7.0", "black >= 23.0", @@ -27,4 +24,7 @@ dev = [ ] [project.scripts] -study-dashboard = "study-dashboard.main:main" +study-dashboard = "study_dashboard.main:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/scripts/activate-pycache.sh b/scripts/activate-pycache.sh new file mode 100755 index 0000000..2024a5c --- /dev/null +++ b/scripts/activate-pycache.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Source this script to keep Python bytecode caches outside the source tree. +# source scripts/activate-pycache.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CACHE_DIR="${PROJECT_ROOT}/bin/pycache" + +mkdir -p "${CACHE_DIR}" +export PYTHONPYCACHEPREFIX="${CACHE_DIR}" + +echo "PYTHONPYCACHEPREFIX=${PYTHONPYCACHEPREFIX}" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..3f5d3cd --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Prepare the virtual environment and install dependencies. +# Usage: bash scripts/bootstrap.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_DIR="${PROJECT_ROOT}/.venv" +PYTHON_BIN="${PYTHON_BIN:-python3}" + +if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then + echo "Python interpreter '${PYTHON_BIN}' not found. Set PYTHON_BIN to override." >&2 + exit 1 +fi + +if [ ! -d "${VENV_DIR}" ]; then + echo "Creating virtual environment at ${VENV_DIR}" + "${PYTHON_BIN}" -m venv "${VENV_DIR}" +else + echo "Reusing existing virtual environment at ${VENV_DIR}" +fi + +"${VENV_DIR}/bin/python" -m pip install --upgrade pip +REQ_FILE="${PROJECT_ROOT}/requirements.txt" +if [ -f "${REQ_FILE}" ]; then + "${VENV_DIR}/bin/pip" install -r "${REQ_FILE}" +else + echo "requirements.txt not found at ${REQ_FILE}" >&2 +fi + +cat <<'EOF' +Virtual environment ready. +Use the helper scripts in scripts/ to run the app, tests, or build artefacts. +EOF diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..4f9bf40 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build the PySide deployment artefacts into bin/deploy via the provided spec. +# Usage: bash scripts/build.sh [extra deploy args] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_DIR="${PROJECT_ROOT}/.venv" +PYTHON_BIN="${VENV_DIR}/bin/python" + +if [ ! -x "${PYTHON_BIN}" ]; then + echo "Virtualenv not found. Run 'bash scripts/bootstrap.sh' first." >&2 + exit 1 +fi + +SPEC_FILE="${PROJECT_ROOT}/deploy/pysidedeploy.spec" +if [ ! -f "${SPEC_FILE}" ]; then + echo "Deploy spec missing at ${SPEC_FILE}" >&2 + exit 1 +fi + +CACHE_DIR="${PROJECT_ROOT}/bin/pycache" +DEPLOY_DIR="${PROJECT_ROOT}/bin/deploy" +mkdir -p "${CACHE_DIR}" "${DEPLOY_DIR}" + +pushd "${PROJECT_ROOT}" >/dev/null +PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}" \ +PYTHONPYCACHEPREFIX="${CACHE_DIR}" \ +"${PYTHON_BIN}" -m PySide6.scripts.deploy \ + -c "${SPEC_FILE}" \ + "src/study_dashboard/main.py" \ + "$@" +popd >/dev/null diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..83c00ab --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Remove stray Python bytecode caches that may have leaked into tracked sources. +# Usage: bash scripts/clean.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +TARGETS=("${PROJECT_ROOT}/src" "${PROJECT_ROOT}/tests") + +removed_any=false + +remove_pycache_dirs() { + local target="$1" + if [ -d "${target}" ]; then + while IFS= read -r -d '' dir; do + removed_any=true + echo "Removing directory: ${dir#${PROJECT_ROOT}/}" + rm -rf "${dir}" + done < <(find "${target}" -type d -name "__pycache__" -print0) + fi +} + +remove_pyc_files() { + local target="$1" + if [ -d "${target}" ]; then + while IFS= read -r -d '' file; do + removed_any=true + echo "Removing file: ${file#${PROJECT_ROOT}/}" + rm -f "${file}" + done < <(find "${target}" -type f \( -name "*.pyc" -o -name "*.pyo" -o -name "*.pyd" \) -print0) + fi +} + +for target in "${TARGETS[@]}"; do + remove_pycache_dirs "${target}" + remove_pyc_files "${target}" +done + +if [ "${removed_any}" = false ]; then + echo "No cached artefacts found under src/ or tests/." +else + echo "Source tree cleaned. Future commands should run via scripts/ to keep it tidy." +fi diff --git a/scripts/run-app.sh b/scripts/run-app.sh new file mode 100755 index 0000000..98e27fd --- /dev/null +++ b/scripts/run-app.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Run the Study Dashboard application with the configured virtualenv and cache prefix. +# Usage: bash scripts/run-app.sh [--args passed to main] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_DIR="${PROJECT_ROOT}/.venv" +PYTHON_BIN="${VENV_DIR}/bin/python" + +if [ ! -x "${PYTHON_BIN}" ]; then + echo "Virtualenv not found. Run 'bash scripts/bootstrap.sh' first." >&2 + exit 1 +fi + +CACHE_DIR="${PROJECT_ROOT}/bin/pycache" +mkdir -p "${CACHE_DIR}" + +PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}" \ +PYTHONPYCACHEPREFIX="${CACHE_DIR}" \ +"${PYTHON_BIN}" -m study_dashboard.main "$@" diff --git a/scripts/run-qa.sh b/scripts/run-qa.sh new file mode 100755 index 0000000..d250559 --- /dev/null +++ b/scripts/run-qa.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Run the default QA suite (pytest, mypy, flake8, black --check). +# Additional arguments are forwarded to pytest. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-${0}}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +VENV_DIR="${PROJECT_ROOT}/.venv" +PYTHON_BIN="${VENV_DIR}/bin/python" + +if [ ! -x "${PYTHON_BIN}" ]; then + echo "Virtualenv not found. Run 'bash scripts/bootstrap.sh' first." >&2 + exit 1 +fi + +CACHE_DIR="${PROJECT_ROOT}/bin/pycache" +mkdir -p "${CACHE_DIR}" +export PYTHONPYCACHEPREFIX="${CACHE_DIR}" +export PYTHONPATH="${PROJECT_ROOT}/src${PYTHONPATH:+:${PYTHONPATH}}" + +PYTEST_ARGS=("$@") + +"${PYTHON_BIN}" -m pytest "${PYTEST_ARGS[@]}" +"${PYTHON_BIN}" -m mypy src +"${VENV_DIR}/bin/flake8" src tests +"${VENV_DIR}/bin/black" --check src tests diff --git a/src/study-dashboard/di_container.py b/src/study-dashboard/di_container.py deleted file mode 100644 index 5a46981..0000000 --- a/src/study-dashboard/di_container.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Module: -Author: Marcel König -Description: -""" - -from dependency_injector import containers, providers -from .services.data_provider import DataProvider - -class ApplicationContainer(containers.DeclarativeContainer): - # Configuration - # config = providers.Configuration() - - # Services - # business_service = providers.Single( - # BusinessService, - # config=config.business - # ) - dataProvider = providers.Singleton(DataProvider) - \ No newline at end of file diff --git a/src/study-dashboard/gui/__init__.py b/src/study-dashboard/gui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/study-dashboard/gui/main_view.py b/src/study-dashboard/gui/main_view.py deleted file mode 100644 index f1e97c9..0000000 --- a/src/study-dashboard/gui/main_view.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Module: gui.mainView - -Dieses Modul enthält die Hauptfenster-Definition für die Qt-basierte GUI. -""" - -from PySide6.QtWidgets import QMainWindow, QLabel - -class MainView(QMainWindow): - def __init__(self, data_provider): - super().__init__() - self.setWindowTitle("Studien-Dashboard") - self.resize(800, 600) - - self.data_provider = data_provider - - label = QLabel(str(self.data_provider.get_all()), self) - self.setCentralWidget(label) - \ No newline at end of file diff --git a/src/study-dashboard/main.py b/src/study-dashboard/main.py deleted file mode 100644 index 021ef61..0000000 --- a/src/study-dashboard/main.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Module: -Author: Marcel König -Description: -""" - -import sys -from PySide6.QtWidgets import QApplication -from .di_container import ApplicationContainer -from .gui.main_view import MainView - -def main(): - container = ApplicationContainer() - # container.config.from_dict( - # { - # "business": { - # "setting1": "value1", - # "setting2": "value" - # } - # } - # ) - - app = QApplication(sys.argv) - window = MainView(data_provider=container.dataProvider()) - window.show() - - sys.exit(app.exec()) - -if __name__ == "__main__": - main() - \ No newline at end of file diff --git a/src/study-dashboard/services/__init__.py b/src/study-dashboard/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/study-dashboard/services/data_provider.py b/src/study-dashboard/services/data_provider.py deleted file mode 100644 index 7a0493b..0000000 --- a/src/study-dashboard/services/data_provider.py +++ /dev/null @@ -1,26 +0,0 @@ -class DataProvider: - def __init__(self): - self._data = { "foo": { "id": "foo", "value": "bar" } } - - def get_all(self): - return list(self._data.values()) - - def get(self, item_id): - return self._data.get(item_id, None) - - def create(self, data): - self._data[data['id']] = data - return data - - def update(self, item_id, data): - if item_id in self._data: - self._data[item_id].update(data) - return self._data[item_id] - return None - - def delete(self, item_id): - if item_id in self._data: - del self._data[item_id] - return True - return False - \ No newline at end of file diff --git a/src/study-dashboard/__init__.py b/src/study_dashboard/__init__.py similarity index 100% rename from src/study-dashboard/__init__.py rename to src/study_dashboard/__init__.py diff --git a/src/study_dashboard/di_container.py b/src/study_dashboard/di_container.py new file mode 100644 index 0000000..8910003 --- /dev/null +++ b/src/study_dashboard/di_container.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from dependency_injector import containers, providers + +from .view_models import MainViewModel +from .services import ( + DatabaseBootstrapper, + DatabaseConfig, + SQLiteStudyRepository, +) + + +class ApplicationContainer(containers.DeclarativeContainer): + """Central dependency graph for the application.""" + + config = providers.Configuration() + + db_path = providers.Callable(Path, config.db_path) + + database_config = providers.Singleton(DatabaseConfig, db_path=db_path) + database_bootstrapper = providers.Singleton( + DatabaseBootstrapper, + config=database_config, + ) + + study_repository = providers.Singleton( + SQLiteStudyRepository, + db_path=db_path, + ) + main_view_model = providers.Factory( + MainViewModel, + repository=study_repository, + ) diff --git a/src/study_dashboard/main.py b/src/study_dashboard/main.py new file mode 100644 index 0000000..3ad1d23 --- /dev/null +++ b/src/study_dashboard/main.py @@ -0,0 +1,28 @@ +import sys +from pathlib import Path + +from PySide6.QtWidgets import QApplication + +from .di_container import ApplicationContainer +from .views import MainView + + +def main() -> None: + container = ApplicationContainer() + + project_root = Path(__file__).resolve().parents[2] + db_path = project_root / "study.db" + + container.config.db_path.from_value(str(db_path)) + container.database_bootstrapper().initialize() + + app = QApplication(sys.argv) + view_model = container.main_view_model() + window = MainView(view_model=view_model) + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/study_dashboard/services/__init__.py b/src/study_dashboard/services/__init__.py new file mode 100644 index 0000000..18144d4 --- /dev/null +++ b/src/study_dashboard/services/__init__.py @@ -0,0 +1,16 @@ +"""Service layer exports.""" + +from .database import DatabaseBootstrapper, DatabaseConfig +from .models import AppointmentRecord, ExamRecord, ModuleRecord +from .repository import StudyRepository +from .sqlite_repository import SQLiteStudyRepository + +__all__ = [ + "AppointmentRecord", + "DatabaseBootstrapper", + "DatabaseConfig", + "ExamRecord", + "ModuleRecord", + "SQLiteStudyRepository", + "StudyRepository", +] diff --git a/src/study_dashboard/services/database.py b/src/study_dashboard/services/database.py new file mode 100644 index 0000000..eb756fb --- /dev/null +++ b/src/study_dashboard/services/database.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class DatabaseConfig: + db_path: Path + + +class DatabaseBootstrapper: + """Creates the SQLite schema and demo data on demand.""" + + def __init__(self, config: DatabaseConfig) -> None: + self._config = config + + @property + def path(self) -> Path: + return self._config.db_path + + def initialize(self) -> None: + self._ensure_parent_dir() + with sqlite3.connect(self.path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + self._create_tables(conn) + self._seed_demo_data(conn) + conn.commit() + + def _ensure_parent_dir(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + + def _create_tables(self, conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS modules ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + credit_points INTEGER NOT NULL, + status TEXT NOT NULL, + progress_percent INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS exams ( + id TEXT PRIMARY KEY, + module_id TEXT NOT NULL + REFERENCES modules(id) ON DELETE CASCADE, + title TEXT NOT NULL, + exam_date TEXT NOT NULL, + status TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS appointments ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + start_date TEXT NOT NULL, + description TEXT NOT NULL + ); + """ + ) + + def _seed_demo_data(self, conn: sqlite3.Connection) -> None: + modules = [ + ( + "MAT101", + "Analysis I", + 5, + "In Progress", + 40, + ), + ( + "CS201", + "Algorithms", + 6, + "Planned", + 0, + ), + ( + "ENG150", + "Academic Writing", + 3, + "Completed", + 100, + ), + ] + exams = [ + ( + "EXAM-MAT101", + "MAT101", + "Klausur Analysis I", + "2025-12-15", + "Scheduled", + ), + ( + "EXAM-CS201", + "CS201", + "Algorithmik Projekt", + "2026-01-20", + "Planned", + ), + ] + appointments = [ + ( + "APPT-MENTOR", + "Mentoring", + "2025-12-08", + "Mentoring call via Teams", + ), + ( + "APPT-STUDY", + "Lerngruppe", + "2025-12-10", + "Gruppenlernen Bibliothek", + ), + ] + + module_sql = "INSERT OR IGNORE INTO modules " "VALUES (?, ?, ?, ?, ?)" + exam_sql = "INSERT OR IGNORE INTO exams " "VALUES (?, ?, ?, ?, ?)" + appointment_sql = "INSERT OR IGNORE INTO appointments " "VALUES (?, ?, ?, ?)" + + self._bulk_insert(conn, module_sql, modules) + self._bulk_insert(conn, exam_sql, exams) + self._bulk_insert(conn, appointment_sql, appointments) + + def _bulk_insert( + self, + conn: sqlite3.Connection, + sql: str, + rows: Iterable[tuple], + ) -> None: + conn.executemany(sql, rows) diff --git a/src/study_dashboard/services/models.py b/src/study_dashboard/services/models.py new file mode 100644 index 0000000..3f82749 --- /dev/null +++ b/src/study_dashboard/services/models.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True) +class ModuleRecord: + module_id: str + title: str + credit_points: int + status: str # e.g. "In Progress", "Completed" + progress_percent: int + + +@dataclass(frozen=True) +class ExamRecord: + exam_id: str + module_id: str + title: str + exam_date: date + status: str # e.g. "Scheduled", "Completed" + + +@dataclass(frozen=True) +class AppointmentRecord: + appointment_id: str + title: str + start_date: date + description: str diff --git a/src/study_dashboard/services/models/__init__.py b/src/study_dashboard/services/models/__init__.py new file mode 100644 index 0000000..d6aad95 --- /dev/null +++ b/src/study_dashboard/services/models/__init__.py @@ -0,0 +1,9 @@ +from .appointment_record import AppointmentRecord +from .exam_record import ExamRecord +from .module_record import ModuleRecord + +__all__ = [ + "AppointmentRecord", + "ExamRecord", + "ModuleRecord", +] diff --git a/src/study_dashboard/services/models/appointment_record.py b/src/study_dashboard/services/models/appointment_record.py new file mode 100644 index 0000000..0d7c078 --- /dev/null +++ b/src/study_dashboard/services/models/appointment_record.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True) +class AppointmentRecord: + appointment_id: str + title: str + start_date: date + description: str + + +__all__ = ["AppointmentRecord"] diff --git a/src/study_dashboard/services/models/exam_record.py b/src/study_dashboard/services/models/exam_record.py new file mode 100644 index 0000000..81df0ab --- /dev/null +++ b/src/study_dashboard/services/models/exam_record.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + + +@dataclass(frozen=True) +class ExamRecord: + exam_id: str + module_id: str + title: str + exam_date: date + status: str # e.g. "Scheduled", "Completed" + + +__all__ = ["ExamRecord"] diff --git a/src/study_dashboard/services/models/module_record.py b/src/study_dashboard/services/models/module_record.py new file mode 100644 index 0000000..6e5d6d7 --- /dev/null +++ b/src/study_dashboard/services/models/module_record.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ModuleRecord: + module_id: str + title: str + credit_points: int + status: str # e.g. "In Progress", "Completed" + progress_percent: int + + +__all__ = ["ModuleRecord"] diff --git a/src/study_dashboard/services/repository.py b/src/study_dashboard/services/repository.py new file mode 100644 index 0000000..2563e41 --- /dev/null +++ b/src/study_dashboard/services/repository.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Sequence + +from .models import AppointmentRecord, ExamRecord, ModuleRecord + + +class StudyRepository(ABC): + """Abstraction over study progress persistence.""" + + @abstractmethod + def list_modules(self) -> Sequence[ModuleRecord]: + raise NotImplementedError + + @abstractmethod + def list_upcoming_exams( + self, + limit: int = 5, + ) -> Sequence[ExamRecord]: + raise NotImplementedError + + @abstractmethod + def list_upcoming_appointments( + self, + limit: int = 5, + ) -> Sequence[AppointmentRecord]: + raise NotImplementedError diff --git a/src/study_dashboard/services/sqlite_repository.py b/src/study_dashboard/services/sqlite_repository.py new file mode 100644 index 0000000..b2a3397 --- /dev/null +++ b/src/study_dashboard/services/sqlite_repository.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import sqlite3 +from datetime import date +from pathlib import Path +from typing import Sequence + +from .models import AppointmentRecord, ExamRecord, ModuleRecord +from .repository import StudyRepository + + +class SQLiteStudyRepository(StudyRepository): + def __init__(self, db_path: Path) -> None: + self._db_path = Path(db_path) + + def list_modules(self) -> Sequence[ModuleRecord]: + query = ( + "SELECT id, title, credit_points, status, progress_percent " + "FROM modules ORDER BY title" + ) + rows = self._fetch_all(query) + return [ + ModuleRecord( + module_id=row["id"], + title=row["title"], + credit_points=row["credit_points"], + status=row["status"], + progress_percent=row["progress_percent"], + ) + for row in rows + ] + + def list_upcoming_exams(self, limit: int = 5) -> Sequence[ExamRecord]: + query = ( + "SELECT id, module_id, title, exam_date, status FROM exams " + "ORDER BY date(exam_date) ASC LIMIT ?" + ) + rows = self._fetch_all(query, (limit,)) + return [ + ExamRecord( + exam_id=row["id"], + module_id=row["module_id"], + title=row["title"], + exam_date=_parse_date(row["exam_date"]), + status=row["status"], + ) + for row in rows + ] + + def list_upcoming_appointments( + self, + limit: int = 5, + ) -> Sequence[AppointmentRecord]: + query = ( + "SELECT id, title, start_date, description FROM appointments " + "ORDER BY date(start_date) ASC LIMIT ?" + ) + rows = self._fetch_all(query, (limit,)) + return [ + AppointmentRecord( + appointment_id=row["id"], + title=row["title"], + start_date=_parse_date(row["start_date"]), + description=row["description"], + ) + for row in rows + ] + + def _fetch_all( + self, + query: str, + params: tuple | None = None, + ) -> list[sqlite3.Row]: + with sqlite3.connect(self._db_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(query, params or tuple()) + return cursor.fetchall() + + +def _parse_date(value: str | bytes) -> date: + text = value.decode() if isinstance(value, bytes) else str(value) + return date.fromisoformat(text) diff --git a/src/study_dashboard/view_models/__init__.py b/src/study_dashboard/view_models/__init__.py new file mode 100644 index 0000000..96a5b1e --- /dev/null +++ b/src/study_dashboard/view_models/__init__.py @@ -0,0 +1,5 @@ +"""ViewModels exposed by the study_dashboard package.""" + +from .main_view_model import MainViewModel + +__all__ = ["MainViewModel"] diff --git a/src/study_dashboard/view_models/main_view_model.py b/src/study_dashboard/view_models/main_view_model.py new file mode 100644 index 0000000..67920a3 --- /dev/null +++ b/src/study_dashboard/view_models/main_view_model.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from PySide6.QtCore import QObject, Signal + +from ..services import ( + AppointmentRecord, + ExamRecord, + ModuleRecord, + StudyRepository, +) + + +class MainViewModel(QObject): + """ViewModel that exposes study progress data for the dashboard view.""" + + modules_changed = Signal(list) + exams_changed = Signal(list) + appointments_changed = Signal(list) + + def __init__(self, repository: StudyRepository) -> None: + super().__init__() + self._repository = repository + + def load(self) -> None: + """Load the initial data set for the dashboard.""" + self._emit_all() + + def refresh(self) -> None: + """Public command that can be triggered from the UI to reload data.""" + self._emit_all() + + def _emit_all(self) -> None: + modules = [ + self._module_to_view_data(module) + for module in self._repository.list_modules() + ] + exams = [ + self._exam_to_view_data(exam) + for exam in self._repository.list_upcoming_exams() + ] + appointments = [ + self._appointment_to_view_data(appointment) + for appointment in self._repository.list_upcoming_appointments() + ] + + self.modules_changed.emit(modules) + self.exams_changed.emit(exams) + self.appointments_changed.emit(appointments) + + @staticmethod + def _module_to_view_data(module: ModuleRecord) -> dict[str, Any]: + return { + "module_id": module.module_id, + "title": module.title, + "credit_points": module.credit_points, + "status": module.status, + "progress": module.progress_percent, + } + + @staticmethod + def _exam_to_view_data(exam: ExamRecord) -> dict[str, Any]: + return { + "exam_id": exam.exam_id, + "module_id": exam.module_id, + "title": exam.title, + "date": MainViewModel._format_date(exam.exam_date), + "status": exam.status, + } + + @staticmethod + def _appointment_to_view_data( + appointment: AppointmentRecord, + ) -> dict[str, Any]: + return { + "appointment_id": appointment.appointment_id, + "title": appointment.title, + "date": MainViewModel._format_date(appointment.start_date), + "description": appointment.description, + } + + @staticmethod + def _format_date(value: date) -> str: + return value.strftime("%d.%m.%Y") diff --git a/src/study_dashboard/views/__init__.py b/src/study_dashboard/views/__init__.py new file mode 100644 index 0000000..7e87937 --- /dev/null +++ b/src/study_dashboard/views/__init__.py @@ -0,0 +1,5 @@ +"""Views exposed by the study_dashboard package.""" + +from .main_view import MainView + +__all__ = ["MainView"] diff --git a/src/study_dashboard/views/main_view.py b/src/study_dashboard/views/main_view.py new file mode 100644 index 0000000..c5c0371 --- /dev/null +++ b/src/study_dashboard/views/main_view.py @@ -0,0 +1,110 @@ +"""PySide6 dashboard main window bound to the ViewModel layer.""" + +from typing import Any + +from PySide6.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QListWidget, + QMainWindow, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ..view_models import MainViewModel + + +class MainView(QMainWindow): + def __init__(self, view_model: MainViewModel) -> None: + super().__init__() + self._view_model = view_model + self._module_list = QListWidget() + self._exam_list = QListWidget() + self._appointment_list = QListWidget() + self._refresh_button = QPushButton("Aktualisieren") + + self._initialize_window() + self._setup_ui() + self._connect_signals() + + self._view_model.load() + + def _initialize_window(self) -> None: + self.setWindowTitle("Studien-Dashboard") + self.resize(1024, 640) + + def _setup_ui(self) -> None: + central_widget = QWidget() + main_layout = QVBoxLayout() + + header_layout = QHBoxLayout() + header_label = QLabel("Dein Studienfortschritt") + header_label.setStyleSheet("font-size: 20px; font-weight: bold;") + header_layout.addWidget(header_label) + header_layout.addStretch(1) + header_layout.addWidget(self._refresh_button) + + content_layout = QHBoxLayout() + module_section = self._create_section( + "Module", + self._module_list, + ) + exams_section = self._create_section( + "Prüfungen", + self._exam_list, + ) + calendar_section = self._create_section( + "Kalender", + self._appointment_list, + ) + + right_column = QVBoxLayout() + right_column.addWidget(exams_section) + right_column.addWidget(calendar_section) + + content_layout.addWidget(module_section, stretch=2) + content_layout.addLayout(right_column, stretch=1) + + main_layout.addLayout(header_layout) + main_layout.addLayout(content_layout) + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + def _create_section(self, title: str, widget: QWidget) -> QGroupBox: + section = QGroupBox(title) + layout = QVBoxLayout() + layout.addWidget(widget) + section.setLayout(layout) + return section + + def _connect_signals(self) -> None: + self._refresh_button.clicked.connect(self._view_model.refresh) + self._view_model.modules_changed.connect(self._update_modules) + self._view_model.exams_changed.connect(self._update_exams) + self._view_model.appointments_changed.connect(self._update_appointments) + + def _update_modules(self, modules: list[dict[str, Any]]) -> None: + self._module_list.clear() + for module in modules: + entry = ( + f"{module['title']} ({module['credit_points']} ECTS) – " + f"{module['status']} · {module['progress']}%" + ) + self._module_list.addItem(entry) + + def _update_exams(self, exams: list[dict[str, Any]]) -> None: + self._exam_list.clear() + for exam in exams: + entry = f"{exam['title']} · {exam['date']} · " f"{exam['status']}" + self._exam_list.addItem(entry) + + def _update_appointments(self, appointments: list[dict[str, Any]]) -> None: + self._appointment_list.clear() + for appointment in appointments: + entry = ( + f"{appointment['date']} – {appointment['title']}\n" + f"{appointment['description']}" + ) + self._appointment_list.addItem(entry) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..00aadb5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import sys +from pathlib import Path + +# Ensure the src/ directory is importable when running pytest +# without installing the package first. +SRC_DIR = Path(__file__).resolve().parents[1] / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) diff --git a/tests/test_services.py b/tests/test_services.py index e69de29..1b90ed1 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -0,0 +1,48 @@ +from pathlib import Path + +from study_dashboard.services.database import ( + DatabaseBootstrapper, + DatabaseConfig, +) +from study_dashboard.services.sqlite_repository import SQLiteStudyRepository + + +def create_bootstrapped_repo(tmp_path) -> SQLiteStudyRepository: + db_path = Path(tmp_path) / "study.db" + bootstrapper = DatabaseBootstrapper(DatabaseConfig(db_path=db_path)) + bootstrapper.initialize() + return SQLiteStudyRepository(db_path) + + +def test_bootstrap_creates_demo_modules(tmp_path): + repo = create_bootstrapped_repo(tmp_path) + + modules = repo.list_modules() + + assert len(modules) >= 3 + module_codes = {module.module_id for module in modules} + assert module_codes >= {"MAT101", "CS201", "ENG150"} + + +def test_bootstrap_is_idempotent(tmp_path): + db_path = Path(tmp_path) / "study.db" + bootstrapper = DatabaseBootstrapper(DatabaseConfig(db_path=db_path)) + + bootstrapper.initialize() + # running twice must not raise nor duplicate rows + bootstrapper.initialize() + + repo = SQLiteStudyRepository(db_path) + + exams = repo.list_upcoming_exams() + assert len(exams) >= 2 + assert exams[0].exam_date <= exams[1].exam_date + + +def test_calendar_tile_lists_upcoming_appointments(tmp_path): + repo = create_bootstrapped_repo(tmp_path) + + appointments = repo.list_upcoming_appointments(limit=1) + + assert len(appointments) == 1 + assert appointments[0].title in {"Mentoring", "Lerngruppe"} diff --git a/tests/test_view_model.py b/tests/test_view_model.py new file mode 100644 index 0000000..7e02be5 --- /dev/null +++ b/tests/test_view_model.py @@ -0,0 +1,117 @@ +from datetime import date + +import pytest +from PySide6.QtCore import QCoreApplication + +from study_dashboard.view_models import MainViewModel +from study_dashboard.services.models import ( + AppointmentRecord, + ExamRecord, + ModuleRecord, +) +from study_dashboard.services.repository import StudyRepository + + +class FakeRepository(StudyRepository): + def __init__(self): + self.modules = [ + ModuleRecord( + module_id="DEMO-1", + title="Demo Modul", + credit_points=5, + status="In Bearbeitung", + progress_percent=60, + ) + ] + self.exams = [ + ExamRecord( + exam_id="EXAM-1", + module_id="DEMO-1", + title="Demo Klausur", + exam_date=date(2025, 12, 31), + status="Geplant", + ) + ] + self.appointments = [ + AppointmentRecord( + appointment_id="APPT-1", + title="Mentoring", + start_date=date(2025, 11, 30), + description="Mock Termin", + ) + ] + + def list_modules(self): + return list(self.modules) + + def list_upcoming_exams(self, limit: int = 5): + return list(self.exams)[:limit] + + def list_upcoming_appointments(self, limit: int = 5): + return list(self.appointments)[:limit] + + +@pytest.fixture(scope="module", autouse=True) +def qt_core_app(): + app = QCoreApplication.instance() + if app is None: + app = QCoreApplication([]) + yield app + + +def _connect_collectors(view_model): + payloads = {"modules": [], "exams": [], "appointments": []} + + view_model.modules_changed.connect( + lambda payload, key="modules": payloads[key].append(payload) + ) + view_model.exams_changed.connect( + lambda payload, key="exams": payloads[key].append(payload) + ) + view_model.appointments_changed.connect( + lambda payload, key="appointments": payloads[key].append(payload) + ) + + return payloads + + +def test_view_model_emits_structured_lists(): + repo = FakeRepository() + view_model = MainViewModel(repo) + captured = _connect_collectors(view_model) + + view_model.load() + + modules_payload = captured["modules"][-1] + exams_payload = captured["exams"][-1] + appointments_payload = captured["appointments"][-1] + + assert modules_payload[0]["module_id"] == "DEMO-1" + assert modules_payload[0]["progress"] == 60 + assert exams_payload[0]["date"] == "31.12.2025" + assert appointments_payload[0]["title"] == "Mentoring" + + +def test_refresh_emits_latest_snapshot(): + repo = FakeRepository() + view_model = MainViewModel(repo) + modules_payloads = [] + view_model.modules_changed.connect(modules_payloads.append) + + view_model.load() + + repo.modules = [ + ModuleRecord( + module_id="DEMO-2", + title="Neues Modul", + credit_points=10, + status="Fast fertig", + progress_percent=90, + ) + ] + + view_model.refresh() + + latest_snapshot = modules_payloads[-1] + assert latest_snapshot[0]["module_id"] == "DEMO-2" + assert latest_snapshot[0]["progress"] == 90