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.
Files changed (28) hide show
  1. {examtracker-1.2.0/src/Examtracker.egg-info → examtracker-1.2.2}/PKG-INFO +10 -3
  2. {examtracker-1.2.0 → examtracker-1.2.2}/README.md +7 -1
  3. {examtracker-1.2.0 → examtracker-1.2.2}/pyproject.toml +2 -2
  4. {examtracker-1.2.0 → examtracker-1.2.2}/requirements.txt +1 -0
  5. {examtracker-1.2.0 → examtracker-1.2.2/src/Examtracker.egg-info}/PKG-INFO +10 -3
  6. {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/SOURCES.txt +2 -1
  7. {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/requires.txt +1 -0
  8. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/app.py +4 -5
  9. examtracker-1.2.2/src/examtracker/data/style.css +44 -0
  10. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/database.py +14 -8
  11. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/database_scheme.py +4 -3
  12. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/classscreen.py +36 -15
  13. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/examscreen.py +18 -5
  14. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/inputscreens.py +25 -0
  15. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/screens/semesterscreen.py +2 -0
  16. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/textual_utils/vimtable.py +0 -1
  17. examtracker-1.2.2/test/test_database.py +275 -0
  18. examtracker-1.2.0/src/examtracker/data/style.css +0 -30
  19. {examtracker-1.2.0 → examtracker-1.2.2}/LICENSE +0 -0
  20. {examtracker-1.2.0 → examtracker-1.2.2}/setup.cfg +0 -0
  21. {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/dependency_links.txt +0 -0
  22. {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/entry_points.txt +0 -0
  23. {examtracker-1.2.0 → examtracker-1.2.2}/src/Examtracker.egg-info/top_level.txt +0 -0
  24. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/__init__.py +0 -0
  25. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/__main__.py +0 -0
  26. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/main.py +0 -0
  27. {examtracker-1.2.0 → examtracker-1.2.2}/src/examtracker/py.typed +0 -0
  28. {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.0
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.13
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
+ ![test](https://github.com/Samhuw8a/Examtracker/actions/workflows/test.yml/badge.svg)
40
42
  [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
  ![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)
@@ -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
- - [ ] Improve CSS
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
+ ![test](https://github.com/Samhuw8a/Examtracker/actions/workflows/test.yml/badge.svg)
1
2
  [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
  ![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)
@@ -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
- - [ ] Improve CSS
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.0" # REQUIRED, although can be dynamic
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.13"
15
+ requires-python = ">=3.10"
16
16
  license = { file = "LICENSE" }
17
17
 
18
18
  authors = [{ name = "Samuel Huwiler", email = "samuel.huwiler@gmx.ch" }]
@@ -5,3 +5,4 @@ pydantic
5
5
  pydantic-settings
6
6
  pathlib
7
7
  pyyaml
8
+ rich
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Examtracker
3
- Version: 1.2.0
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.13
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
+ ![test](https://github.com/Samhuw8a/Examtracker/actions/workflows/test.yml/badge.svg)
40
42
  [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](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
  ![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)
@@ -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
- - [ ] Improve CSS
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
@@ -5,3 +5,4 @@ pydantic
5
5
  pydantic-settings
6
6
  pathlib
7
7
  pyyaml
8
+ rich
@@ -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, ComposeResult
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
- # CSS_PATH = "/Users/samuel/Repositories/Examtracker/data/style.css"
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, class_name: str, semster_obj: Semester
46
+ session: Session,
47
+ class_name: str,
48
+ semster_obj: Semester,
49
+ exam_grade: Optional[float] = None,
47
50
  ) -> None:
48
- class_obj = Class(name=class_name, semester_id=semster_obj.semester_id)
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
- # create_tables(engine)
102
+ create_tables(engine)
98
103
  session = Session(engine)
99
- # # add_semester(session, "FS25")
104
+ add_semester(session, "FS25")
100
105
  # # remove_semester_by_name(session, "FS25")
101
106
  # # 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")
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, # type:ignore
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 SingleInputScreen
16
+ from examtracker.screens.inputscreens import DoubleInputScreen
17
17
  from examtracker.textual_utils.vimtable import VimTable
18
18
 
19
19
 
20
- class AddClassScreen(SingleInputScreen):
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.input_button.placeholder = "class name"
29
- self.input_button.value = ""
30
- self.input_button.focus()
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.input_button.value.strip()
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(SingleInputScreen):
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.input_button.value = class_obj.name
56
- self.input_button.focus()
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.input_button.value.strip()
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
- self.class_table.add_row(cls.class_id, cls.name)
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 DataTable, Footer, Header
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 = "row"
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, proc
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.RowSelected)
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))
@@ -2,7 +2,6 @@ from textual.widgets import DataTable
2
2
 
3
3
 
4
4
  class VimTable(DataTable):
5
-
6
5
  BINDINGS = [
7
6
  ("j", "cursor_down"),
8
7
  ("k", "cursor_up"),
@@ -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