banu4prasad commited on
Commit
54d7e31
·
1 Parent(s): 29c94c3

feat: Add alembic migrations and database foreign key indexes

Browse files
backend/alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+
24
+ # timezone to use when rendering the date within the migration file
25
+ # as well as the filename.
26
+ # If specified, requires the tzdata library which can be installed by adding
27
+ # `alembic[tz]` to the pip requirements.
28
+ # string value is passed to ZoneInfo()
29
+ # leave blank for localtime
30
+ # timezone =
31
+
32
+ # max length of characters to apply to the "slug" field
33
+ # truncate_slug_length = 40
34
+
35
+ # set to 'true' to run the environment during
36
+ # the 'revision' command, regardless of autogenerate
37
+ # revision_environment = false
38
+
39
+ # set to 'true' to allow .pyc and .pyo files without
40
+ # a source .py file to be detected as revisions in the
41
+ # versions/ directory
42
+ # sourceless = false
43
+
44
+ # version location specification; This defaults
45
+ # to <script_location>/versions. When using multiple version
46
+ # directories, initial revisions must be specified with --version-path.
47
+ # The path separator used here should be the separator specified by "path_separator"
48
+ # below.
49
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
50
+
51
+ # path_separator; This indicates what character is used to split lists of file
52
+ # paths, including version_locations and prepend_sys_path within configparser
53
+ # files such as alembic.ini.
54
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
55
+ # to provide os-dependent path splitting.
56
+ #
57
+ # Note that in order to support legacy alembic.ini files, this default does NOT
58
+ # take place if path_separator is not present in alembic.ini. If this
59
+ # option is omitted entirely, fallback logic is as follows:
60
+ #
61
+ # 1. Parsing of the version_locations option falls back to using the legacy
62
+ # "version_path_separator" key, which if absent then falls back to the legacy
63
+ # behavior of splitting on spaces and/or commas.
64
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
65
+ # behavior of splitting on spaces, commas, or colons.
66
+ #
67
+ # Valid values for path_separator are:
68
+ #
69
+ # path_separator = :
70
+ # path_separator = ;
71
+ # path_separator = space
72
+ # path_separator = newline
73
+ #
74
+ # Use os.pathsep. Default configuration used for new projects.
75
+ path_separator = os
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = driver://user:pass@localhost/dbname
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
backend/alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration.
backend/alembic/env.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from sqlalchemy import engine_from_config
4
+ from sqlalchemy import pool
5
+
6
+ from alembic import context
7
+
8
+ from app.core.config import settings
9
+ from app.core.database import Base
10
+ from app.models import models
11
+
12
+ # this is the Alembic Config object, which provides
13
+ # access to the values within the .ini file in use.
14
+ config = context.config
15
+
16
+ # Interpret the config file for Python logging.
17
+ # This line sets up loggers basically.
18
+ if config.config_file_name is not None:
19
+ fileConfig(config.config_file_name)
20
+
21
+ # add your model's MetaData object here
22
+ # for 'autogenerate' support
23
+ # from myapp import mymodel
24
+ # target_metadata = mymodel.Base.metadata
25
+ target_metadata = Base.metadata
26
+
27
+ escaped_url = settings.sqlalchemy_database_url.replace('%', '%%')
28
+ config.set_main_option("sqlalchemy.url", escaped_url)
29
+
30
+ # other values from the config, defined by the needs of env.py,
31
+ # can be acquired:
32
+ # my_important_option = config.get_main_option("my_important_option")
33
+ # ... etc.
34
+
35
+
36
+ def run_migrations_offline() -> None:
37
+ """Run migrations in 'offline' mode.
38
+
39
+ This configures the context with just a URL
40
+ and not an Engine, though an Engine is acceptable
41
+ here as well. By skipping the Engine creation
42
+ we don't even need a DBAPI to be available.
43
+
44
+ Calls to context.execute() here emit the given string to the
45
+ script output.
46
+
47
+ """
48
+ url = config.get_main_option("sqlalchemy.url")
49
+ context.configure(
50
+ url=url,
51
+ target_metadata=target_metadata,
52
+ literal_binds=True,
53
+ dialect_opts={"paramstyle": "named"},
54
+ )
55
+
56
+ with context.begin_transaction():
57
+ context.run_migrations()
58
+
59
+
60
+ def run_migrations_online() -> None:
61
+ """Run migrations in 'online' mode.
62
+
63
+ In this scenario we need to create an Engine
64
+ and associate a connection with the context.
65
+
66
+ """
67
+ connectable = engine_from_config(
68
+ config.get_section(config.config_ini_section, {}),
69
+ prefix="sqlalchemy.",
70
+ poolclass=pool.NullPool,
71
+ )
72
+
73
+ with connectable.connect() as connection:
74
+ context.configure(
75
+ connection=connection, target_metadata=target_metadata
76
+ )
77
+
78
+ with context.begin_transaction():
79
+ context.run_migrations()
80
+
81
+
82
+ if context.is_offline_mode():
83
+ run_migrations_offline()
84
+ else:
85
+ run_migrations_online()
backend/alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
backend/alembic/versions/54e3661375a4_add_foreign_key_indexes.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add foreign key indexes
2
+
3
+ Revision ID: 54e3661375a4
4
+ Revises:
5
+ Create Date: 2026-06-07 23:49:28.611574
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ from sqlalchemy.dialects import postgresql
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '54e3661375a4'
16
+ down_revision: Union[str, Sequence[str], None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ op.create_index(op.f('ix_checklist_subjects_created_by'), 'checklist_subjects', ['created_by'], unique=False)
24
+ op.create_index(op.f('ix_checklist_topics_subject_id'), 'checklist_topics', ['subject_id'], unique=False)
25
+ op.create_index(op.f('ix_questions_test_id'), 'questions', ['test_id'], unique=False)
26
+ op.create_index(op.f('ix_test_attempts_test_id'), 'test_attempts', ['test_id'], unique=False)
27
+ op.create_index('ix_test_attempts_test_id_status_user_id', 'test_attempts', ['test_id', 'status', 'user_id'], unique=False)
28
+ op.create_index(op.f('ix_test_attempts_user_id'), 'test_attempts', ['user_id'], unique=False)
29
+ op.create_index(op.f('ix_test_series_created_by'), 'test_series', ['created_by'], unique=False)
30
+ op.create_index(op.f('ix_tests_created_by'), 'tests', ['created_by'], unique=False)
31
+ op.create_index(op.f('ix_tests_series_id'), 'tests', ['series_id'], unique=False)
32
+ op.create_index(op.f('ix_user_answers_attempt_id'), 'user_answers', ['attempt_id'], unique=False)
33
+ op.create_index(op.f('ix_user_answers_question_id'), 'user_answers', ['question_id'], unique=False)
34
+
35
+
36
+ def downgrade() -> None:
37
+ """Downgrade schema."""
38
+ op.drop_index(op.f('ix_user_answers_question_id'), table_name='user_answers')
39
+ op.drop_index(op.f('ix_user_answers_attempt_id'), table_name='user_answers')
40
+ op.drop_index(op.f('ix_tests_series_id'), table_name='tests')
41
+ op.drop_index(op.f('ix_tests_created_by'), table_name='tests')
42
+ op.drop_index(op.f('ix_test_series_created_by'), table_name='test_series')
43
+ op.drop_index(op.f('ix_test_attempts_user_id'), table_name='test_attempts')
44
+ op.drop_index('ix_test_attempts_test_id_status_user_id', table_name='test_attempts')
45
+ op.drop_index(op.f('ix_test_attempts_test_id'), table_name='test_attempts')
46
+ op.drop_index(op.f('ix_questions_test_id'), table_name='questions')
47
+ op.drop_index(op.f('ix_checklist_topics_subject_id'), table_name='checklist_topics')
48
+ op.drop_index(op.f('ix_checklist_subjects_created_by'), table_name='checklist_subjects')
backend/app/models/models.py CHANGED
@@ -2,7 +2,7 @@ import enum
2
  from datetime import datetime, timezone
3
 
4
  from sqlalchemy import (JSON, Boolean, DateTime, Enum, Float,
5
- ForeignKey, Integer, String, Text, UniqueConstraint)
6
  from sqlalchemy.orm import relationship, Mapped, mapped_column
7
 
8
  from app.core.database import Base
@@ -82,7 +82,7 @@ class TestSeries(Base):
82
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
83
  title: Mapped[str] = mapped_column(String(500), nullable=False)
84
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
85
- created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"))
86
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
87
 
88
  tests = relationship("Test", back_populates="series")
@@ -99,13 +99,13 @@ class Test(Base):
99
  total_marks: Mapped[float] = mapped_column(Float, default=0.0)
100
  pdf_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
101
  is_published: Mapped[bool] = mapped_column(Boolean, default=True)
102
- series_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("test_series.id"), nullable=True)
103
  series_order: Mapped[int] = mapped_column(Integer, default=0)
