Refactor: Project structure to MVVM. Add: Basic scaffolding for data service and gui. Add: Build and Utility scripts.

This commit is contained in:
ghost 2025-12-06 17:39:27 +01:00
parent 204f50f2df
commit 1695590346
37 changed files with 1094 additions and 121 deletions

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
max-line-length = 88
extend-ignore = E203, W503

5
.gitignore vendored
View File

@ -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

View File

@ -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.

View 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
View 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.

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View 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,
)

View 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()

View 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",
]

View 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)

View 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

View File

@ -0,0 +1,9 @@
from .appointment_record import AppointmentRecord
from .exam_record import ExamRecord
from .module_record import ModuleRecord
__all__ = [
"AppointmentRecord",
"ExamRecord",
"ModuleRecord",
]

View 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"]

View 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"]

View 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"]

View 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

View 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)

View File

@ -0,0 +1,5 @@
"""ViewModels exposed by the study_dashboard package."""
from .main_view_model import MainViewModel
__all__ = ["MainViewModel"]

View 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")

View File

@ -0,0 +1,5 @@
"""Views exposed by the study_dashboard package."""
from .main_view import MainView
__all__ = ["MainView"]

View 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
View 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))

View File

@ -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
View 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