Examtracker 1.2.2__tar.gz → 1.2.3__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.2/src/Examtracker.egg-info → examtracker-1.2.3}/PKG-INFO +8 -1
  2. {examtracker-1.2.2 → examtracker-1.2.3}/README.md +5 -0
  3. {examtracker-1.2.2 → examtracker-1.2.3}/pyproject.toml +1 -1
  4. {examtracker-1.2.2 → examtracker-1.2.3}/requirements.txt +2 -0
  5. {examtracker-1.2.2 → examtracker-1.2.3/src/Examtracker.egg-info}/PKG-INFO +8 -1
  6. {examtracker-1.2.2 → examtracker-1.2.3}/src/Examtracker.egg-info/requires.txt +2 -0
  7. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/database.py +19 -14
  8. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/database_scheme.py +6 -7
  9. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/main.py +1 -1
  10. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/screens/classscreen.py +28 -19
  11. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/screens/examscreen.py +55 -30
  12. examtracker-1.2.3/src/examtracker/screens/inputscreens.py +32 -0
  13. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/screens/semesterscreen.py +15 -12
  14. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/settings.py +7 -6
  15. {examtracker-1.2.2 → examtracker-1.2.3}/test/test_database.py +17 -11
  16. examtracker-1.2.2/src/examtracker/screens/inputscreens.py +0 -81
  17. {examtracker-1.2.2 → examtracker-1.2.3}/LICENSE +0 -0
  18. {examtracker-1.2.2 → examtracker-1.2.3}/setup.cfg +0 -0
  19. {examtracker-1.2.2 → examtracker-1.2.3}/src/Examtracker.egg-info/SOURCES.txt +0 -0
  20. {examtracker-1.2.2 → examtracker-1.2.3}/src/Examtracker.egg-info/dependency_links.txt +0 -0
  21. {examtracker-1.2.2 → examtracker-1.2.3}/src/Examtracker.egg-info/entry_points.txt +0 -0
  22. {examtracker-1.2.2 → examtracker-1.2.3}/src/Examtracker.egg-info/top_level.txt +0 -0
  23. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/__init__.py +0 -0
  24. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/__main__.py +0 -0
  25. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/app.py +0 -0
  26. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/data/style.css +0 -0
  27. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/py.typed +0 -0
  28. {examtracker-1.2.2 → examtracker-1.2.3}/src/examtracker/textual_utils/vimtable.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Examtracker
3
- Version: 1.2.2
3
+ Version: 1.2.3
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
@@ -36,6 +36,8 @@ Requires-Dist: pydantic-settings
36
36
  Requires-Dist: pathlib
37
37
  Requires-Dist: pyyaml
38
38
  Requires-Dist: rich
39
+ Requires-Dist: pymysql
40
+ Requires-Dist: cryptography
39
41
  Dynamic: license-file
40
42
 