104
  category: Mapped[str | None] = mapped_column(String(50), nullable=True)
105
  series_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
106
  test_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
107
  subject: Mapped[str | None] = mapped_column(String(100), nullable=True)
108
- created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"))
109
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
110
 
111
  questions = relationship(
@@ -127,7 +127,7 @@ class Test(Base):
127
  class Question(Base):
128
  __tablename__ = "questions"
129
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
130
- test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
131
  question_type: Mapped[QuestionType] = mapped_column(Enum(QuestionType), default=QuestionType.mcq, nullable=False)
132
  question_text: Mapped[str] = mapped_column(Text, nullable=False)
133
  question_image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
@@ -151,8 +151,8 @@ class TestAttempt(Base):
151
  __test__ = False
152
  __tablename__ = "test_attempts"
153
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
154
- user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
155
- test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
156
  status: Mapped[TestStatus] = mapped_column(Enum(TestStatus), default=TestStatus.in_progress)
157
  started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
158
  submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -161,6 +161,10 @@ class TestAttempt(Base):
161
  tab_violations: Mapped[int] = mapped_column(Integer, default=0)
162
  fullscreen_violations: Mapped[int] = mapped_column(Integer, default=0)
163
 
 
 
 
 
164
  user = relationship("User", back_populates="attempts")
165
  test = relationship("Test", back_populates="attempts")
166
  answers = relationship(
@@ -187,8 +191,8 @@ class PracticeAttemptCounter(Base):
187
  class UserAnswer(Base):
188
  __tablename__ = "user_answers"
189
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
190
- attempt_id: Mapped[int] = mapped_column(Integer, ForeignKey("test_attempts.id"), nullable=False)
191
- question_id: Mapped[int] = mapped_column(Integer, ForeignKey("questions.id"), nullable=False)
192
  selected_answer: Mapped[str | None] = mapped_column(String(500), nullable=True)
193
  is_correct: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
194
  marks_awarded: Mapped[float] = mapped_column(Float, default=0.0)
@@ -223,7 +227,7 @@ class ChecklistSubject(Base):
223
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
224
  name: Mapped[str] = mapped_column(String(200), nullable=False)
225
  order_index: Mapped[int] = mapped_column(Integer, default=0)
226
- created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"))
227
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
228
 
229
  topics = relationship(
@@ -234,7 +238,7 @@ class ChecklistSubject(Base):
234
  class ChecklistTopic(Base):
235
  __tablename__ = "checklist_topics"
236
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
237
- subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("checklist_subjects.id"), nullable=False)
238
  name: Mapped[str] = mapped_column(String(200), nullable=False)
239
  order_index: Mapped[int] = mapped_column(Integer, default=0)
240
 
 
2
  from datetime import datetime, timezone
3
 
4
  from sqlalchemy import (JSON, Boolean, DateTime, Enum, Float,
5
+ ForeignKey, Integer, String, Text, UniqueConstraint, Index)
6
  from sqlalchemy.orm import relationship, Mapped, mapped_column
7
 
8
  from app.core.database import Base
 
82
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
83
  title: Mapped[str] = mapped_column(String(500), nullable=False)
84
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
85
+ created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), index=True)
86
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
87
 
