Examtracker 1.2.0__tar.gz → 1.2.2__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.2.0/src/Examtracker.egg-info → examtracker-1.2.2}/PKG-INFO +10 -3
- {examtracker-1.2.0 → examtracker-1.2.2}/README.md +7 -1
- {examtracker-1.2.0 → examtracker-1.2.2}/pyproject.toml +2 -2
- {examtracker-1.2.0 → examtracker-1.2.2}/requirements.txt +1 -0
- {examtracker-1.2.0 → examtracker-1.2.2/src/Examtracker.egg-info}/PKG-INFO +10 -3
- {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/SOURCES.txt +2 -1
- {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/requires.txt +1 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/app.py +4 -5
- examtracker-1.2.2/src/examtracker/data/style.css +44 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/database.py +14 -8
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/database_scheme.py +4 -3
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/classscreen.py +36 -15
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/examscreen.py +18 -5
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/inputscreens.py +25 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/semesterscreen.py +2 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/textual_utils/vimtable.py +0 -1
- examtracker-1.2.2/test/test_database.py +275 -0
- examtracker-1.2.0/src/examtracker/data/style.css +0 -30
- {examtracker-1.2.0 → examtracker-1.2.2}/LICENSE +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/setup.cfg +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/dependency_links.txt +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/entry_points.txt +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/top_level.txt +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/__init__.py +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/__main__.py +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/main.py +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/py.typed +0 -0
- {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/settings.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Examtracker
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
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
5
|
Author-email: Samuel Huwiler <samuel.huwiler@gmx.ch>
|
|
6
6
|
License: MIT License
|
|
@@ -25,7 +25,7 @@ License: MIT License
|
|
|
25
25
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
|
|
28
|
-
Requires-Python: >=3.
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
License-File: LICENSE
|
|
31
31
|
Requires-Dist: textual
|
|
@@ -35,8 +35,10 @@ Requires-Dist: pydantic
|
|
|
35
35
|
Requires-Dist: pydantic-settings
|
|
36
36
|
Requires-Dist: pathlib
|
|
37
37
|
Requires-Dist: pyyaml
|
|
38
|
+
Requires-Dist: rich
|
|
38
39
|
Dynamic: license-file
|
|
39
40
|
|
|
41
|
+

|
|
40
42
|
[](https://choosealicense.com/licenses/mit/)
|
|
41
43
|
<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
44
|

|
|
@@ -132,6 +134,7 @@ Environment variables override values defined in `config.yml`.
|
|
|
132
134
|
- `id`
|
|
133
135
|
- `name`
|
|
134
136
|
- `semester_id`
|
|
137
|
+
- `exam_grade`
|
|
135
138
|
|
|
136
139
|
### `semester`
|
|
137
140
|
- `id`
|
|
@@ -146,5 +149,9 @@ Environment variables override values defined in `config.yml`.
|
|
|
146
149
|
- [x] Configuration file support
|
|
147
150
|
- [x] Abort edit and add screens
|
|
148
151
|
- [x] Cross-platform config discovery
|
|
149
|
-
- [
|
|
152
|
+
- [x] Improve CSS
|
|
150
153
|
- [x] enable Editing Exams and Semester
|
|
154
|
+
- [x] Feature where you can add the grade you got in the exams
|
|
155
|
+
- [ ] Enable externaly hosted database in config
|
|
156
|
+
- [x] write Tests
|
|
157
|
+
- [x] automagicly push new pypi versions and make release upon github tag push tag push
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+

|
|
1
2
|
[](https://choosealicense.com/licenses/mit/)
|
|
2
3
|
<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
|

|
|
@@ -93,6 +94,7 @@ Environment variables override values defined in `config.yml`.
|
|
|
93
94
|
- `id`
|
|
94
95
|
- `name`
|
|
95
96
|
- `semester_id`
|
|
97
|
+
- `exam_grade`
|
|
96
98
|
|
|
97
99
|
### `semester`
|
|
98
100
|
- `id`
|
|
@@ -107,5 +109,9 @@ Environment variables override values defined in `config.yml`.
|
|
|
107
109
|
- [x] Configuration file support
|
|
108
110
|
- [x] Abort edit and add screens
|
|
109
111
|
- [x] Cross-platform config discovery
|
|
110
|
-
- [
|
|
112
|
+
- [x] Improve CSS
|
|
111
113
|
- [x] enable Editing Exams and Semester
|
|
114
|
+
- [x] Feature where you can add the grade you got in the exams
|
|
115
|
+
- [ ] Enable externaly hosted database in config
|
|
116
|
+
- [x] write Tests
|
|
117
|
+
- [x] automagicly push new pypi versions and make release upon github tag push tag push
|
|
@@ -7,12 +7,12 @@ build-backend = "setuptools.build_meta" # If not defined, then legacy behavior c
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "Examtracker" # REQUIRED, is the only field that cannot be marked as dynamic.
|
|
10
|
-
version = "1.2.
|
|
10
|
+
version = "1.2.2" # REQUIRED, although can be dynamic
|
|
11
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
12
|
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
|
|
15
|
-
requires-python = ">=3.
|
|
15
|
+
requires-python = ">=3.10"
|
|
16
16
|
license = { file = "LICENSE" }
|
|
17
17
|
|
|
18
18
|
authors = [{ name = "Samuel Huwiler", email = "samuel.huwiler@gmx.ch" }]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: Examtracker
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
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
5
|
Author-email: Samuel Huwiler <samuel.huwiler@gmx.ch>
|
|
6
6
|
License: MIT License
|
|
@@ -25,7 +25,7 @@ License: MIT License
|
|
|
25
25
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
|
|
28
|
-
Requires-Python: >=3.
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
29
|
Description-Content-Type: text/markdown
|
|
30
30
|
License-File: LICENSE
|
|
31
31
|
Requires-Dist: textual
|
|
@@ -35,8 +35,10 @@ Requires-Dist: pydantic
|
|
|
35
35
|
Requires-Dist: pydantic-settings
|
|
36
36
|
Requires-Dist: pathlib
|
|
37
37
|
Requires-Dist: pyyaml
|
|
38
|
+
Requires-Dist: rich
|
|
38
39
|
Dynamic: license-file
|
|
39
40
|
|
|
41
|
+

|
|
40
42
|
[](https://choosealicense.com/licenses/mit/)
|
|
41
43
|
<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
44
|

|
|
@@ -132,6 +134,7 @@ Environment variables override values defined in `config.yml`.
|
|
|
132
134
|
- `id`
|
|
133
135
|
- `name`
|
|
134
136
|
- `semester_id`
|
|
137
|
+
- `exam_grade`
|
|
135
138
|
|
|
136
139
|
### `semester`
|
|
137
140
|
- `id`
|
|
@@ -146,5 +149,9 @@ Environment variables override values defined in `config.yml`.
|
|
|
146
149
|
- [x] Configuration file support
|
|
147
150
|
- [x] Abort edit and add screens
|
|
148
151
|
- [x] Cross-platform config discovery
|
|
149
|
-
- [
|
|
152
|
+
- [x] Improve CSS
|
|
150
153
|
- [x] enable Editing Exams and Semester
|
|
154
|
+
- [x] Feature where you can add the grade you got in the exams
|
|
155
|
+
- [ ] Enable externaly hosted database in config
|
|
156
|
+
- [x] write Tests
|
|
157
|
+
- [x] automagicly push new pypi versions and make release upon github tag push tag push
|
|
@@ -21,4 +21,5 @@ src/examtracker/screens/classscreen.py
|
|
|
21
21
|
src/examtracker/screens/examscreen.py
|
|
22
22
|
src/examtracker/screens/inputscreens.py
|
|
23
23
|
src/examtracker/screens/semesterscreen.py
|
|
24
|
-
src/examtracker/textual_utils/vimtable.py
|
|
24
|
+
src/examtracker/textual_utils/vimtable.py
|
|
25
|
+
test/test_database.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from sqlalchemy import Engine # type:ignore
|
|
2
2
|
from sqlalchemy.orm import Session
|
|
3
|
-
from textual.app import App
|
|
3
|
+
from textual.app import App
|
|
4
4
|
|
|
5
5
|
from examtracker.screens.semesterscreen import SemesterScreen
|
|
6
6
|
from examtracker.settings import Settings
|
|
@@ -11,12 +11,11 @@ class ExamTracker(App):
|
|
|
11
11
|
("q", "quit", "Quit"),
|
|
12
12
|
("ctrl+c", "quit"),
|
|
13
13
|
]
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def compose(self) -> ComposeResult:
|
|
17
|
-
yield SemesterScreen()
|
|
14
|
+
ENABLE_COMMAND_PALETTE = False
|
|
15
|
+
# CSS_PATH = "/Users/samuel/Repositories/Examtracker/src/examtracker/data/style.css"
|
|
18
16
|
|
|
19
17
|
def on_mount(self) -> None:
|
|
18
|
+
self.theme = "gruvbox"
|
|
20
19
|
self.push_screen(SemesterScreen())
|
|
21
20
|
self.stylesheet.read(self.config.css_path)
|
|
22
21
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
DataTable {
|
|
2
|
+
border-title-align: center;
|
|
3
|
+
border-title-style: bold;
|
|
4
|
+
border-title-color: $foreground;
|
|
5
|
+
margin: 3;
|
|
6
|
+
padding: 2;
|
|
7
|
+
width: 75%;
|
|
8
|
+
border: white;
|
|
9
|
+
text-style: bold;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DataTable .datatable--header {
|
|
14
|
+
text-style: bold;
|
|
15
|
+
background: $primary;
|
|
16
|
+
color: black;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
DataTable .datatable--cursor {
|
|
20
|
+
background: $accent 60%;
|
|
21
|
+
text-style: italic underline;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
Screen {
|
|
25
|
+
position: relative;
|
|
26
|
+
align: center top;
|
|
27
|
+
margin: 3;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Vertical {
|
|
31
|
+
margin: 5;
|
|
32
|
+
align: center top;
|
|
33
|
+
width: 75%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Input {
|
|
37
|
+
width: 75%;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Label {
|
|
42
|
+
width: 75%;
|
|
43
|
+
padding: 1;
|
|
44
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Database related functions for getting and adding entries
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import List
|
|
5
|
+
from typing import List, Optional
|
|
6
6
|
|
|
7
7
|
from sqlalchemy import Engine, create_engine, inspect # type:ignore
|
|
8
8
|
from sqlalchemy.orm import Session
|
|
@@ -43,9 +43,14 @@ def get_exam_by_id(session: Session, exam_id: int) -> Exam:
|
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def add_class_to_semester(
|
|
46
|
-
session: Session,
|
|
46
|
+
session: Session,
|
|
47
|
+
class_name: str,
|
|
48
|
+
semster_obj: Semester,
|
|
49
|
+
exam_grade: Optional[float] = None,
|
|
47
50
|
) -> None:
|
|
48
|
-
class_obj = Class(
|
|
51
|
+
class_obj = Class(
|
|
52
|
+
name=class_name, semester_id=semster_obj.semester_id, exam_grade=exam_grade
|
|
53
|
+
)
|
|
49
54
|
session.add(class_obj)
|
|
50
55
|
session.flush()
|
|
51
56
|
|
|
@@ -94,14 +99,15 @@ def remove_exam_by_id(session: Session, exam_id: int) -> None:
|
|
|
94
99
|
|
|
95
100
|
def main() -> int:
|
|
96
101
|
engine: Engine = create_database_engine("test.db")
|
|
97
|
-
|
|
102
|
+
create_tables(engine)
|
|
98
103
|
session = Session(engine)
|
|
99
|
-
|
|
104
|
+
add_semester(session, "FS25")
|
|
100
105
|
# # remove_semester_by_name(session, "FS25")
|
|
101
106
|
# # print(get_all_semester(session))
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
107
|
+
sem = get_semester_by_name(session, "FS25")
|
|
108
|
+
add_class_to_semester(session, "wEprog", sem)
|
|
109
|
+
cls = get_all_classes_for_semester(session, sem)[0]
|
|
110
|
+
print(cls.exam_grade)
|
|
105
111
|
# # print(get_all_exams_for_class(session, cls))
|
|
106
112
|
session.commit()
|
|
107
113
|
return 0
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
Defining the database tables and entries
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
from typing import List
|
|
5
|
+
from typing import List, Optional
|
|
6
6
|
|
|
7
7
|
from sqlalchemy import ForeignKey, String
|
|
8
|
-
from sqlalchemy.orm import (
|
|
8
|
+
from sqlalchemy.orm import ( # type: ignore
|
|
9
9
|
DeclarativeBase,
|
|
10
|
-
Mapped,
|
|
10
|
+
Mapped,
|
|
11
11
|
mapped_column,
|
|
12
12
|
relationship,
|
|
13
13
|
)
|
|
@@ -34,6 +34,7 @@ class Class(Base):
|
|
|
34
34
|
__tablename__ = "classes"
|
|
35
35
|
class_id: Mapped[int] = mapped_column(primary_key=True)
|
|
36
36
|
name: Mapped[str] = mapped_column(String(30))
|
|
37
|
+
exam_grade: Mapped[Optional[float]]
|
|
37
38
|
semester_id: Mapped[int] = mapped_column(ForeignKey("semester.semester_id"))
|
|
38
39
|
semester: Mapped["Semester"] = relationship(back_populates="classes") # type:ignore
|
|
39
40
|
|
|
@@ -13,38 +13,43 @@ from examtracker.database import (
|
|
|
13
13
|
remove_class_by_id,
|
|
14
14
|
)
|
|
15
15
|
from examtracker.screens.examscreen import ExamScreen
|
|
16
|
-
from examtracker.screens.inputscreens import
|
|
16
|
+
from examtracker.screens.inputscreens import DoubleInputScreen
|
|
17
17
|
from examtracker.textual_utils.vimtable import VimTable
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
class AddClassScreen(
|
|
21
|
-
|
|
20
|
+
class AddClassScreen(DoubleInputScreen):
|
|
22
21
|
def __init__(self, semester_name: str, **kwargs) -> None:
|
|
23
22
|
super().__init__(**kwargs)
|
|
24
23
|
self.semester_name = semester_name
|
|
25
24
|
self.label_text = f"Add class to: {self.semester_name}"
|
|
26
25
|
|
|
27
26
|
def on_mount(self) -> None:
|
|
28
|
-
self.
|
|
29
|
-
self.
|
|
30
|
-
self.
|
|
27
|
+
self.input_button1.placeholder = "class name"
|
|
28
|
+
self.input_button1.value = ""
|
|
29
|
+
self.input_button2.placeholder = "exam_score"
|
|
30
|
+
self.input_button2.value = ""
|
|
31
|
+
self.input_button1.focus()
|
|
31
32
|
|
|
32
33
|
# Submit on Enter from any Input
|
|
33
34
|
def submit(self) -> None:
|
|
34
|
-
name = self.
|
|
35
|
+
name = self.input_button1.value.strip()
|
|
36
|
+
exam_score = self.input_button2.value.strip()
|
|
37
|
+
try:
|
|
38
|
+
score = float(exam_score)
|
|
39
|
+
except ValueError:
|
|
40
|
+
score = None
|
|
35
41
|
if not name:
|
|
36
42
|
return # Require class name
|
|
37
43
|
semester = get_semester_by_name(self.db_session, self.semester_name)
|
|
38
44
|
# Add the class
|
|
39
|
-
add_class_to_semester(self.db_session, name, semester) # type: ignore
|
|
45
|
+
add_class_to_semester(self.db_session, name, semester, score) # type: ignore
|
|
40
46
|
self.db_session.commit()
|
|
41
47
|
|
|
42
48
|
# Pop the screen and return
|
|
43
49
|
self.app.pop_screen()
|
|
44
50
|
|
|
45
51
|
|
|
46
|
-
class EditClassScreen(
|
|
47
|
-
|
|
52
|
+
class EditClassScreen(DoubleInputScreen):
|
|
48
53
|
def __init__(self, class_id: int, **kwargs) -> None:
|
|
49
54
|
super().__init__(**kwargs)
|
|
50
55
|
self.class_id = class_id
|
|
@@ -52,18 +57,28 @@ class EditClassScreen(SingleInputScreen):
|
|
|
52
57
|
|
|
53
58
|
def on_mount(self) -> None:
|
|
54
59
|
class_obj = get_class_by_id(self.db_session, self.class_id)
|
|
55
|
-
self.
|
|
56
|
-
|
|
60
|
+
self.input_button1.value = class_obj.name
|
|
61
|
+
if class_obj.exam_grade is not None:
|
|
62
|
+
self.input_button2.value = str(class_obj.exam_grade)
|
|
63
|
+
else:
|
|
64
|
+
self.input_button2.value = ""
|
|
65
|
+
self.input_button1.focus()
|
|
57
66
|
|
|
58
67
|
# Submit on Enter from any Input
|
|
59
68
|
def submit(self) -> None:
|
|
60
|
-
name = self.
|
|
69
|
+
name = self.input_button1.value.strip()
|
|
70
|
+
exam_score = self.input_button2.value.strip()
|
|
71
|
+
try:
|
|
72
|
+
score = float(exam_score)
|
|
73
|
+
except ValueError:
|
|
74
|
+
score = None
|
|
61
75
|
if not name:
|
|
62
76
|
return # Require class name
|
|
63
77
|
|
|
64
78
|
class_obj = get_class_by_id(self.db_session, self.class_id)
|
|
65
79
|
# Add the class
|
|
66
80
|
class_obj.name = name
|
|
81
|
+
class_obj.exam_grade = score
|
|
67
82
|
self.db_session.commit()
|
|
68
83
|
|
|
69
84
|
# Pop the screen and return
|
|
@@ -90,7 +105,7 @@ class ClassScreen(Screen):
|
|
|
90
105
|
def compose(self) -> ComposeResult:
|
|
91
106
|
yield Header()
|
|
92
107
|
self.class_table: VimTable = VimTable()
|
|
93
|
-
self.class_table.add_columns("ID", "Name")
|
|
108
|
+
self.class_table.add_columns("ID", "Name", "Exam grade")
|
|
94
109
|
self.class_table.cursor_type = "row"
|
|
95
110
|
self.class_table.border_title = f"classes for: {self.semester_name}"
|
|
96
111
|
yield self.class_table
|
|
@@ -118,6 +133,8 @@ class ClassScreen(Screen):
|
|
|
118
133
|
row_index = self.class_table.cursor_row
|
|
119
134
|
if row_index is None:
|
|
120
135
|
return
|
|
136
|
+
if not self.class_table.is_valid_row_index(row_index):
|
|
137
|
+
return
|
|
121
138
|
|
|
122
139
|
class_id = self.class_table.get_row_at(row_index)[0]
|
|
123
140
|
self.app.push_screen(EditClassScreen(class_id))
|
|
@@ -127,7 +144,11 @@ class ClassScreen(Screen):
|
|
|
127
144
|
|
|
128
145
|
self.class_table.clear()
|
|
129
146
|
for cls in get_all_classes_for_semester(self.db_session, semester):
|
|
130
|
-
|
|
147
|
+
score = cls.exam_grade
|
|
148
|
+
str_score = ""
|
|
149
|
+
if score is not None:
|
|
150
|
+
str_score = cls.exam_grade
|
|
151
|
+
self.class_table.add_row(cls.class_id, cls.name, str_score)
|
|
131
152
|
|
|
132
153
|
def on_screen_resume(self) -> None:
|
|
133
154
|
self.refresh_table()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
from rich.text import Text
|
|
1
2
|
from textual import on
|
|
2
3
|
from textual.app import ComposeResult, Screen
|
|
3
|
-
from textual.widgets import
|
|
4
|
+
from textual.widgets import Footer, Header
|
|
4
5
|
|
|
5
6
|
from examtracker.database import (
|
|
6
7
|
add_exam_to_class,
|
|
@@ -13,6 +14,15 @@ from examtracker.screens.inputscreens import TrippleInputScreen
|
|
|
13
14
|
from examtracker.textual_utils.vimtable import VimTable
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def proc_color(value: float) -> str:
|
|
18
|
+
if value >= 75.0:
|
|
19
|
+
return "#03fc20"
|
|
20
|
+
elif value >= 50:
|
|
21
|
+
return "#fcf003"
|
|
22
|
+
else:
|
|
23
|
+
return "#fc1c03"
|
|
24
|
+
|
|
25
|
+
|
|
16
26
|
class EditExamScreen(TrippleInputScreen):
|
|
17
27
|
def __init__(self, exam_id: int, **kwargs):
|
|
18
28
|
super().__init__(**kwargs)
|
|
@@ -125,7 +135,7 @@ class ExamScreen(Screen):
|
|
|
125
135
|
|
|
126
136
|
self.exam_table: VimTable = VimTable()
|
|
127
137
|
self.exam_table.add_columns("ID", "Name", "Max_points", "Scored_points", "%")
|
|
128
|
-
self.exam_table.cursor_type = "
|
|
138
|
+
self.exam_table.cursor_type = "cell"
|
|
129
139
|
self.exam_table.border_title = f"Exams completed for: {self.class_name}"
|
|
130
140
|
yield self.exam_table
|
|
131
141
|
|
|
@@ -158,21 +168,24 @@ class ExamScreen(Screen):
|
|
|
158
168
|
if cls.max_points == 0:
|
|
159
169
|
proc = 0.0
|
|
160
170
|
else:
|
|
161
|
-
proc = (cls.scored_points / cls.max_points) * 100
|
|
171
|
+
proc = round((cls.scored_points / cls.max_points) * 100, 2)
|
|
162
172
|
|
|
173
|
+
proc_text = Text(str(proc), style=proc_color(proc))
|
|
163
174
|
self.exam_table.add_row(
|
|
164
|
-
cls.exam_id, cls.name, cls.max_points, cls.scored_points,
|
|
175
|
+
cls.exam_id, cls.name, cls.max_points, cls.scored_points, proc_text
|
|
165
176
|
)
|
|
166
177
|
|
|
167
178
|
def on_screen_resume(self) -> None:
|
|
168
179
|
# Called when returning from AddSemesterScreen
|
|
169
180
|
self.refresh_table()
|
|
170
181
|
|
|
171
|
-
@on(VimTable.
|
|
182
|
+
@on(VimTable.CellSelected)
|
|
172
183
|
def action_edit(self) -> None:
|
|
173
184
|
row_index = self.exam_table.cursor_row
|
|
174
185
|
if row_index is None:
|
|
175
186
|
return
|
|
187
|
+
if not self.exam_table.is_valid_row_index(row_index):
|
|
188
|
+
return
|
|
176
189
|
|
|
177
190
|
exam_id = self.exam_table.get_row_at(row_index)[0]
|
|
178
191
|
self.app.push_screen(EditExamScreen(exam_id))
|
|
@@ -54,3 +54,28 @@ class TrippleInputScreen(Screen):
|
|
|
54
54
|
@on(Input.Submitted)
|
|
55
55
|
def input_submitted(self, event: Input.Submitted) -> None:
|
|
56
56
|
self.submit() # type: ignore
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DoubleInputScreen(Screen):
|
|
60
|
+
BINDINGS = [
|
|
61
|
+
("escape", "app.pop_screen", "Cancel"),
|
|
62
|
+
]
|
|
63
|
+
input_button1: Input = Input()
|
|
64
|
+
input_button2: Input = Input()
|
|
65
|
+
label_text: str = ""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self.db_session = self.app.db_session # type: ignore
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
yield Header()
|
|
73
|
+
with Vertical():
|
|
74
|
+
yield Label(self.label_text)
|
|
75
|
+
yield self.input_button1
|
|
76
|
+
yield self.input_button2
|
|
77
|
+
yield Footer()
|
|
78
|
+
|
|
79
|
+
@on(Input.Submitted)
|
|
80
|
+
def input_submitted(self, event: Input.Submitted) -> None:
|
|
81
|
+
self.submit() # type: ignore
|
|
@@ -103,6 +103,8 @@ class SemesterScreen(Screen):
|
|
|
103
103
|
row_index = self.semester_table.cursor_row
|
|
104
104
|
if row_index is None:
|
|
105
105
|
return
|
|
106
|
+
if not self.semester_table.is_valid_row_index(row_index):
|
|
107
|
+
return
|
|
106
108
|
|
|
107
109
|
semester_name = self.semester_table.get_row_at(row_index)[0]
|
|
108
110
|
self.app.push_screen(EditSemesterScreen(semester_name))
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from hypothesis import given, settings
|
|
5
|
+
from hypothesis.strategies import floats, integers, text
|
|
6
|
+
from sqlalchemy import create_engine
|
|
7
|
+
from sqlalchemy.exc import IntegrityError
|
|
8
|
+
from sqlalchemy.orm import Session
|
|
9
|
+
|
|
10
|
+
# Import your database functions and schema
|
|
11
|
+
# Ensure 'examtracker' is in your python path
|
|
12
|
+
from examtracker.database import (
|
|
13
|
+
add_class_to_semester,
|
|
14
|
+
add_exam_to_class,
|
|
15
|
+
add_semester,
|
|
16
|
+
create_tables,
|
|
17
|
+
get_all_classes_for_semester,
|
|
18
|
+
get_all_exams_for_class,
|
|
19
|
+
get_all_semester,
|
|
20
|
+
get_class_by_id,
|
|
21
|
+
get_exam_by_id,
|
|
22
|
+
get_semester_by_name,
|
|
23
|
+
remove_class_by_id,
|
|
24
|
+
remove_exam_by_id,
|
|
25
|
+
remove_semester_by_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# --- Database Setup Logic ---
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def temp_db():
|
|
33
|
+
"""
|
|
34
|
+
Creates a fresh, in-memory database for every Hypothesis iteration.
|
|
35
|
+
This prevents data leakage between tests and satisfies Hypothesis health checks.
|
|
36
|
+
"""
|
|
37
|
+
# Using :memory: is significantly faster for property-based testing
|
|
38
|
+
engine = create_engine("sqlite:///:memory:")
|
|
39
|
+
# Base.metadata.create_all(engine)
|
|
40
|
+
create_tables(engine)
|
|
41
|
+
with Session(engine) as session:
|
|
42
|
+
yield session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Helper to filter out null bytes which SQLite cannot store in strings
|
|
46
|
+
valid_text = text(min_size=1).filter(lambda x: "\x00" not in x)
|
|
47
|
+
|
|
48
|
+
# ------------- Semester tests -------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@given(name=valid_text)
|
|
52
|
+
@settings(deadline=None)
|
|
53
|
+
def test_add_get_semester(name: str) -> None:
|
|
54
|
+
with temp_db() as session:
|
|
55
|
+
add_semester(session, name)
|
|
56
|
+
session.commit()
|
|
57
|
+
|
|
58
|
+
retrieved = get_semester_by_name(session, name)
|
|
59
|
+
assert retrieved.name == name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_get_nonexistant_semester() -> None:
|
|
63
|
+
with temp_db() as session:
|
|
64
|
+
with pytest.raises(Exception): # SQLAlchemy raises NoResultFound for .one()
|
|
65
|
+
get_semester_by_name(session, "Does Not Exist")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_get_all_semester_logic() -> None:
|
|
69
|
+
with temp_db() as session:
|
|
70
|
+
add_semester(session, "Sem1")
|
|
71
|
+
add_semester(session, "Sem2")
|
|
72
|
+
session.commit()
|
|
73
|
+
|
|
74
|
+
semesters = get_all_semester(session)
|
|
75
|
+
assert len(semesters) == 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@given(name=valid_text)
|
|
79
|
+
@settings(deadline=None)
|
|
80
|
+
def test_semester_name_uniqueness(name: str) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Property-based test to ensure no two semesters can share the same generated name.
|
|
83
|
+
"""
|
|
84
|
+
with temp_db() as session:
|
|
85
|
+
# First insertion
|
|
86
|
+
add_semester(session, name)
|
|
87
|
+
session.commit()
|
|
88
|
+
|
|
89
|
+
# Second insertion of the exact same name
|
|
90
|
+
add_semester(session, name)
|
|
91
|
+
|
|
92
|
+
with pytest.raises(IntegrityError):
|
|
93
|
+
session.commit()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ------------- Class tests -------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@given(name=valid_text)
|
|
100
|
+
@settings(deadline=None)
|
|
101
|
+
def test_add_get_class(name: str) -> None:
|
|
102
|
+
with temp_db() as session:
|
|
103
|
+
# Setup: Classes require a parent Semester
|
|
104
|
+
add_semester(session, "Base Semester")
|
|
105
|
+
sem = get_semester_by_name(session, "Base Semester")
|
|
106
|
+
|
|
107
|
+
add_class_to_semester(session, name, sem)
|
|
108
|
+
session.commit()
|
|
109
|
+
|
|
110
|
+
# Verify via relationship
|
|
111
|
+
classes = get_all_classes_for_semester(session, sem)
|
|
112
|
+
assert any(c.name == name for c in classes)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@given(name=valid_text, score=floats())
|
|
116
|
+
@settings(deadline=None)
|
|
117
|
+
def test_add_get_class_with_score(name: str, score: float) -> None:
|
|
118
|
+
with temp_db() as session:
|
|
119
|
+
# Setup: Classes require a parent Semester
|
|
120
|
+
add_semester(session, "Base Semester")
|
|
121
|
+
sem = get_semester_by_name(session, "Base Semester")
|
|
122
|
+
|
|
123
|
+
add_class_to_semester(session, name, sem, score)
|
|
124
|
+
session.commit()
|
|
125
|
+
|
|
126
|
+
# Verify via relationship
|
|
127
|
+
classes = get_all_classes_for_semester(session, sem)
|
|
128
|
+
assert any(c.name == name for c in classes)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_get_nonexistant_class() -> None:
|
|
132
|
+
with temp_db() as session:
|
|
133
|
+
with pytest.raises(Exception):
|
|
134
|
+
get_class_by_id(session, 999)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ------------- Exam tests -------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@given(
|
|
141
|
+
name=valid_text,
|
|
142
|
+
max_points=integers(min_value=0, max_value=10000),
|
|
143
|
+
scored_points=integers(min_value=0, max_value=10000),
|
|
144
|
+
)
|
|
145
|
+
@settings(deadline=None)
|
|
146
|
+
def test_add_get_exam(name: str, max_points: int, scored_points: int) -> None:
|
|
147
|
+
with temp_db() as session:
|
|
148
|
+
# Setup Hierarchy: Semester -> Class
|
|
149
|
+
add_semester(session, "S1")
|
|
150
|
+
sem = get_semester_by_name(session, "S1")
|
|
151
|
+
add_class_to_semester(session, "C1", sem)
|
|
152
|
+
cls = sem.classes[0]
|
|
153
|
+
|
|
154
|
+
add_exam_to_class(session, name, max_points, scored_points, cls)
|
|
155
|
+
session.commit()
|
|
156
|
+
|
|
157
|
+
# Verify exam data
|
|
158
|
+
exams = get_all_exams_for_class(session, cls)
|
|
159
|
+
assert len(exams) == 1
|
|
160
|
+
assert exams[0].name == name
|
|
161
|
+
assert exams[0].max_points == max_points
|
|
162
|
+
assert exams[0].scored_points == scored_points
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_get_nonexistant_exam() -> None:
|
|
166
|
+
with temp_db() as session:
|
|
167
|
+
with pytest.raises(Exception):
|
|
168
|
+
get_exam_by_id(session, 888)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ------------- Cascade and Specific Deletion Tests -------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_remove_semester_cascades_to_classes_and_exams() -> None:
|
|
175
|
+
"""Tests that deleting a semester removes all nested classes and exams."""
|
|
176
|
+
with temp_db() as session:
|
|
177
|
+
# 1. Setup Hierarchy: Semester -> Class -> Exam
|
|
178
|
+
add_semester(session, "Delete Cascade Test")
|
|
179
|
+
sem = get_semester_by_name(session, "Delete Cascade Test")
|
|
180
|
+
|
|
181
|
+
add_class_to_semester(session, "Nested Class", sem)
|
|
182
|
+
cls = sem.classes[0]
|
|
183
|
+
|
|
184
|
+
add_exam_to_class(session, "Nested Exam", 100, 90, cls)
|
|
185
|
+
session.commit()
|
|
186
|
+
|
|
187
|
+
# Capture IDs to verify deletion later
|
|
188
|
+
class_id = cls.class_id
|
|
189
|
+
exam_id = cls.exams[0].exam_id
|
|
190
|
+
|
|
191
|
+
# 2. Action: Remove the parent Semester
|
|
192
|
+
remove_semester_by_name(session, "Delete Cascade Test")
|
|
193
|
+
session.commit()
|
|
194
|
+
|
|
195
|
+
# 3. Verify: Semester is gone
|
|
196
|
+
with pytest.raises(Exception):
|
|
197
|
+
get_semester_by_name(session, "Delete Cascade Test")
|
|
198
|
+
|
|
199
|
+
# 4. Verify: Children are gone (Assuming Cascade Delete is set in schema)
|
|
200
|
+
with pytest.raises(Exception):
|
|
201
|
+
get_class_by_id(session, class_id)
|
|
202
|
+
with pytest.raises(Exception):
|
|
203
|
+
get_exam_by_id(session, exam_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_remove_class_by_id_logic() -> None:
|
|
207
|
+
"""Tests that a class is removed without affecting the parent semester."""
|
|
208
|
+
with temp_db() as session:
|
|
209
|
+
add_semester(session, "Persistence Sem")
|
|
210
|
+
sem = get_semester_by_name(session, "Persistence Sem")
|
|
211
|
+
add_class_to_semester(session, "Target Class", sem)
|
|
212
|
+
session.commit()
|
|
213
|
+
|
|
214
|
+
class_id = sem.classes[0].class_id
|
|
215
|
+
|
|
216
|
+
remove_class_by_id(session, class_id)
|
|
217
|
+
session.commit()
|
|
218
|
+
|
|
219
|
+
# Verify class is gone but semester remains
|
|
220
|
+
with pytest.raises(Exception):
|
|
221
|
+
get_class_by_id(session, class_id)
|
|
222
|
+
assert get_semester_by_name(session, "Persistence Sem") is not None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_remove_exam_by_id_logic() -> None:
|
|
226
|
+
"""Tests that an exam is removed from its parent class."""
|
|
227
|
+
with temp_db() as session:
|
|
228
|
+
add_semester(session, "S1")
|
|
229
|
+
sem = get_semester_by_name(session, "S1")
|
|
230
|
+
add_class_to_semester(session, "C1", sem)
|
|
231
|
+
cls = sem.classes[0]
|
|
232
|
+
add_exam_to_class(session, "Final", 100, 100, cls)
|
|
233
|
+
session.commit()
|
|
234
|
+
|
|
235
|
+
exam_id = cls.exams[0].exam_id
|
|
236
|
+
|
|
237
|
+
remove_exam_by_id(session, exam_id)
|
|
238
|
+
session.commit()
|
|
239
|
+
|
|
240
|
+
with pytest.raises(Exception):
|
|
241
|
+
get_exam_by_id(session, exam_id)
|
|
242
|
+
assert len(get_all_exams_for_class(session, cls)) == 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ------------- Integrity & Edge Case Tests -------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_empty_relationships_return_empty_list() -> None:
|
|
249
|
+
"""Verifies that queries return empty lists instead of errors when no children exist."""
|
|
250
|
+
with temp_db() as session:
|
|
251
|
+
add_semester(session, "Empty Sem")
|
|
252
|
+
sem = get_semester_by_name(session, "Empty Sem")
|
|
253
|
+
|
|
254
|
+
# Should be empty list, not an exception
|
|
255
|
+
assert get_all_classes_for_semester(session, sem) == []
|
|
256
|
+
|
|
257
|
+
add_class_to_semester(session, "Empty Class", sem)
|
|
258
|
+
cls = sem.classes[0]
|
|
259
|
+
assert get_all_exams_for_class(session, cls) == []
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@given(pts=integers(min_value=0, max_value=2147483647)) # Max 32-bit signed int
|
|
263
|
+
def test_exam_score_boundaries(pts: int) -> None:
|
|
264
|
+
"""Tests that the database handles large point values correctly."""
|
|
265
|
+
with temp_db() as session:
|
|
266
|
+
add_semester(session, "Boundary Sem")
|
|
267
|
+
sem = get_semester_by_name(session, "Boundary Sem")
|
|
268
|
+
add_class_to_semester(session, "Math", sem)
|
|
269
|
+
cls = sem.classes[0]
|
|
270
|
+
|
|
271
|
+
add_exam_to_class(session, "Big Score", pts, pts, cls)
|
|
272
|
+
session.commit()
|
|
273
|
+
|
|
274
|
+
exam = get_all_exams_for_class(session, cls)[0]
|
|
275
|
+
assert exam.max_points == pts
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|