Examtracker 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examtracker-1.1.0/LICENSE +21 -0
- examtracker-1.1.0/PKG-INFO +159 -0
- examtracker-1.1.0/README.md +120 -0
- examtracker-1.1.0/pyproject.toml +31 -0
- examtracker-1.1.0/requirements.txt +7 -0
- examtracker-1.1.0/setup.cfg +4 -0
- examtracker-1.1.0/src/Examtracker.egg-info/PKG-INFO +159 -0
- examtracker-1.1.0/src/Examtracker.egg-info/SOURCES.txt +23 -0
- examtracker-1.1.0/src/Examtracker.egg-info/dependency_links.txt +1 -0
- examtracker-1.1.0/src/Examtracker.egg-info/entry_points.txt +2 -0
- examtracker-1.1.0/src/Examtracker.egg-info/requires.txt +7 -0
- examtracker-1.1.0/src/Examtracker.egg-info/top_level.txt +1 -0
- examtracker-1.1.0/src/examtracker/__init__.py +0 -0
- examtracker-1.1.0/src/examtracker/__main__.py +4 -0
- examtracker-1.1.0/src/examtracker/app.py +39 -0
- examtracker-1.1.0/src/examtracker/data/style.css +30 -0
- examtracker-1.1.0/src/examtracker/database.py +111 -0
- examtracker-1.1.0/src/examtracker/database_scheme.py +59 -0
- examtracker-1.1.0/src/examtracker/main.py +17 -0
- examtracker-1.1.0/src/examtracker/py.typed +0 -0
- examtracker-1.1.0/src/examtracker/screens/classscreen.py +123 -0
- examtracker-1.1.0/src/examtracker/screens/examscreen.py +209 -0
- examtracker-1.1.0/src/examtracker/screens/semesterscreen.py +108 -0
- examtracker-1.1.0/src/examtracker/settings.py +92 -0
- examtracker-1.1.0/src/examtracker/textual_utils/vimtable.py +11 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samuel Huwiler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Examtracker
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: A Python exam tracker for the Lernphase .It allows you to keep track of all exams you have completed and the scores you achieved.
|
|
5
|
+
Author-email: Samuel Huwiler <samuel.huwiler@gmx.ch>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Samuel Huwiler
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Requires-Python: >=3.13
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: textual
|
|
32
|
+
Requires-Dist: sqlalchemy
|
|
33
|
+
Requires-Dist: flask-sqlalchemy
|
|
34
|
+
Requires-Dist: pydantic
|
|
35
|
+
Requires-Dist: pydantic-settings
|
|
36
|
+
Requires-Dist: pathlib
|
|
37
|
+
Requires-Dist: pyyaml
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
[](https://choosealicense.com/licenses/mit/)
|
|
41
|
+
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
# Examtracker
|
|
45
|
+
|
|
46
|
+
A Python exam tracker for the **"Lernphase"**.
|
|
47
|
+
It allows you to keep track of all exams you have completed and the scores you achieved.
|
|
48
|
+
|
|
49
|
+
The application uses:
|
|
50
|
+
|
|
51
|
+
- SQLAlchemy + SQLite for data storage
|
|
52
|
+
- Textual as the TUI backend
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# Installation
|
|
57
|
+
|
|
58
|
+
### Clone the repository
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/Samhuw8a/Examtracker.git
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Install the project
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd Examtracker
|
|
68
|
+
python -m pip install -e .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> You might need to add the `--break-system-packages` flag to the install command
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# Usage
|
|
76
|
+
|
|
77
|
+
Once installed, you can start the program with:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
examtracker
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# Configuration
|
|
86
|
+
|
|
87
|
+
You can change the location of the database file using a configuration file.
|
|
88
|
+
|
|
89
|
+
By default, the program searches for:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
~/.config/examtracker/config.yml
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Default `config.yml`
|
|
98
|
+
|
|
99
|
+
<details>
|
|
100
|
+
<summary>default configuration</summary>
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
database_path: "<path to repo>/data/test.db"
|
|
104
|
+
css_path: "<path to repo>/data/style.css"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
</details>
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Environment Variables
|
|
112
|
+
|
|
113
|
+
All configuration options can also be set using environment variables.
|
|
114
|
+
|
|
115
|
+
You can even change the location where the program searches for the configuration file.
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>Environment variables</summary>
|
|
119
|
+
|
|
120
|
+
```env
|
|
121
|
+
EXAMTRACKER_DATABASE_PATH=/tmp/test.db
|
|
122
|
+
EXAMTRACKER_CSS_PATH=/tmp/style.css
|
|
123
|
+
EXAMTRACKER_CONFIG=/tmp/config.yml
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
</details>
|
|
127
|
+
|
|
128
|
+
Environment variables override values defined in `config.yml`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# Database Schema
|
|
133
|
+
|
|
134
|
+
### `exams`
|
|
135
|
+
- `id`
|
|
136
|
+
- `name`
|
|
137
|
+
- `max_points`
|
|
138
|
+
- `scored_points`
|
|
139
|
+
- `class_id`
|
|
140
|
+
|
|
141
|
+
### `classes`
|
|
142
|
+
- `id`
|
|
143
|
+
- `name`
|
|
144
|
+
- `semester_id`
|
|
145
|
+
|
|
146
|
+
### `semester`
|
|
147
|
+
- `id`
|
|
148
|
+
- `name` (unique)
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# TODO
|
|
153
|
+
|
|
154
|
+
- [x] Handle SQL errors
|
|
155
|
+
- [x] Initialize database
|
|
156
|
+
- [x] Configuration file support
|
|
157
|
+
- [x] Abort edit and add screens
|
|
158
|
+
- [x] Cross-platform config discovery
|
|
159
|
+
- [ ] Improve CSS
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
[](https://choosealicense.com/licenses/mit/)
|
|
2
|
+
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
# Examtracker
|
|
6
|
+
|
|
7
|
+
A Python exam tracker for the **"Lernphase"**.
|
|
8
|
+
It allows you to keep track of all exams you have completed and the scores you achieved.
|
|
9
|
+
|
|
10
|
+
The application uses:
|
|
11
|
+
|
|
12
|
+
- SQLAlchemy + SQLite for data storage
|
|
13
|
+
- Textual as the TUI backend
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# Installation
|
|
18
|
+
|
|
19
|
+
### Clone the repository
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git clone https://github.com/Samhuw8a/Examtracker.git
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Install the project
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cd Examtracker
|
|
29
|
+
python -m pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> You might need to add the `--break-system-packages` flag to the install command
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Usage
|
|
37
|
+
|
|
38
|
+
Once installed, you can start the program with:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
examtracker
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# Configuration
|
|
47
|
+
|
|
48
|
+
You can change the location of the database file using a configuration file.
|
|
49
|
+
|
|
50
|
+
By default, the program searches for:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
~/.config/examtracker/config.yml
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Default `config.yml`
|
|
59
|
+
|
|
60
|
+
<details>
|
|
61
|
+
<summary>default configuration</summary>
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
database_path: "<path to repo>/data/test.db"
|
|
65
|
+
css_path: "<path to repo>/data/style.css"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
</details>
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Environment Variables
|
|
73
|
+
|
|
74
|
+
All configuration options can also be set using environment variables.
|
|
75
|
+
|
|
76
|
+
You can even change the location where the program searches for the configuration file.
|
|
77
|
+
|
|
78
|
+
<details>
|
|
79
|
+
<summary>Environment variables</summary>
|
|
80
|
+
|
|
81
|
+
```env
|
|
82
|
+
EXAMTRACKER_DATABASE_PATH=/tmp/test.db
|
|
83
|
+
EXAMTRACKER_CSS_PATH=/tmp/style.css
|
|
84
|
+
EXAMTRACKER_CONFIG=/tmp/config.yml
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
</details>
|
|
88
|
+
|
|
89
|
+
Environment variables override values defined in `config.yml`.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
# Database Schema
|
|
94
|
+
|
|
95
|
+
### `exams`
|
|
96
|
+
- `id`
|
|
97
|
+
- `name`
|
|
98
|
+
- `max_points`
|
|
99
|
+
- `scored_points`
|
|
100
|
+
- `class_id`
|
|
101
|
+
|
|
102
|
+
### `classes`
|
|
103
|
+
- `id`
|
|
104
|
+
- `name`
|
|
105
|
+
- `semester_id`
|
|
106
|
+
|
|
107
|
+
### `semester`
|
|
108
|
+
- `id`
|
|
109
|
+
- `name` (unique)
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
# TODO
|
|
114
|
+
|
|
115
|
+
- [x] Handle SQL errors
|
|
116
|
+
- [x] Initialize database
|
|
117
|
+
- [x] Configuration file support
|
|
118
|
+
- [x] Abort edit and add screens
|
|
119
|
+
- [x] Cross-platform config discovery
|
|
120
|
+
- [ ] Improve CSS
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# A list of packages that are needed to build your package:
|
|
3
|
+
requires = ["setuptools"] # REQUIRED if [build-system] table is used
|
|
4
|
+
# The name of the Python object that frontends will use to perform the build:
|
|
5
|
+
build-backend = "setuptools.build_meta" # If not defined, then legacy behavior can happen.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "Examtracker" # REQUIRED, is the only field that cannot be marked as dynamic.
|
|
10
|
+
version = "1.1.0" # REQUIRED, although can be dynamic
|
|
11
|
+
description = "A Python exam tracker for the Lernphase .It allows you to keep track of all exams you have completed and the scores you achieved."
|
|
12
|
+
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
|
|
15
|
+
requires-python = ">=3.13"
|
|
16
|
+
license = { file = "LICENSE" }
|
|
17
|
+
|
|
18
|
+
authors = [{ name = "Samuel Huwiler", email = "samuel.huwiler@gmx.ch" }]
|
|
19
|
+
|
|
20
|
+
dynamic = ["dependencies"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.dynamic]
|
|
23
|
+
dependencies = {file = ["requirements.txt"]}
|
|
24
|
+
optional-dependencies = {dev = { file = ["dev_requirements.txt"] }}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
examtracker = ["data/*"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
examtracker = "examtracker.__main__:main"
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Examtracker
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: A Python exam tracker for the Lernphase .It allows you to keep track of all exams you have completed and the scores you achieved.
|
|
5
|
+
Author-email: Samuel Huwiler <samuel.huwiler@gmx.ch>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Samuel Huwiler
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Requires-Python: >=3.13
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: textual
|
|
32
|
+
Requires-Dist: sqlalchemy
|
|
33
|
+
Requires-Dist: flask-sqlalchemy
|
|
34
|
+
Requires-Dist: pydantic
|
|
35
|
+
Requires-Dist: pydantic-settings
|
|
36
|
+
Requires-Dist: pathlib
|
|
37
|
+
Requires-Dist: pyyaml
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
[](https://choosealicense.com/licenses/mit/)
|
|
41
|
+
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
# Examtracker
|
|
45
|
+
|
|
46
|
+
A Python exam tracker for the **"Lernphase"**.
|
|
47
|
+
It allows you to keep track of all exams you have completed and the scores you achieved.
|
|
48
|
+
|
|
49
|
+
The application uses:
|
|
50
|
+
|
|
51
|
+
- SQLAlchemy + SQLite for data storage
|
|
52
|
+
- Textual as the TUI backend
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
# Installation
|
|
57
|
+
|
|
58
|
+
### Clone the repository
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/Samhuw8a/Examtracker.git
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Install the project
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd Examtracker
|
|
68
|
+
python -m pip install -e .
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
> You might need to add the `--break-system-packages` flag to the install command
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# Usage
|
|
76
|
+
|
|
77
|
+
Once installed, you can start the program with:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
examtracker
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
# Configuration
|
|
86
|
+
|
|
87
|
+
You can change the location of the database file using a configuration file.
|
|
88
|
+
|
|
89
|
+
By default, the program searches for:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
~/.config/examtracker/config.yml
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Default `config.yml`
|
|
98
|
+
|
|
99
|
+
<details>
|
|
100
|
+
<summary>default configuration</summary>
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
database_path: "<path to repo>/data/test.db"
|
|
104
|
+
css_path: "<path to repo>/data/style.css"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
</details>
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Environment Variables
|
|
112
|
+
|
|
113
|
+
All configuration options can also be set using environment variables.
|
|
114
|
+
|
|
115
|
+
You can even change the location where the program searches for the configuration file.
|
|
116
|
+
|
|
117
|
+
<details>
|
|
118
|
+
<summary>Environment variables</summary>
|
|
119
|
+
|
|
120
|
+
```env
|
|
121
|
+
EXAMTRACKER_DATABASE_PATH=/tmp/test.db
|
|
122
|
+
EXAMTRACKER_CSS_PATH=/tmp/style.css
|
|
123
|
+
EXAMTRACKER_CONFIG=/tmp/config.yml
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
</details>
|
|
127
|
+
|
|
128
|
+
Environment variables override values defined in `config.yml`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
# Database Schema
|
|
133
|
+
|
|
134
|
+
### `exams`
|
|
135
|
+
- `id`
|
|
136
|
+
- `name`
|
|
137
|
+
- `max_points`
|
|
138
|
+
- `scored_points`
|
|
139
|
+
- `class_id`
|
|
140
|
+
|
|
141
|
+
### `classes`
|
|
142
|
+
- `id`
|
|
143
|
+
- `name`
|
|
144
|
+
- `semester_id`
|
|
145
|
+
|
|
146
|
+
### `semester`
|
|
147
|
+
- `id`
|
|
148
|
+
- `name` (unique)
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
# TODO
|
|
153
|
+
|
|
154
|
+
- [x] Handle SQL errors
|
|
155
|
+
- [x] Initialize database
|
|
156
|
+
- [x] Configuration file support
|
|
157
|
+
- [x] Abort edit and add screens
|
|
158
|
+
- [x] Cross-platform config discovery
|
|
159
|
+
- [ ] Improve CSS
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
requirements.txt
|
|
5
|
+
src/Examtracker.egg-info/PKG-INFO
|
|
6
|
+
src/Examtracker.egg-info/SOURCES.txt
|
|
7
|
+
src/Examtracker.egg-info/dependency_links.txt
|
|
8
|
+
src/Examtracker.egg-info/entry_points.txt
|
|
9
|
+
src/Examtracker.egg-info/requires.txt
|
|
10
|
+
src/Examtracker.egg-info/top_level.txt
|
|
11
|
+
src/examtracker/__init__.py
|
|
12
|
+
src/examtracker/__main__.py
|
|
13
|
+
src/examtracker/app.py
|
|
14
|
+
src/examtracker/database.py
|
|
15
|
+
src/examtracker/database_scheme.py
|
|
16
|
+
src/examtracker/main.py
|
|
17
|
+
src/examtracker/py.typed
|
|
18
|
+
src/examtracker/settings.py
|
|
19
|
+
src/examtracker/data/style.css
|
|
20
|
+
src/examtracker/screens/classscreen.py
|
|
21
|
+
src/examtracker/screens/examscreen.py
|
|
22
|
+
src/examtracker/screens/semesterscreen.py
|
|
23
|
+
src/examtracker/textual_utils/vimtable.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
examtracker
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from sqlalchemy import Engine # type:ignore
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
from textual.app import App, ComposeResult
|
|
4
|
+
|
|
5
|
+
from examtracker.screens.semesterscreen import SemesterScreen
|
|
6
|
+
from examtracker.settings import Settings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExamTracker(App):
|
|
10
|
+
BINDINGS = [
|
|
11
|
+
("q", "quit", "Quit"),
|
|
12
|
+
("ctrl+c", "quit"),
|
|
13
|
+
]
|
|
14
|
+
# CSS_PATH = "/Users/samuel/Repositories/Examtracker/data/style.css"
|
|
15
|
+
|
|
16
|
+
def compose(self) -> ComposeResult:
|
|
17
|
+
yield SemesterScreen()
|
|
18
|
+
|
|
19
|
+
def on_mount(self) -> None:
|
|
20
|
+
self.push_screen(SemesterScreen())
|
|
21
|
+
self.stylesheet.read(self.config.css_path)
|
|
22
|
+
|
|
23
|
+
def __init__(self, db_engine: Engine, config: Settings, **kwargs) -> None:
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self.db_session = Session(db_engine)
|
|
26
|
+
self.config = config
|
|
27
|
+
|
|
28
|
+
def quit(self) -> None:
|
|
29
|
+
self.db_session.commit()
|
|
30
|
+
self.db_session.close()
|
|
31
|
+
self.exit()
|
|
32
|
+
|
|
33
|
+
def pop_screen(self):
|
|
34
|
+
# Only pop if there is more than 1 screen (not including the blank screen)
|
|
35
|
+
if len(self.screen_stack) > 2:
|
|
36
|
+
super().pop_screen()
|
|
37
|
+
else:
|
|
38
|
+
# Ignore pop on base screen
|
|
39
|
+
pass
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
DataTable {
|
|
2
|
+
border-title-align: center;
|
|
3
|
+
border-title-style: bold;
|
|
4
|
+
margin: 5;
|
|
5
|
+
padding: 2;
|
|
6
|
+
width: 75%;
|
|
7
|
+
border: white;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
Screen {
|
|
11
|
+
position: relative;
|
|
12
|
+
align: center top;
|
|
13
|
+
margin: 3;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Vertical {
|
|
17
|
+
margin: 5;
|
|
18
|
+
align: center top;
|
|
19
|
+
width:75%;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
Input {
|
|
23
|
+
width: 75%;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Label {
|
|
28
|
+
width: 75%;
|
|
29
|
+
padding: 1;
|
|
30
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Database related functions for getting and adding entries
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import Engine, create_engine, inspect # type:ignore
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
from examtracker.database_scheme import Base, Class, Exam, Semester
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_database_engine(path: str) -> Engine:
|
|
14
|
+
engine = create_engine("sqlite:///" + path, echo=False)
|
|
15
|
+
inspector = inspect(engine)
|
|
16
|
+
|
|
17
|
+
if not inspector.get_table_names():
|
|
18
|
+
create_tables(engine)
|
|
19
|
+
|
|
20
|
+
return engine
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_tables(engine: Engine) -> None:
|
|
24
|
+
Base.metadata.create_all(engine)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_semester_by_name(session: Session, name: str) -> Semester:
|
|
28
|
+
return session.query(Semester).filter_by(name=name).one()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_semester(session: Session, name: str) -> None:
|
|
32
|
+
semester_obj = Semester(name=name)
|
|
33
|
+
session.add(semester_obj)
|
|
34
|
+
session.flush
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_class_by_id(session: Session, class_id: int) -> Class:
|
|
38
|
+
return session.query(Class).filter_by(class_id=class_id).one()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_exam_by_id(session: Session, exam_id: int) -> Exam:
|
|
42
|
+
return session.query(Exam).filter_by(exam_id=exam_id).one()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def add_class_to_semester(
|
|
46
|
+
session: Session, class_name: str, semster_obj: Semester
|
|
47
|
+
) -> None:
|
|
48
|
+
class_obj = Class(name=class_name, semester_id=semster_obj.semester_id)
|
|
49
|
+
session.add(class_obj)
|
|
50
|
+
session.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add_exam_to_class(
|
|
54
|
+
session: Session, name: str, max_points: int, scored_points: int, class_obj: Class
|
|
55
|
+
) -> None:
|
|
56
|
+
exam_obj = Exam(
|
|
57
|
+
name=name,
|
|
58
|
+
max_points=max_points,
|
|
59
|
+
scored_points=scored_points,
|
|
60
|
+
class_id=class_obj.class_id,
|
|
61
|
+
)
|
|
62
|
+
session.add(exam_obj)
|
|
63
|
+
session.flush()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_all_exams_for_class(session: Session, class_obj: Class) -> List[Exam]:
|
|
67
|
+
return session.query(Exam).filter_by(class_id=class_obj.class_id).all()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_all_semester(session: Session) -> List[Semester]:
|
|
71
|
+
return session.query(Semester).all()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_all_classes_for_semester(
|
|
75
|
+
session: Session, semster_obj: Semester
|
|
76
|
+
) -> List[Class]:
|
|
77
|
+
return session.query(Class).filter_by(semester_id=semster_obj.semester_id).all()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def remove_semester_by_name(session: Session, name: str) -> None:
|
|
81
|
+
sem = get_semester_by_name(session, name)
|
|
82
|
+
session.delete(sem) # type:ignore
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def remove_class_by_id(session: Session, id: int) -> None:
|
|
86
|
+
cls = get_class_by_id(session, id)
|
|
87
|
+
session.delete(cls) # type:ignore
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def remove_exam_by_id(session: Session, exam_id: int) -> None:
|
|
91
|
+
exam_obj = get_exam_by_id(session, exam_id)
|
|
92
|
+
session.delete(exam_obj) # type: ignore
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
engine: Engine = create_database_engine("test.db")
|
|
97
|
+
# create_tables(engine)
|
|
98
|
+
session = Session(engine)
|
|
99
|
+
# # add_semester(session, "FS25")
|
|
100
|
+
# # remove_semester_by_name(session, "FS25")
|
|
101
|
+
# # print(get_all_semester(session))
|
|
102
|
+
# sem = get_semester_by_name(session, "HS24")
|
|
103
|
+
# add_class_to_semester(session, "Eprog", sem)
|
|
104
|
+
# # cls = get_class_by_name(session,"Eprog")
|
|
105
|
+
# # print(get_all_exams_for_class(session, cls))
|
|
106
|
+
session.commit()
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Defining the database tables and entries
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import ForeignKey, String
|
|
8
|
+
from sqlalchemy.orm import (
|
|
9
|
+
DeclarativeBase,
|
|
10
|
+
Mapped, # type:ignore
|
|
11
|
+
mapped_column,
|
|
12
|
+
relationship,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Base(DeclarativeBase):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Exam(Base):
|
|
21
|
+
__tablename__ = "exams"
|
|
22
|
+
exam_id: Mapped[int] = mapped_column(primary_key=True)
|
|
23
|
+
name: Mapped[str] = mapped_column(String(30))
|
|
24
|
+
max_points: Mapped[int]
|
|
25
|
+
scored_points: Mapped[int]
|
|
26
|
+
class_id: Mapped[int] = mapped_column(ForeignKey("classes.class_id"))
|
|
27
|
+
class_: Mapped["Class"] = relationship(back_populates="exams") # type:ignore
|
|
28
|
+
|
|
29
|
+
def __repr__(self) -> str:
|
|
30
|
+
return f"ID:{self.exam_id}|name:{self.name}|{self.max_points}|{self.scored_points}|Class:{self.class_id}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Class(Base):
|
|
34
|
+
__tablename__ = "classes"
|
|
35
|
+
class_id: Mapped[int] = mapped_column(primary_key=True)
|
|
36
|
+
name: Mapped[str] = mapped_column(String(30))
|
|
37
|
+
semester_id: Mapped[int] = mapped_column(ForeignKey("semester.semester_id"))
|
|
38
|
+
semester: Mapped["Semester"] = relationship(back_populates="classes") # type:ignore
|
|
39
|
+
|
|
40
|
+
exams: Mapped[List["Exam"]] = relationship(
|
|
41
|
+
back_populates="class_",
|
|
42
|
+
cascade="all, delete-orphan",
|
|
43
|
+
) # type: ignore
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"ID:{self.class_id}|name:{self.name}|Semester:{self.semester_id}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Semester(Base):
|
|
50
|
+
__tablename__ = "semester"
|
|
51
|
+
semester_id: Mapped[int] = mapped_column(primary_key=True)
|
|
52
|
+
name: Mapped[str] = mapped_column(String(30), unique=True)
|
|
53
|
+
classes: Mapped[List["Class"]] = relationship(
|
|
54
|
+
back_populates="semester",
|
|
55
|
+
cascade="all, delete-orphan",
|
|
56
|
+
) # type: ignore
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return f"ID:{self.semester_id}|name:{self.name}"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from examtracker.app import ExamTracker
|
|
4
|
+
from examtracker.database import create_database_engine
|
|
5
|
+
from examtracker.settings import Settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> int:
|
|
9
|
+
config: Settings = Settings()
|
|
10
|
+
db_engine = create_database_engine(config.database_path)
|
|
11
|
+
app = ExamTracker(db_engine, config)
|
|
12
|
+
app.run()
|
|
13
|
+
return 0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from sqlalchemy.exc import IntegrityError
|
|
2
|
+
from sqlalchemy.orm import Session
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult, Screen
|
|
5
|
+
from textual.containers import Vertical
|
|
6
|
+
from textual.widgets import DataTable, Footer, Header, Input, Label
|
|
7
|
+
|
|
8
|
+
from examtracker.database import (
|
|
9
|
+
add_class_to_semester,
|
|
10
|
+
get_all_classes_for_semester,
|
|
11
|
+
get_semester_by_name,
|
|
12
|
+
remove_class_by_id,
|
|
13
|
+
)
|
|
14
|
+
from examtracker.screens.examscreen import ExamScreen
|
|
15
|
+
from examtracker.textual_utils.vimtable import VimTable
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AddClassScreen(Screen):
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("escape", "app.pop_screen", "Cancel"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def __init__(self, semester_name: str, **kwargs):
|
|
24
|
+
super().__init__(**kwargs)
|
|
25
|
+
self.semester_name = semester_name
|
|
26
|
+
self.db_session = self.app.db_session # type: ignore
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
yield Header()
|
|
30
|
+
with Vertical():
|
|
31
|
+
yield Label(f"Add Class to: {self.semester_name}")
|
|
32
|
+
# Inputs for the class
|
|
33
|
+
self.name_input = Input(placeholder="Class Name", id="name")
|
|
34
|
+
yield self.name_input
|
|
35
|
+
yield Footer()
|
|
36
|
+
|
|
37
|
+
def on_mount(self) -> None:
|
|
38
|
+
self.name_input.focus()
|
|
39
|
+
|
|
40
|
+
# Submit on Enter from any Input
|
|
41
|
+
@on(Input.Submitted)
|
|
42
|
+
def submit(self) -> None:
|
|
43
|
+
name = self.name_input.value.strip()
|
|
44
|
+
if not name:
|
|
45
|
+
return # Require class name
|
|
46
|
+
|
|
47
|
+
semester = get_semester_by_name(self.db_session, self.semester_name)
|
|
48
|
+
|
|
49
|
+
# Add the class
|
|
50
|
+
try:
|
|
51
|
+
add_class_to_semester(self.db_session, name, semester) # type: ignore
|
|
52
|
+
self.db_session.commit()
|
|
53
|
+
except IntegrityError:
|
|
54
|
+
# TODO add error message for unique constraint
|
|
55
|
+
self.db_session.rollback()
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
# Pop the screen and return
|
|
59
|
+
self.app.pop_screen()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ClassScreen(Screen):
|
|
63
|
+
"""
|
|
64
|
+
Shows all the classes for a given semester
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
BINDINGS = [
|
|
68
|
+
("escape", "app.pop_screen", "Back"),
|
|
69
|
+
("a", "add", "Add semester"),
|
|
70
|
+
("ctrl+r", "remove", "Remove semester"),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __init__(self, semester_name: str) -> None:
|
|
74
|
+
super().__init__()
|
|
75
|
+
self.db_session: Session = self.app.db_session # type: ignore
|
|
76
|
+
self.semester_name = semester_name
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
yield Header()
|
|
80
|
+
self.class_table: VimTable = VimTable()
|
|
81
|
+
self.class_table.add_columns("ID", "Name")
|
|
82
|
+
self.class_table.cursor_type = "row"
|
|
83
|
+
self.class_table.border_title = f"Classes for: {self.semester_name}"
|
|
84
|
+
yield self.class_table
|
|
85
|
+
yield Footer()
|
|
86
|
+
|
|
87
|
+
def on_mount(self) -> None:
|
|
88
|
+
self.refresh_table()
|
|
89
|
+
|
|
90
|
+
def action_add(self) -> None:
|
|
91
|
+
self.app.push_screen(AddClassScreen(self.semester_name))
|
|
92
|
+
|
|
93
|
+
def action_remove(self) -> None:
|
|
94
|
+
row_index = self.class_table.cursor_row
|
|
95
|
+
if row_index is None:
|
|
96
|
+
return
|
|
97
|
+
if not self.class_table.is_valid_row_index(row_index):
|
|
98
|
+
return
|
|
99
|
+
row = self.class_table.get_row_at(row_index)
|
|
100
|
+
class_id = row[0]
|
|
101
|
+
remove_class_by_id(self.db_session, class_id)
|
|
102
|
+
self.db_session.commit()
|
|
103
|
+
self.refresh_table()
|
|
104
|
+
|
|
105
|
+
def refresh_table(self) -> None:
|
|
106
|
+
semester = get_semester_by_name(self.db_session, self.semester_name)
|
|
107
|
+
|
|
108
|
+
self.class_table.clear()
|
|
109
|
+
for cls in get_all_classes_for_semester(self.db_session, semester):
|
|
110
|
+
self.class_table.add_row(cls.class_id, cls.name)
|
|
111
|
+
|
|
112
|
+
def on_screen_resume(self) -> None:
|
|
113
|
+
# Called when returning from AddSemesterScreen
|
|
114
|
+
self.refresh_table()
|
|
115
|
+
|
|
116
|
+
@on(VimTable.RowSelected)
|
|
117
|
+
def open_exam(self, event: DataTable.RowSelected) -> None:
|
|
118
|
+
row_index = self.class_table.cursor_row
|
|
119
|
+
if row_index is None:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
class_id = self.class_table.get_row_at(row_index)[0]
|
|
123
|
+
self.app.push_screen(ExamScreen(class_id))
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from textual import on
|
|
2
|
+
from textual.app import ComposeResult, Screen
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
from textual.widgets import DataTable, Footer, Header, Input, Label
|
|
5
|
+
|
|
6
|
+
from examtracker.database import (
|
|
7
|
+
add_exam_to_class,
|
|
8
|
+
get_all_exams_for_class,
|
|
9
|
+
get_class_by_id,
|
|
10
|
+
get_exam_by_id,
|
|
11
|
+
remove_exam_by_id,
|
|
12
|
+
)
|
|
13
|
+
from examtracker.textual_utils.vimtable import VimTable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EditExamScreen(Screen):
|
|
17
|
+
BINDINGS = [
|
|
18
|
+
("escape", "app.pop_screen", "Cancel"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def __init__(self, exam_id: int, **kwargs):
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self.exam_id = exam_id
|
|
24
|
+
self.db_session = self.app.db_session # type:ignore
|
|
25
|
+
|
|
26
|
+
def compose(self) -> ComposeResult:
|
|
27
|
+
yield Header()
|
|
28
|
+
with Vertical():
|
|
29
|
+
yield Label("Edit Exam")
|
|
30
|
+
# Inputs for the class
|
|
31
|
+
self.name_input = Input(placeholder="Exam Name", id="name")
|
|
32
|
+
yield self.name_input
|
|
33
|
+
|
|
34
|
+
self.max_input = Input(placeholder="Exam Max Points (optional)", id="max")
|
|
35
|
+
yield self.max_input
|
|
36
|
+
self.score_input = Input(
|
|
37
|
+
placeholder="Exam Scored Points (optional)", id="score"
|
|
38
|
+
)
|
|
39
|
+
yield self.score_input
|
|
40
|
+
|
|
41
|
+
yield Footer()
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
exam = get_exam_by_id(self.db_session, self.exam_id)
|
|
45
|
+
|
|
46
|
+
self.name_input.value = exam.name
|
|
47
|
+
self.max_input.value = str(exam.max_points)
|
|
48
|
+
self.score_input.value = str(exam.scored_points)
|
|
49
|
+
|
|
50
|
+
self.name_input.focus()
|
|
51
|
+
|
|
52
|
+
@on(Input.Submitted)
|
|
53
|
+
def submit(self) -> None:
|
|
54
|
+
name = self.name_input.value.strip()
|
|
55
|
+
if not name:
|
|
56
|
+
return # Require class name
|
|
57
|
+
|
|
58
|
+
# Optional exam points
|
|
59
|
+
try:
|
|
60
|
+
max_points = int(self.max_input.value.strip())
|
|
61
|
+
except ValueError:
|
|
62
|
+
# Ignore invalid numbers for now
|
|
63
|
+
max_points = 0
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
scored_points = int(self.score_input.value.strip())
|
|
67
|
+
except ValueError:
|
|
68
|
+
# Ignore invalid numbers for now
|
|
69
|
+
scored_points = 0
|
|
70
|
+
|
|
71
|
+
exam = get_exam_by_id(self.db_session, self.exam_id)
|
|
72
|
+
exam.name = name
|
|
73
|
+
exam.max_points = max_points
|
|
74
|
+
exam.scored_points = scored_points
|
|
75
|
+
|
|
76
|
+
self.db_session.commit()
|
|
77
|
+
|
|
78
|
+
self.app.pop_screen()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AddExamScreen(Screen):
|
|
82
|
+
BINDINGS = [
|
|
83
|
+
("escape", "app.pop_screen", "Cancel"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def __init__(self, class_id: int, **kwargs):
|
|
87
|
+
super().__init__(**kwargs)
|
|
88
|
+
self.class_id = class_id
|
|
89
|
+
self.db_session = self.app.db_session # type: ignore
|
|
90
|
+
self.class_name = get_class_by_id(self.db_session, self.class_id).name
|
|
91
|
+
|
|
92
|
+
def compose(self) -> ComposeResult:
|
|
93
|
+
yield Header()
|
|
94
|
+
with Vertical():
|
|
95
|
+
yield Label(f"Add Exam to: {self.class_name}")
|
|
96
|
+
# Inputs for the class
|
|
97
|
+
self.name_input = Input(placeholder="Exam Name", id="name")
|
|
98
|
+
yield self.name_input
|
|
99
|
+
|
|
100
|
+
# Optional exam fields
|
|
101
|
+
self.max_input = Input(placeholder="Exam Max Points (optional)", id="max")
|
|
102
|
+
yield self.max_input
|
|
103
|
+
self.score_input = Input(
|
|
104
|
+
placeholder="Exam Scored Points (optional)", id="score"
|
|
105
|
+
)
|
|
106
|
+
yield self.score_input
|
|
107
|
+
|
|
108
|
+
yield Footer()
|
|
109
|
+
|
|
110
|
+
def on_mount(self) -> None:
|
|
111
|
+
self.name_input.focus()
|
|
112
|
+
|
|
113
|
+
@on(Input.Submitted)
|
|
114
|
+
def submit(self) -> None:
|
|
115
|
+
name = self.name_input.value.strip()
|
|
116
|
+
if not name:
|
|
117
|
+
return # Require class name
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
max_points = int(self.max_input.value.strip())
|
|
121
|
+
except ValueError:
|
|
122
|
+
# Ignore invalid numbers for now
|
|
123
|
+
max_points = 0
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
scored_points = int(self.score_input.value.strip())
|
|
127
|
+
except ValueError:
|
|
128
|
+
# Ignore invalid numbers for now
|
|
129
|
+
scored_points = 0
|
|
130
|
+
|
|
131
|
+
class_obj = get_class_by_id(self.db_session, self.class_id)
|
|
132
|
+
|
|
133
|
+
# Add the class
|
|
134
|
+
add_exam_to_class(self.db_session, name, max_points, scored_points, class_obj)
|
|
135
|
+
self.db_session.commit()
|
|
136
|
+
|
|
137
|
+
# Pop the screen and return
|
|
138
|
+
self.app.pop_screen()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ExamScreen(Screen):
|
|
142
|
+
BINDINGS = [
|
|
143
|
+
("escape", "app.pop_screen", "Back"),
|
|
144
|
+
("a", "add", "Add exam"),
|
|
145
|
+
("ctrl+r", "remove", "Remove exam"),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def __init__(self, class_id: int, **kwargs):
|
|
149
|
+
super().__init__(**kwargs)
|
|
150
|
+
self.class_id = class_id
|
|
151
|
+
self.db_session = self.app.db_session # type: ignore
|
|
152
|
+
self.class_name = get_class_by_id(self.db_session, self.class_id).name
|
|
153
|
+
|
|
154
|
+
def compose(self) -> ComposeResult:
|
|
155
|
+
yield Header()
|
|
156
|
+
|
|
157
|
+
self.exam_table: VimTable = VimTable()
|
|
158
|
+
self.exam_table.add_columns("ID", "Name", "Max_points", "Scored_points", "%")
|
|
159
|
+
self.exam_table.cursor_type = "row"
|
|
160
|
+
self.exam_table.border_title = f"Exams completed for: {self.class_name}"
|
|
161
|
+
yield self.exam_table
|
|
162
|
+
|
|
163
|
+
yield Footer()
|
|
164
|
+
|
|
165
|
+
def on_mount(self) -> None:
|
|
166
|
+
self.refresh_table()
|
|
167
|
+
self.exam_table.focus()
|
|
168
|
+
|
|
169
|
+
def action_add(self) -> None:
|
|
170
|
+
self.app.push_screen(AddExamScreen(self.class_id))
|
|
171
|
+
|
|
172
|
+
def action_remove(self) -> None:
|
|
173
|
+
row_index = self.exam_table.cursor_row
|
|
174
|
+
if row_index is None:
|
|
175
|
+
return
|
|
176
|
+
if not self.exam_table.is_valid_row_index(row_index):
|
|
177
|
+
return
|
|
178
|
+
row = self.exam_table.get_row_at(row_index)
|
|
179
|
+
exam_id = row[0]
|
|
180
|
+
remove_exam_by_id(self.db_session, exam_id)
|
|
181
|
+
self.db_session.commit()
|
|
182
|
+
self.refresh_table()
|
|
183
|
+
|
|
184
|
+
def refresh_table(self) -> None:
|
|
185
|
+
class_obj = get_class_by_id(self.db_session, self.class_id)
|
|
186
|
+
|
|
187
|
+
self.exam_table.clear()
|
|
188
|
+
for cls in get_all_exams_for_class(self.db_session, class_obj):
|
|
189
|
+
if cls.max_points == 0:
|
|
190
|
+
proc = 0.0
|
|
191
|
+
else:
|
|
192
|
+
proc = (cls.scored_points / cls.max_points) * 100
|
|
193
|
+
|
|
194
|
+
self.exam_table.add_row(
|
|
195
|
+
cls.exam_id, cls.name, cls.max_points, cls.scored_points, proc
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def on_screen_resume(self) -> None:
|
|
199
|
+
# Called when returning from AddSemesterScreen
|
|
200
|
+
self.refresh_table()
|
|
201
|
+
|
|
202
|
+
@on(VimTable.RowSelected)
|
|
203
|
+
def edit_exam(self, event: DataTable.RowSelected) -> None:
|
|
204
|
+
row_index = self.exam_table.cursor_row
|
|
205
|
+
if row_index is None:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
exam_id = self.exam_table.get_row_at(row_index)[0]
|
|
209
|
+
self.app.push_screen(EditExamScreen(exam_id))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from sqlalchemy.exc import IntegrityError
|
|
2
|
+
from textual import on
|
|
3
|
+
from textual.app import ComposeResult, Screen
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual.widgets import DataTable, Footer, Header, Input, Label
|
|
6
|
+
|
|
7
|
+
from examtracker.database import add_semester, get_all_semester, remove_semester_by_name
|
|
8
|
+
from examtracker.screens.classscreen import ClassScreen
|
|
9
|
+
from examtracker.textual_utils.vimtable import VimTable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AddSemesterScreen(Screen):
|
|
13
|
+
BINDINGS = [
|
|
14
|
+
("escape", "app.pop_screen", "Cancel"),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
super().__init__()
|
|
19
|
+
self.db_session = self.app.db_session # type: ignore
|
|
20
|
+
|
|
21
|
+
def compose(self) -> ComposeResult:
|
|
22
|
+
yield Header()
|
|
23
|
+
with Vertical():
|
|
24
|
+
yield Label("Add new Semester")
|
|
25
|
+
self.input = Input(
|
|
26
|
+
placeholder="Semester name (e.g. Fall 2026)",
|
|
27
|
+
id="semester_name",
|
|
28
|
+
)
|
|
29
|
+
yield self.input
|
|
30
|
+
yield Footer()
|
|
31
|
+
|
|
32
|
+
def on_mount(self) -> None:
|
|
33
|
+
self.input.focus()
|
|
34
|
+
|
|
35
|
+
@on(Input.Submitted)
|
|
36
|
+
def input_submitted(self, event: Input.Submitted) -> None:
|
|
37
|
+
self.submit()
|
|
38
|
+
|
|
39
|
+
def submit(self) -> None:
|
|
40
|
+
name = self.input.value.strip()
|
|
41
|
+
if not name:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
add_semester(self.db_session, name)
|
|
45
|
+
self.db_session.commit()
|
|
46
|
+
except IntegrityError:
|
|
47
|
+
# TODO add error message for unique constraint
|
|
48
|
+
self.db_session.rollback()
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
self.app.pop_screen()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SemesterScreen(Screen):
|
|
55
|
+
BINDINGS = [
|
|
56
|
+
("a", "add", "Add semester"),
|
|
57
|
+
("ctrl+r", "remove", "Remove semester"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
yield Header()
|
|
65
|
+
self.semester_table: VimTable = VimTable()
|
|
66
|
+
self.semester_table.add_columns("Name")
|
|
67
|
+
self.semester_table.cursor_type = "row"
|
|
68
|
+
self.semester_table.border_title = "Semester Overview"
|
|
69
|
+
yield self.semester_table
|
|
70
|
+
yield Footer()
|
|
71
|
+
|
|
72
|
+
def on_mount(self) -> None:
|
|
73
|
+
self.db_session = self.app.db_session # type: ignore
|
|
74
|
+
self.refresh_table()
|
|
75
|
+
self.semester_table.focus()
|
|
76
|
+
|
|
77
|
+
def refresh_table(self) -> None:
|
|
78
|
+
self.semester_table.clear()
|
|
79
|
+
for sem in get_all_semester(self.db_session):
|
|
80
|
+
self.semester_table.add_row(sem.name)
|
|
81
|
+
|
|
82
|
+
def action_add(self) -> None:
|
|
83
|
+
self.app.push_screen(AddSemesterScreen())
|
|
84
|
+
|
|
85
|
+
def action_remove(self) -> None:
|
|
86
|
+
row_index = self.semester_table.cursor_row
|
|
87
|
+
if row_index is None:
|
|
88
|
+
return
|
|
89
|
+
if not self.semester_table.is_valid_row_index(row_index):
|
|
90
|
+
return
|
|
91
|
+
row = self.semester_table.get_row_at(row_index)
|
|
92
|
+
semester_name = row[0]
|
|
93
|
+
|
|
94
|
+
remove_semester_by_name(self.db_session, semester_name) # type:ignore
|
|
95
|
+
self.db_session.commit()
|
|
96
|
+
self.refresh_table()
|
|
97
|
+
|
|
98
|
+
def on_screen_resume(self) -> None:
|
|
99
|
+
self.refresh_table()
|
|
100
|
+
|
|
101
|
+
@on(VimTable.RowSelected)
|
|
102
|
+
def open_class(self, event: DataTable.RowSelected) -> None:
|
|
103
|
+
row_index = self.semester_table.cursor_row
|
|
104
|
+
if row_index is None:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
semester_name = self.semester_table.get_row_at(row_index)[0]
|
|
108
|
+
self.app.push_screen(ClassScreen(semester_name))
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
import yaml # type: ignore
|
|
10
|
+
from platformdirs import PlatformDirs
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
|
|
14
|
+
APP_NAME = "examtracker"
|
|
15
|
+
APP_AUTHOR = "Samuel Huwiler"
|
|
16
|
+
|
|
17
|
+
dirs = PlatformDirs(appname=APP_NAME, appauthor=APP_AUTHOR)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_config_dir() -> Path:
|
|
21
|
+
if sys.platform == "darwin":
|
|
22
|
+
# Force ~/.config instead of ~/Library/Application Support
|
|
23
|
+
return Path.home() / ".config" / APP_NAME
|
|
24
|
+
else:
|
|
25
|
+
# Linux & Windows default
|
|
26
|
+
return Path(dirs.user_config_dir)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_default_database_path() -> Path:
|
|
30
|
+
"""Return a writable database path in the user data directory."""
|
|
31
|
+
|
|
32
|
+
db_dir = get_config_dir()
|
|
33
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return db_dir / "examtracker.db"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_default_css_path() -> Path:
|
|
38
|
+
"""Return the path to the CSS inside the package data folder."""
|
|
39
|
+
return Path(files("examtracker").joinpath("data/style.css")) # type:ignore
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def yaml_config_settings_source(settings_cls) -> Dict[str, Any]:
|
|
43
|
+
config_path = os.getenv("EXAMTRACKER_CONFIG")
|
|
44
|
+
|
|
45
|
+
if config_path:
|
|
46
|
+
path = Path(config_path).expanduser()
|
|
47
|
+
if path.exists():
|
|
48
|
+
with open(path, "r") as f:
|
|
49
|
+
return yaml.safe_load(f) or {}
|
|
50
|
+
|
|
51
|
+
user_config = get_config_dir() / "config.yml"
|
|
52
|
+
|
|
53
|
+
if user_config.exists():
|
|
54
|
+
with open(user_config, "r") as f:
|
|
55
|
+
return yaml.safe_load(f) or {}
|
|
56
|
+
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Settings(BaseSettings):
|
|
61
|
+
database_path: str = Field(default_factory=lambda: str(get_default_database_path()))
|
|
62
|
+
css_path: str = Field(default_factory=lambda: str(get_default_css_path()))
|
|
63
|
+
model_config = SettingsConfigDict(
|
|
64
|
+
env_prefix="EXAMTRACKER_",
|
|
65
|
+
extra="ignore",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def settings_customise_sources(
|
|
70
|
+
cls,
|
|
71
|
+
settings_cls,
|
|
72
|
+
init_settings,
|
|
73
|
+
env_settings,
|
|
74
|
+
dotenv_settings,
|
|
75
|
+
file_secret_settings,
|
|
76
|
+
):
|
|
77
|
+
return (
|
|
78
|
+
init_settings,
|
|
79
|
+
env_settings,
|
|
80
|
+
lambda: yaml_config_settings_source(settings_cls),
|
|
81
|
+
dotenv_settings,
|
|
82
|
+
file_secret_settings,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
print(Settings())
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|