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/
|
||||
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
|
||||
|
||||
68
README.md
68
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.
|
||||
|
||||
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"
|
||||
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"]
|
||||
|
||||
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