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.
@@ -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
+ [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
+ ![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2FSamhuw8a%2FExamtracker%2Fmaster%2Fpyproject.toml&color=d8634c)
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
+ [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
+ ![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2FSamhuw8a%2FExamtracker%2Fmaster%2Fpyproject.toml&color=d8634c)
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,7 @@
1
+ textual
2
+ sqlalchemy
3
+ flask-sqlalchemy
4
+ pydantic
5
+ pydantic-settings
6
+ pathlib
7
+ pyyaml
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
+ ![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2FSamhuw8a%2FExamtracker%2Fmaster%2Fpyproject.toml&color=d8634c)
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,2 @@
1
+ [console_scripts]
2
+ examtracker = examtracker.__main__:main
@@ -0,0 +1,7 @@
1
+ textual
2
+ sqlalchemy
3
+ flask-sqlalchemy
4
+ pydantic
5
+ pydantic-settings
6
+ pathlib
7
+ pyyaml
@@ -0,0 +1 @@
1
+ examtracker
File without changes
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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())
@@ -0,0 +1,11 @@
1
+ from textual.widgets import DataTable
2
+
3
+
4
+ class VimTable(DataTable):
5
+
6
+ BINDINGS = [
7
+ ("j", "cursor_down"),
8
+ ("k", "cursor_up"),
9
+ ("l", "select_cursor"),
10
+ ("h", "app.pop_screen"),
11
+ ]