88
  tests = relationship("Test", back_populates="series")
 
99
  total_marks: Mapped[float] = mapped_column(Float, default=0.0)
100
  pdf_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
101
  is_published: Mapped[bool] = mapped_column(Boolean, default=True)
102
+ series_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("test_series.id"), nullable=True, index=True)
103
  series_order: Mapped[int] = mapped_column(Integer, default=0)
104
  category: Mapped[str | None] = mapped_column(String(50), nullable=True)
105
  series_name: Mapped[str | None] = mapped_column(String(50), nullable=True)
106
  test_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
107
  subject: Mapped[str | None] = mapped_column(String(100), nullable=True)
108
+ created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), index=True)
109
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
110
 
111
  questions = relationship(
 
127
  class Question(Base):
128
  __tablename__ = "questions"
129
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
130
+ test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False, index=True)
131
  question_type: Mapped[QuestionType] = mapped_column(Enum(QuestionType), default=QuestionType.mcq, nullable=False)
132
  question_text: Mapped[str] = mapped_column(Text, nullable=False)
133
  question_image_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
 
151
  __test__ = False
152
  __tablename__ = "test_attempts"
153
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
154
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
155
+ test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False, index=True)
156
  status: Mapped[TestStatus] = mapped_column(Enum(TestStatus), default=TestStatus.in_progress)
157
  started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
158
  submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
 
161
  tab_violations: Mapped[int] = mapped_column(Integer, default=0)
162
  fullscreen_violations: Mapped[int] = mapped_column(Integer, default=0)
163
 
164
+ __table_args__ = (
165
+ Index("ix_test_attempts_test_id_status_user_id", "test_id", "status", "user_id"),
166
+ )
167
+
168
  user = relationship("User", back_populates="attempts")
169
  test = relationship("Test", back_populates="attempts")
170
  answers = relationship(
 
191
  class UserAnswer(Base):
192
  __tablename__ = "user_answers"
193
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
194
+ attempt_id: Mapped[int] = mapped_column(Integer, ForeignKey("test_attempts.id"), nullable=False, index=True)
195
+ question_id: Mapped[int] = mapped_column(Integer, ForeignKey("questions.id"), nullable=False, index=True)
196
  selected_answer: Mapped[str | None] = mapped_column(String(500), nullable=True)
197
  is_correct: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
198
  marks_awarded: Mapped[float] = mapped_column(Float, default=0.0)
 
227
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
228
  name: Mapped[str] = mapped_column(String(200), nullable=False)
229
  order_index: Mapped[int] = mapped_column(Integer, default=0)
230
+ created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), index=True)
231
  created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
232
 
233
  topics = relationship(
 
238
  class ChecklistTopic(Base):
239
  __tablename__ = "checklist_topics"
240
  id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
241
+ subject_id: Mapped[int] = mapped_column(Integer, ForeignKey("checklist_subjects.id"), nullable=False, index=True)
242
  name: Mapped[str] = mapped_column(String(200), nullable=False)
243
  order_index: Mapped[int] = mapped_column(Integer, default=0)
244