Refactor: Project structure to MVVM. Add: Basic scaffolding for data service and gui. Add: Build and Utility scripts.
This commit is contained in:
parent
204f50f2df
commit
1695590346
3
.flake8
Normal file
3
.flake8
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[flake8]
|
||||||
|
max-line-length = 88
|
||||||
|
extend-ignore = E203, W503
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -14,6 +14,7 @@ dist/
|
|||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
|
bin/
|
||||||
lib/
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
@ -187,6 +188,7 @@ cython_debug/
|
|||||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
# 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
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.github/
|
||||||
|
|
||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
@ -240,3 +242,6 @@ object_script.*.Debug
|
|||||||
*.prl
|
*.prl
|
||||||
*.qmlc
|
*.qmlc
|
||||||
*.jsc
|
*.jsc
|
||||||
|
|
||||||
|
# SQLite
|
||||||
|
study.db
|
||||||
|
|||||||
68
README.md
68
README.md
@ -1,31 +1,63 @@
|
|||||||
# Study Dashboard
|
# Study Dashboard
|
||||||
|
|
||||||
## Pakete
|
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.
|
||||||
- PySide6
|
|
||||||
- dependency-injector
|
|
||||||
|
|
||||||
|
> **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
|
```bash
|
||||||
# Projekt herunterladen
|
# Clone the project
|
||||||
git clone https://git.ghostnet.selfhost.eu/spektr/study-dashboard.git
|
git clone https://git.ghostnet.selfhost.eu/spektr/study-dashboard.git
|
||||||
cd study-dashboard
|
cd study-dashboard
|
||||||
|
|
||||||
# Virtuelle Umgebung erstellen
|
# Create/refresh the virtual environment and install requirements
|
||||||
python -m venv .venv
|
bash scripts/bootstrap.sh
|
||||||
|
|
||||||
# Aktivieren (Linux/macOS)
|
|
||||||
source .venv/bin/activate
|
|
||||||
|
|
||||||
# Aktivieren (Windows)
|
|
||||||
.venv\Scripts\activate
|
|
||||||
|
|
||||||
# Abhängigkeiten aktualisieren
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```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.
|
||||||
|
|||||||
34
docs/agent_instructions.md
Normal file
34
docs/agent_instructions.md
Normal file
@ -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.
|
||||||
35
docs/architecture.md
Normal file
35
docs/architecture.md
Normal file
@ -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.
|
||||||
@ -7,18 +7,15 @@ name = "study-dashboard"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Dashboard zur Nachverfolgung des Studienfortschritts"
|
description = "Dashboard zur Nachverfolgung des Studienfortschritts"
|
||||||
authors = [
|
authors = [
|
||||||
{
|
{ name = "Marcel König", email = "marcel.koenig@iu-study.org" }
|
||||||
name = "Marcel König",
|
|
||||||
email = "marcel.koenig@iu-study.org"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PySide6 >= 6.4.0",
|
"PySide6 >= 6.4.0",
|
||||||
"dependency-injector >= 4.41.0"
|
"dependency-injector >= 4.41.0"
|
||||||
]
|
]
|
||||||
required-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|
||||||
[project.optional.dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest >= 7.0",
|
"pytest >= 7.0",
|
||||||
"black >= 23.0",
|
"black >= 23.0",
|
||||||
@ -27,4 +24,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
study-dashboard = "study-dashboard.main:main"
|
study-dashboard = "study_dashboard.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|||||||
14
scripts/activate-pycache.sh
Executable file
14
scripts/activate-pycache.sh
Executable file
@ -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}"
|
||||||
35
scripts/bootstrap.sh
Executable file
35
scripts/bootstrap.sh
Executable file
@ -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
|
||||||
34
scripts/build.sh
Executable file
34
scripts/build.sh
Executable file
@ -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
|
||||||
44
scripts/clean.sh
Executable file
44
scripts/clean.sh
Executable file
@ -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
|
||||||
22
scripts/run-app.sh
Executable file
22
scripts/run-app.sh
Executable file
@ -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 "$@"
|
||||||
27
scripts/run-qa.sh
Executable file
27
scripts/run-qa.sh
Executable file
@ -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
|
||||||
@ -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)
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
|
|
||||||
@ -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()
|
|
||||||
|
|
||||||
@ -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
|
|
||||||
|
|
||||||
33
src/study_dashboard/di_container.py
Normal file
33
src/study_dashboard/di_container.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
28
src/study_dashboard/main.py
Normal file
28
src/study_dashboard/main.py
Normal file
@ -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()
|
||||||
16
src/study_dashboard/services/__init__.py
Normal file
16
src/study_dashboard/services/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
||||||
133
src/study_dashboard/services/database.py
Normal file
133
src/study_dashboard/services/database.py
Normal file
@ -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)
|
||||||
30
src/study_dashboard/services/models.py
Normal file
30
src/study_dashboard/services/models.py
Normal file
@ -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
|
||||||
9
src/study_dashboard/services/models/__init__.py
Normal file
9
src/study_dashboard/services/models/__init__.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from .appointment_record import AppointmentRecord
|
||||||
|
from .exam_record import ExamRecord
|
||||||
|
from .module_record import ModuleRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppointmentRecord",
|
||||||
|
"ExamRecord",
|
||||||
|
"ModuleRecord",
|
||||||
|
]
|
||||||
15
src/study_dashboard/services/models/appointment_record.py
Normal file
15
src/study_dashboard/services/models/appointment_record.py
Normal file
@ -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"]
|
||||||
16
src/study_dashboard/services/models/exam_record.py
Normal file
16
src/study_dashboard/services/models/exam_record.py
Normal file
@ -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"]
|
||||||
15
src/study_dashboard/services/models/module_record.py
Normal file
15
src/study_dashboard/services/models/module_record.py
Normal file
@ -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"]
|
||||||
28
src/study_dashboard/services/repository.py
Normal file
28
src/study_dashboard/services/repository.py
Normal file
@ -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
|
||||||
82
src/study_dashboard/services/sqlite_repository.py
Normal file
82
src/study_dashboard/services/sqlite_repository.py
Normal file
@ -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)
|
||||||
5
src/study_dashboard/view_models/__init__.py
Normal file
5
src/study_dashboard/view_models/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""ViewModels exposed by the study_dashboard package."""
|
||||||
|
|
||||||
|
from .main_view_model import MainViewModel
|
||||||
|
|
||||||
|
__all__ = ["MainViewModel"]
|
||||||
86
src/study_dashboard/view_models/main_view_model.py
Normal file
86
src/study_dashboard/view_models/main_view_model.py
Normal file
@ -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")
|
||||||
5
src/study_dashboard/views/__init__.py
Normal file
5
src/study_dashboard/views/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Views exposed by the study_dashboard package."""
|
||||||
|
|
||||||
|
from .main_view import MainView
|
||||||
|
|
||||||
|
__all__ = ["MainView"]
|
||||||
110
src/study_dashboard/views/main_view.py
Normal file
110
src/study_dashboard/views/main_view.py
Normal file
@ -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)
|
||||||
8
tests/conftest.py
Normal file
8
tests/conftest.py
Normal file
@ -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))
|
||||||
@ -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"}
|
||||||
117
tests/test_view_model.py
Normal file
117
tests/test_view_model.py
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user