41
43
  ![test](https://github.com/Samhuw8a/Examtracker/actions/workflows/test.yml/badge.svg)
@@ -94,6 +96,7 @@ By default, the program searches for:
94
96
 
95
97
  ```yaml
96
98
  database_path: "~/.config/examtracker/examtracker.db"
99
+ database_uri: "sqllite:///~/.config/examtracker/examtracker.db"
97
100
  ```
98
101
 
99
102
  </details>
@@ -144,6 +147,7 @@ Environment variables override values defined in `config.yml`.
144
147
 
145
148
  # TODO
146
149
 
150
+ - [x] Allow for floating point values for the points scored
147
151
  - [x] Handle SQL errors
148
152
  - [x] Initialize database
149
153
  - [x] Configuration file support
@@ -153,5 +157,8 @@ Environment variables override values defined in `config.yml`.
153
157
  - [x] enable Editing Exams and Semester
154
158
  - [x] Feature where you can add the grade you got in the exams
155
159
  - [ ] Enable externaly hosted database in config
160
+ - [x] Add a Notes section to the Exams for adding notes
161
+ - [ ] Fix sizing issue when removing a large note from an exam
162
+ - [x] Save the collumn when entering a submenu to set the cursor to the same place as before instead of to the top
156
163
  - [x] write Tests
157
164
  - [x] automagicly push new pypi versions and make release upon github tag push tag push
@@ -54,6 +54,7 @@ By default, the program searches for:
54
54
 
55
55
  ```yaml
56
56
  database_path: "~/.config/examtracker/examtracker.db"
57
+ database_uri: "sqllite:///~/.config/examtracker/examtracker.db"
57
58
  ```
58
59
 
59
60
  </details>
@@ -104,6 +105,7 @@ Environment variables override values defined in `config.yml`.
104
105
 
105
106
  # TODO
106
107
 
108
+ - [x] Allow for floating point values for the points scored
107
109
  - [x] Handle SQL errors
108
110
  - [x] Initialize database
109
111
  - [x] Configuration file support
@@ -113,5 +115,8 @@ Environment variables override values defined in `config.yml`.
113
115
  - [x] enable Editing Exams and Semester
114
116
  - [x] Feature where you can add the grade you got in the exams
115
117
  - [ ] Enable externaly hosted database in config
118
+ - [x] Add a Notes section to the Exams for adding notes
119
+ - [ ] Fix sizing issue when removing a large note from an exam
120
+ - [x] Save the collumn when entering a submenu to set the cursor to the same place as before instead of to the top
116
121
  - [x] write Tests
117
122
  - [x] automagicly push new pypi versions and make release upon github tag push tag push
@@ -7,7 +7,7 @@ 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.2" # REQUIRED, although can be dynamic
10
+ version = "1.2.3" # 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"
@@ -6,3 +6,5 @@ pydantic-settings
6
6
  pathlib
7
7
  pyyaml
8
8
  rich
9
+ pymysql
10
+ cryptography
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Examtracker
3
- Version: 1.2.2
3
+ Version: 1.2.3
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
@@ -36,6 +36,8 @@ Requires-Dist: pydantic-settings
36
36
  Requires-Dist: pathlib
37
37
  Requires-Dist: pyyaml
38
38
  Requires-Dist: rich
39
+ Requires-Dist: pymysql
40
+ Requires-Dist: cryptography
39
41
  Dynamic: license-file
40
42
 
41
43
  ![test](https://github.com/Samhuw8a/Examtracker/actions/workflows/test.yml/badge.svg)
@@ -94,6 +96,7 @@ By default, the program searches for:
94
96
 
95
97
  ```yaml
96
98
  database_path: "~/.config/examtracker/examtracker.db"
99
+ database_uri: "sqllite:///~/.config/examtracker/examtracker.db"
97
100
  ```
98
101
 
99
102
  </details>
@@ -144,6 +147,7 @@ Environment variables override values defined in `config.yml`.
144
147
 
145
148
  # TODO
146
149
 
150
+ - [x] Allow for floating point values for the points scored
147
151
  - [x] Handle SQL errors
148
152
  - [x] Initialize database
149
153
  - [x] Configuration file support
@@ -153,5 +157,8 @@ Environment variables override values defined in `config.yml`.
153
157
  - [x] enable Editing Exams and Semester
154
158
  - [x] Feature where you can add the grade you got in the exams
155
159
  - [ ] Enable externaly hosted database in config
160
+ - [x] Add a Notes section to the Exams for adding notes
161
+ - [ ] Fix sizing issue when removing a large note from an exam
162
+ - [x] Save the collumn when entering a submenu to set the cursor to the same place as before instead of to the top
156
163
  - [x] write Tests
157
164
  - [x] automagicly push new pypi versions and make release upon github tag push tag push
@@ -6,3 +6,5 @@ pydantic-settings
6
6
  pathlib
7
7
  pyyaml
8
8
  rich
9
+ pymysql
10
+ cryptography
@@ -2,20 +2,19 @@
2
2
  Database related functions for getting and adding entries
3
3
  """
4
4
 
5
- from typing import List, Optional
5
+ from typing import Optional
6
6
 
7
- from sqlalchemy import Engine, create_engine, inspect # type:ignore
7
+ from sqlalchemy import Engine # type:ignore
8
8
  from sqlalchemy.orm import Session
9
9
 
10
10
  from examtracker.database_scheme import Base, Class, Exam, Semester
11
11
 
12
12
 
13
- def create_database_engine(path: str) -> Engine:
14
- engine = create_engine("sqlite:///" + path, echo=False)
15
- inspector = inspect(engine)
13
+ def create_database_engine(uri: str) -> Engine:
14
+ from sqlalchemy import create_engine
16
15
 
17
- if not inspector.get_table_names():
18
- create_tables(engine)
16
+ engine = create_engine(uri, echo=False)
17
+ create_tables(engine)
19
18
 
20
19
  return engine
21
20
 
@@ -31,7 +30,7 @@ def get_semester_by_name(session: Session, name: str) -> Semester:
31
30
  def add_semester(session: Session, name: str) -> None:
32
31
  semester_obj = Semester(name=name)
33
32
  session.add(semester_obj)
34
- session.flush
33
+ session.flush()
35
34
 
36
35
 
37
36
  def get_class_by_id(session: Session, class_id: int) -> Class:
@@ -56,29 +55,35 @@ def add_class_to_semester(
56
55
 
57
56
 
58
57
  def add_exam_to_class(
59
- session: Session, name: str, max_points: int, scored_points: int, class_obj: Class
58
+ session: Session,
59
+ name: str,
60
+ max_points: float,
61
+ scored_points: float,
62
+ notes: str,
63
+ class_obj: Class,
60
64
  ) -> None:
61
65
  exam_obj = Exam(
62
66
  name=name,
63
67
  max_points=max_points,
64
68
  scored_points=scored_points,
69
+ notes=notes,
65
70
  class_id=class_obj.class_id,
66
71
  )
67
72
  session.add(exam_obj)
68
73
  session.flush()
69
74
 
70
75
 
71
- def get_all_exams_for_class(session: Session, class_obj: Class) -> List[Exam]:
76
+ def get_all_exams_for_class(session: Session, class_obj: Class) -> list[Exam]:
72
77
  return session.query(Exam).filter_by(class_id=class_obj.class_id).all()
73
78
 
74
79
 
75
- def get_all_semester(session: Session) -> List[Semester]:
76
- return session.query(Semester).all()
80
+ def get_all_semester(session: Session) -> list[Semester]:
81
+ return session.query(Semester).all()[::-1]
77
82
 
78
83
 
79
84
  def get_all_classes_for_semester(
80
85
  session: Session, semster_obj: Semester
81
- ) -> List[Class]:
86
+ ) -> list[Class]:
82
87
  return session.query(Class).filter_by(semester_id=semster_obj.semester_id).all()
83
88
 
84
89
 
@@ -98,7 +103,7 @@ def remove_exam_by_id(session: Session, exam_id: int) -> None:
98
103
 
99
104
 
100
105
  def main() -> int:
101
- engine: Engine = create_database_engine("test.db")
106
+ engine: Engine = create_database_engine("sqlite:///test.db")
102
107
  create_tables(engine)
103
108
  session = Session(engine)
104
109
  add_semester(session, "FS25")
@@ -2,8 +2,6 @@
2
2
  Defining the database tables and entries
3
3
  """
4
4
 
5
- from typing import List, Optional
6
-
7
5
  from sqlalchemy import ForeignKey, String
8
6
  from sqlalchemy.orm import ( # type: ignore
9
7
  DeclarativeBase,
@@ -21,8 +19,9 @@ class Exam(Base):
21
19
  __tablename__ = "exams"
22
20
  exam_id: Mapped[int] = mapped_column(primary_key=True)
23
21
  name: Mapped[str] = mapped_column(String(30))
24
- max_points: Mapped[int]
25
- scored_points: Mapped[int]
22
+ max_points: Mapped[float]
23
+ scored_points: Mapped[float]
24
+ notes: Mapped[str] = mapped_column(String(100))
26
25
  class_id: Mapped[int] = mapped_column(ForeignKey("classes.class_id"))
27
26
  class_: Mapped["Class"] = relationship(back_populates="exams") # type:ignore
28
27
 
@@ -34,11 +33,11 @@ class Class(Base):
34
33
  __tablename__ = "classes"
35
34
  class_id: Mapped[int] = mapped_column(primary_key=True)
36
35
  name: Mapped[str] = mapped_column(String(30))
37
- exam_grade: Mapped[Optional[float]]
36
+ exam_grade: Mapped[float | None]
38
37
  semester_id: Mapped[int] = mapped_column(ForeignKey("semester.semester_id"))
39
38
  semester: Mapped["Semester"] = relationship(back_populates="classes") # type:ignore
40
39
 
41
- exams: Mapped[List["Exam"]] = relationship(
40
+ exams: Mapped[list["Exam"]] = relationship(
42
41
  back_populates="class_",
43
42
  cascade="all, delete-orphan",
44
43
  ) # type: ignore
@@ -51,7 +50,7 @@ class Semester(Base):
51
50
  __tablename__ = "semester"
52
51
  semester_id: Mapped[int] = mapped_column(primary_key=True)
53
52
  name: Mapped[str] = mapped_column(String(30), unique=True)
54
- classes: Mapped[List["Class"]] = relationship(
53
+ classes: Mapped[list["Class"]] = relationship(
55
54
  back_populates="semester",
56
55
  cascade="all, delete-orphan",
57
56
  ) # type: ignore
@@ -7,7 +7,7 @@ from examtracker.settings import Settings
7
7
 
8
8
  def main() -> int:
9
9
  config: Settings = Settings()
10
- db_engine = create_database_engine(config.database_path)
10
+ db_engine = create_database_engine(config.database_uri)
11
11
  app = ExamTracker(db_engine, config)
12
12
  app.run()
13
13
  return 0
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+
3
5
  from sqlalchemy.orm import Session
4
6
  from textual import on
5
7
  from textual.app import ComposeResult, Screen
@@ -13,27 +15,27 @@ from examtracker.database import (
13
15
  remove_class_by_id,
14
16
  )
15
17
  from examtracker.screens.examscreen import ExamScreen
16
- from examtracker.screens.inputscreens import DoubleInputScreen
18
+ from examtracker.screens.inputscreens import MultiInputScreen
17
19
  from examtracker.textual_utils.vimtable import VimTable
18
20
 
19
21
 
20
- class AddClassScreen(DoubleInputScreen):
22
+ class AddClassScreen(MultiInputScreen):
21
23
  def __init__(self, semester_name: str, **kwargs) -> None:
22
- super().__init__(**kwargs)
24
+ super().__init__(2, **kwargs)
23
25
  self.semester_name = semester_name
24
26
  self.label_text = f"Add class to: {self.semester_name}"
25
27
 
26
28
  def on_mount(self) -> None:
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()
29
+ self.buttons[0].placeholder = "class name"
30
+ self.buttons[0].value = ""
31
+ self.buttons[1].placeholder = "exam_score"
32
+ self.buttons[1].value = ""
33
+ self.buttons[0].focus()
32
34
 
33
35
  # Submit on Enter from any Input
34
36
  def submit(self) -> None:
35
- name = self.input_button1.value.strip()
36
- exam_score = self.input_button2.value.strip()
37
+ name = self.buttons[0].value.strip()
38
+ exam_score = self.buttons[1].value.strip()
37
39
  try:
38
40
  score = float(exam_score)
39
41
  except ValueError:
@@ -49,29 +51,32 @@ class AddClassScreen(DoubleInputScreen):
49
51
  self.app.pop_screen()
50
52
 
51
53
 
52
- class EditClassScreen(DoubleInputScreen):
54
+ class EditClassScreen(MultiInputScreen):
53
55
  def __init__(self, class_id: int, **kwargs) -> None:
54
- super().__init__(**kwargs)
56
+ super().__init__(2, **kwargs)
55
57
  self.class_id = class_id
56
58
  self.label_text = "Edit class"
57
59
 
58
60
  def on_mount(self) -> None:
59
61
  class_obj = get_class_by_id(self.db_session, self.class_id)
60
- self.input_button1.value = class_obj.name
62
+ self.buttons[0].value = class_obj.name
61
63
  if class_obj.exam_grade is not None:
62
- self.input_button2.value = str(class_obj.exam_grade)
64
+ self.buttons[1].value = str(class_obj.exam_grade)
63
65
  else:
64
- self.input_button2.value = ""
65
- self.input_button1.focus()
66
+ self.buttons[1].value = ""
67
+ self.buttons[0].focus()
66
68
 
67
69
  # Submit on Enter from any Input
68
70
  def submit(self) -> None:
69
- name = self.input_button1.value.strip()
70
- exam_score = self.input_button2.value.strip()
71
+ name = self.buttons[0].value.strip()
72
+ exam_score = self.buttons[1].value.strip()
71
73
  try:
72
74
  score = float(exam_score)
73
75
  except ValueError:
74
76
  score = None
77
+
78
+ if isinstance(score, float) and not math.isfinite(score):
79
+ score = None
75
80
  if not name:
76
81
  return # Require class name
77
82
 
@@ -101,6 +106,7 @@ class ClassScreen(Screen):
101
106
  super().__init__()
102
107
  self.db_session: Session = self.app.db_session # type: ignore
103
108
  self.semester_name = semester_name
109
+ self.latest_row = 0
104
110
 
105
111
  def compose(self) -> ComposeResult:
106
112
  yield Header()
@@ -147,9 +153,11 @@ class ClassScreen(Screen):
147
153
  score = cls.exam_grade
148
154
  str_score = ""
149
155
  if score is not None:
150
- str_score = cls.exam_grade
156
+ str_score = str(cls.exam_grade)
151
157
  self.class_table.add_row(cls.class_id, cls.name, str_score)
152
158
 
159
+ self.class_table.move_cursor(row=self.latest_row, column=0)
160
+
153
161
  def on_screen_resume(self) -> None:
154
162
  self.refresh_table()
155
163
 
@@ -158,6 +166,7 @@ class ClassScreen(Screen):
158
166
  row_index = self.class_table.cursor_row
159
167
  if row_index is None:
160
168
  return
169
+ self.latest_row = row_index
161
170
 
162
171
  class_id = self.class_table.get_row_at(row_index)[0]
163
172
  self.app.push_screen(ExamScreen(class_id))
@@ -1,4 +1,5 @@
1
- from rich.text import Text
1
+ import math
2
+
2
3
  from textual import on
3
4
  from textual.app import ComposeResult, Screen
4
5
  from textual.widgets import Footer, Header
@@ -10,7 +11,7 @@ from examtracker.database import (
10
11
  get_exam_by_id,
11
12
  remove_exam_by_id,
12
13
  )
13
- from examtracker.screens.inputscreens import TrippleInputScreen
14
+ from examtracker.screens.inputscreens import MultiInputScreen
14
15
  from examtracker.textual_utils.vimtable import VimTable
15
16
 
16
17
 
@@ -23,93 +24,108 @@ def proc_color(value: float) -> str:
23
24
  return "#fc1c03"
24
25
 
25
26
 
26
- class EditExamScreen(TrippleInputScreen):
27
+ class EditExamScreen(MultiInputScreen):
27
28
  def __init__(self, exam_id: int, **kwargs):
28
- super().__init__(**kwargs)
29
+ super().__init__(4, **kwargs)
29
30
  self.label_text = "Edit exam"
30
31
  self.exam_id = exam_id
31
- self.db_session = self.app.db_session # type:ignore
32
+ self.db_session = self.app.db_session # type: ignore
32
33
 
33
34
  def on_mount(self) -> None:
34
35
  exam = get_exam_by_id(self.db_session, self.exam_id)
35
36
 
36
- self.input_button1.value = exam.name
37
- self.input_button2.value = str(exam.max_points)
38
- self.input_button3.value = str(exam.scored_points)
37
+ self.buttons[0].value = exam.name
38
+ self.buttons[1].value = str(exam.max_points)
39
+ self.buttons[2].value = str(exam.scored_points)
40
+ self.buttons[3].value = str(exam.notes)
39
41
 
40
- self.input_button1.focus()
42
+ self.buttons[0].focus()
41
43
 
42
44
  def submit(self) -> None:
43
- name = self.input_button1.value.strip()
45
+ name = self.buttons[0].value.strip()
44
46
  if not name:
45
47
  return # Require class name
46
48
 
47
49
  # Optional exam points
48
50
  try:
49
- max_points = int(self.input_button2.value.strip())
51
+ max_points = float(self.buttons[1].value.strip())
50
52
  except ValueError:
51
53
  # Ignore invalid numbers for now
52
54
  max_points = 0
53
55
 
54
56
  try:
55
- scored_points = int(self.input_button3.value.strip())
57
+ scored_points = float(self.buttons[2].value.strip())
56
58
  except ValueError:
57
59
  # Ignore invalid numbers for now
58
60
  scored_points = 0
61
+ if not math.isfinite(scored_points):
62
+ scored_points = 0
63
+
64
+ notes = self.buttons[3].value.strip()
59
65
 
60
66
  exam = get_exam_by_id(self.db_session, self.exam_id)
61
67
  exam.name = name
62
68
  exam.max_points = max_points
63
69
  exam.scored_points = scored_points
70
+ exam.notes = notes
64
71
 
65
72
  self.db_session.commit()
66
73
 
67
74
  self.app.pop_screen()
68
75
 
69
76
 
70
- class AddExamScreen(TrippleInputScreen):
77
+ class AddExamScreen(MultiInputScreen):
71
78
  BINDINGS = [
72
79
  ("escape", "app.pop_screen", "Cancel"),
73
80
  ]
74
81
 
75
82
  def __init__(self, class_id: int, **kwargs):
76
- super().__init__(**kwargs)
83
+ super().__init__(4, **kwargs)
77
84
  self.db_session = self.app.db_session # type: ignore
78
85
  self.class_id = class_id
79
86
  self.class_name = get_class_by_id(self.db_session, self.class_id).name
80
87
  self.label_text = f"Add exam to: {self.class_name}"
81
88
 
82
89
  def on_mount(self) -> None:
83
- self.input_button1.focus()
84
- self.input_button1.placeholder = "Exam Name"
85
- self.input_button2.placeholder = "Max Points"
86
- self.input_button3.placeholder = "Scored Points"
90
+ self.buttons[0].focus()
91
+ self.buttons[0].placeholder = "Exam Name"
92
+ self.buttons[1].placeholder = "Max Points"
93
+ self.buttons[2].placeholder = "Scored Points"
94
+ self.buttons[3].placeholder = "Additional Notes"
87
95
 
88
- self.input_button1.value = ""
89
- self.input_button2.value = ""
90
- self.input_button3.value = ""
96
+ self.buttons[0].value = ""
97
+ self.buttons[1].value = ""
98
+ self.buttons[2].value = ""
99
+ self.buttons[3].value = ""
91
100
 
92
101
  def submit(self) -> None:
93
- name = self.input_button1.value.strip()
102
+ name = self.buttons[0].value.strip()
94
103
  if not name:
95
104
  return # Require class name
96
105
 
97
106
  try:
98
- max_points = int(self.input_button2.value.strip())
107
+ max_points = float(self.buttons[1].value.strip())
99
108
  except ValueError:
100
109
  # Ignore invalid numbers for now
101
110
  max_points = 0
102
111
 
103
112
  try:
104
- scored_points = int(self.input_button3.value.strip())
113
+ scored_points = float(self.buttons[2].value.strip())
105
114
  except ValueError:
106
115
  # Ignore invalid numbers for now
107
116
  scored_points = 0
108
117
 
118
+ if not math.isfinite(scored_points):
119
+ scored_points = 0
120
+
121
+ notes = self.buttons[3].value.strip()
122
+
109
123
  class_obj = get_class_by_id(self.db_session, self.class_id)
110
124
 
111
125
  # Add the class
112
- add_exam_to_class(self.db_session, name, max_points, scored_points, class_obj)
126
+ add_exam_to_class(
127
+ self.db_session, name, max_points, scored_points, notes, class_obj
128
+ )
113
129
  self.db_session.commit()
114
130
 
115
131
  # Pop the screen and return
@@ -134,7 +150,9 @@ class ExamScreen(Screen):
134
150
  yield Header()
135
151
 
136
152
  self.exam_table: VimTable = VimTable()
137
- self.exam_table.add_columns("ID", "Name", "Max_points", "Scored_points", "%")
153
+ self.exam_table.add_columns(
154
+ "ID", "Name", "Max_points", "Scored_points", "%", "Additional Notes"
155
+ )
138
156
  self.exam_table.cursor_type = "cell"
139
157
  self.exam_table.border_title = f"Exams completed for: {self.class_name}"
140
158
  yield self.exam_table
@@ -161,18 +179,25 @@ class ExamScreen(Screen):
161
179
  self.refresh_table()
162
180
 
163
181
  def refresh_table(self) -> None:
182
+ from rich.text import Text
183
+
164
184
  class_obj = get_class_by_id(self.db_session, self.class_id)
165
185
 
166
186
  self.exam_table.clear()
167
- for cls in get_all_exams_for_class(self.db_session, class_obj):
168
- if cls.max_points == 0:
187
+ for exm in get_all_exams_for_class(self.db_session, class_obj):
188
+ if exm.max_points == 0:
169
189
  proc = 0.0
170
190
  else:
171
- proc = round((cls.scored_points / cls.max_points) * 100, 2)
191
+ proc = round((exm.scored_points / exm.max_points) * 100, 2)
172
192
 
173
193
  proc_text = Text(str(proc), style=proc_color(proc))
174
194
  self.exam_table.add_row(
175
- cls.exam_id, cls.name, cls.max_points, cls.scored_points, proc_text
195
+ exm.exam_id,
196
+ exm.name,
197
+ exm.max_points,
198
+ exm.scored_points,
199
+ proc_text,
200
+ exm.notes,
176
201
  )
177
202
 
178
203
  def on_screen_resume(self) -> None:
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import on
4
+ from textual.app import ComposeResult, Screen
5
+ from textual.containers import Vertical
6
+ from textual.widgets import Footer, Header, Input, Label
7
+
8
+
9
+ class MultiInputScreen(Screen):
10
+ BINDINGS = [
11
+ ("escape", "app.pop_screen", "Cancel"),
12
+ ]
13
+ label_text: str = ""
14
+
15
+ def __init__(self, n: int, **kwargs) -> None:
16
+ super().__init__(**kwargs)
17
+ self.buttons: list[Input] = []
18
+ self.db_session = self.app.db_session # type: ignore
19
+ for i in range(n):
20
+ self.buttons.append(Input())
21
+
22
+ def compose(self) -> ComposeResult:
23
+ yield Header()
24
+ with Vertical():
25
+ yield Label(self.label_text)
26
+ for i in range(len(self.buttons)):
27
+ yield self.buttons[i]
28
+ yield Footer()
29
+
30
+ @on(Input.Submitted)
31
+ def input_submitted(self, event: Input.Submitted) -> None:
32
+ self.submit() # type: ignore
@@ -12,23 +12,23 @@ from examtracker.database import (
12
12
  remove_semester_by_name,
13
13
  )
14
14
  from examtracker.screens.classscreen import ClassScreen
15
- from examtracker.screens.inputscreens import SingleInputScreen
15
+ from examtracker.screens.inputscreens import MultiInputScreen
16
16
  from examtracker.textual_utils.vimtable import VimTable
17
17
 
18
18
 
19
- class AddSemesterScreen(SingleInputScreen):
19
+ class AddSemesterScreen(MultiInputScreen):
20
20
 
21
21
  def __init__(self, *args, **kwargs) -> None:
22
- super().__init__(*args, **kwargs)
22
+ super().__init__(1, *args, **kwargs)
23
23
  self.label_text = "Add new semester"
24
24
 
25
25
  def on_mount(self) -> None:
26
- self.input_button.focus()
27
- self.input_button.placeholder = "semester name"
28
- self.input_button.value = ""
26
+ self.buttons[0].focus()
27
+ self.buttons[0].placeholder = "semester name"
28
+ self.buttons[0].value = ""
29
29
 
30
30
  def submit(self) -> None:
31
- name = self.input_button.value.strip()
31
+ name = self.buttons[0].value.strip()
32
32
  if not name:
33
33
  return
34
34
  try:
@@ -42,20 +42,20 @@ class AddSemesterScreen(SingleInputScreen):
42
42
  self.app.pop_screen()
43
43
 
44
44
 
45
- class EditSemesterScreen(SingleInputScreen):
45
+ class EditSemesterScreen(MultiInputScreen):
46
46
 
47
47
  def __init__(self, semester_name: str, **kwargs) -> None:
48
- super().__init__(**kwargs)
48
+ super().__init__(1, **kwargs)
49
49
  self.semester_name = semester_name
50
50
  self.label_text = "Edit semester"
51
51
 
52
52
  def on_mount(self) -> None:
53
53
  semester = get_semester_by_name(self.db_session, self.semester_name)
54
- self.input_button.value = semester.name
55
- self.input_button.focus()
54
+ self.buttons[0].value = semester.name
55
+ self.buttons[0].focus()
56
56
 
57
57
  def submit(self) -> None:
58
- name = self.input_button.value.strip()
58
+ name = self.buttons[0].value.strip()
59
59
  if not name:
60
60
  return
61
61
  semester = get_semester_by_name(self.db_session, self.semester_name)
@@ -79,6 +79,7 @@ class SemesterScreen(Screen):
79
79
 
80
80
  def __init__(self) -> None:
81
81
  super().__init__()
82
+ self.latest_row = 0
82
83
 
83
84
  def compose(self) -> ComposeResult:
84
85
  yield Header()
@@ -98,6 +99,7 @@ class SemesterScreen(Screen):
98
99
  self.semester_table.clear()
99
100
  for sem in get_all_semester(self.db_session):
100
101
  self.semester_table.add_row(sem.name)
102
+ self.semester_table.move_cursor(row=self.latest_row, column=0)
101
103
 
102
104
  def action_edit(self) -> None:
103
105
  row_index = self.semester_table.cursor_row
@@ -133,6 +135,7 @@ class SemesterScreen(Screen):
133
135
  row_index = self.semester_table.cursor_row
134
136
  if row_index is None:
135
137
  return
138
+ self.latest_row = row_index
136
139
 
137
140
  semester_name = self.semester_table.get_row_at(row_index)[0]
138
141
  self.app.push_screen(ClassScreen(semester_name))
@@ -4,9 +4,8 @@ import os
4
4
  import sys
5
5
  from importlib.resources import files
6
6
  from pathlib import Path
7
- from typing import Any, Dict
7
+ from typing import Any
8
8
 
9
- import yaml # type: ignore
10
9
  from platformdirs import PlatformDirs
11
10
  from pydantic import Field
12
11
  from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -26,12 +25,12 @@ def get_config_dir() -> Path:
26
25
  return Path(dirs.user_config_dir)
27
26
 
28
27
 
29
- def get_default_database_path() -> Path:
28
+ def get_default_database_uri() -> str:
30
29
  """Return a writable database path in the user data directory."""
31
30
 
32
31
  db_dir = get_config_dir()
33
32
  db_dir.mkdir(parents=True, exist_ok=True)
34
- return db_dir / "examtracker.db"
33
+ return "sqlite:///" + str(db_dir / "examtracker.db")
35
34
 
36
35
 
37
36
  def get_default_css_path() -> Path:
@@ -39,7 +38,9 @@ def get_default_css_path() -> Path:
39
38
  return Path(files("examtracker").joinpath("data/style.css")) # type:ignore
40
39
 
41
40
 
42
- def yaml_config_settings_source(settings_cls) -> Dict[str, Any]:
41
+ def yaml_config_settings_source(settings_cls) -> dict[str, Any]:
42
+ import yaml # type: ignore
43
+
43
44
  config_path = os.getenv("EXAMTRACKER_CONFIG")
44
45
 
45
46
  if config_path:
@@ -58,7 +59,7 @@ def yaml_config_settings_source(settings_cls) -> Dict[str, Any]:
58
59
 
59
60
 
60
61
  class Settings(BaseSettings):
61
- database_path: str = Field(default_factory=lambda: str(get_default_database_path()))
62
+ database_uri: str = Field(default_factory=lambda: str(get_default_database_uri()))
62
63
  css_path: str = Field(default_factory=lambda: str(get_default_css_path()))
63
64
  model_config = SettingsConfigDict(
64
65
  env_prefix="EXAMTRACKER_",
@@ -87,10 +87,8 @@ def test_semester_name_uniqueness(name: str) -> None:
87
87
  session.commit()
88
88
 
89
89
  # Second insertion of the exact same name
90
- add_semester(session, name)
91
-
92
90
  with pytest.raises(IntegrityError):
93
- session.commit()
91
+ add_semester(session, name)
94
92
 
95
93
 
96
94
  # ------------- Class tests -------------
@@ -139,11 +137,18 @@ def test_get_nonexistant_class() -> None:
139
137
 
140
138
  @given(
141
139
  name=valid_text,
142
- max_points=integers(min_value=0, max_value=10000),
143
- scored_points=integers(min_value=0, max_value=10000),
140
+ max_points=floats(
141
+ min_value=0, max_value=10000, allow_infinity=False, allow_nan=False
142
+ ),
143
+ scored_points=floats(
144
+ min_value=0, max_value=10000, allow_infinity=False, allow_nan=False
145
+ ),
146
+ notes=text(),
144
147
  )
145
148
  @settings(deadline=None)
146
- def test_add_get_exam(name: str, max_points: int, scored_points: int) -> None:
149
+ def test_add_get_exam(
150
+ name: str, max_points: float, scored_points: float, notes: str
151
+ ) -> None:
147
152
  with temp_db() as session:
148
153
  # Setup Hierarchy: Semester -> Class
149
154
  add_semester(session, "S1")
@@ -151,7 +156,7 @@ def test_add_get_exam(name: str, max_points: int, scored_points: int) -> None:
151
156
  add_class_to_semester(session, "C1", sem)
152
157
  cls = sem.classes[0]
153
158
 
154
- add_exam_to_class(session, name, max_points, scored_points, cls)
159
+ add_exam_to_class(session, name, max_points, scored_points, notes, cls)
155
160
  session.commit()
156
161
 
157
162
  # Verify exam data
@@ -160,6 +165,7 @@ def test_add_get_exam(name: str, max_points: int, scored_points: int) -> None:
160
165
  assert exams[0].name == name
161
166
  assert exams[0].max_points == max_points
162
167
  assert exams[0].scored_points == scored_points
168
+ assert exams[0].notes == notes
163
169
 
164
170
 
165
171
  def test_get_nonexistant_exam() -> None:
@@ -181,7 +187,7 @@ def test_remove_semester_cascades_to_classes_and_exams() -> None:
181
187
  add_class_to_semester(session, "Nested Class", sem)
182
188
  cls = sem.classes[0]
183
189
 
184
- add_exam_to_class(session, "Nested Exam", 100, 90, cls)
190
+ add_exam_to_class(session, "Nested Exam", 100, 90, "notes", cls)
185
191
  session.commit()
186
192
 
187
193
  # Capture IDs to verify deletion later
@@ -229,7 +235,7 @@ def test_remove_exam_by_id_logic() -> None:
229
235
  sem = get_semester_by_name(session, "S1")
230
236
  add_class_to_semester(session, "C1", sem)
231
237
  cls = sem.classes[0]
232
- add_exam_to_class(session, "Final", 100, 100, cls)
238
+ add_exam_to_class(session, "Final", 100, 100, "notes", cls)
233
239
  session.commit()
234
240
 
235
241
  exam_id = cls.exams[0].exam_id
@@ -259,7 +265,7 @@ def test_empty_relationships_return_empty_list() -> None:
259
265
  assert get_all_exams_for_class(session, cls) == []
260
266
 
261
267
 
262
- @given(pts=integers(min_value=0, max_value=2147483647)) # Max 32-bit signed int
268
+ @given(pts=floats(allow_infinity=False, allow_nan=False)) # Max 32-bit signed int
263
269
  def test_exam_score_boundaries(pts: int) -> None:
264
270
  """Tests that the database handles large point values correctly."""
265
271
  with temp_db() as session:
@@ -268,7 +274,7 @@ def test_exam_score_boundaries(pts: int) -> None:
268
274
  add_class_to_semester(session, "Math", sem)
269
275
  cls = sem.classes[0]
270
276
 
271
- add_exam_to_class(session, "Big Score", pts, pts, cls)
277
+ add_exam_to_class(session, "Big Score", pts, pts, "notes", cls)
272
278
  session.commit()
273
279
 
274
280
  exam = get_all_exams_for_class(session, cls)[0]
@@ -1,81 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from textual import on
4
- from textual.app import ComposeResult, Screen
5
- from textual.containers import Vertical
6
- from textual.widgets import Footer, Header, Input, Label
7
-
8
-
9
- class SingleInputScreen(Screen):
10
- BINDINGS = [
11
- ("escape", "app.pop_screen", "Cancel"),
12
- ]
13
- input_button: Input = Input()
14
- label_text: str = ""
15
-
16
- def __init__(self) -> None:
17
- super().__init__()
18
- self.db_session = self.app.db_session # type: ignore
19
-
20
- def compose(self) -> ComposeResult:
21
- yield Header()
22
- with Vertical():
23
- yield Label(self.label_text)
24
- yield self.input_button
25
- yield Footer()
26
-
27
- @on(Input.Submitted)
28
- def input_submitted(self, event: Input.Submitted) -> None:
29
- self.submit() # type: ignore
30
-
31
-
32
- class TrippleInputScreen(Screen):
33
- BINDINGS = [
34
- ("escape", "app.pop_screen", "Cancel"),
35
- ]
36
- input_button1: Input = Input()
37
- input_button2: Input = Input()
38
- input_button3: Input = Input()
39
- label_text: str = ""
40
-
41
- def __init__(self) -> None:
42
- super().__init__()
43
- self.db_session = self.app.db_session # type: ignore
44
-
45
- def compose(self) -> ComposeResult:
46
- yield Header()
47
- with Vertical():
48
- yield Label(self.label_text)
49
- yield self.input_button1
50
- yield self.input_button2
51
- yield self.input_button3
52
- yield Footer()
53
-
54
- @on(Input.Submitted)
55
- def input_submitted(self, event: Input.Submitted) -> None:
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
File without changes
File without changes