Skip to content

Commit 0574495

Browse files
author
Nils Bars
committed
Update ORM models to use SQLAlchemy 2.0 mapped_column
Migrate all model definitions from the legacy db.Column() style to the modern mapped_column() function with proper Mapped type annotations. Changes: - Replace db.Column() with mapped_column() from sqlalchemy.orm - Add Mapped[] type annotations for proper type inference - Remove __allow_unmapped__ = True from all model classes - Import relationship from sqlalchemy.orm instead of using db.relationship - Use ForeignKey from sqlalchemy instead of db.ForeignKey - Fix implicit Optional in ConfigParsingError constructor - Use raw string for SSH welcome message to fix escape sequence warning Closes #18
1 parent ecf740b commit 0574495

4 files changed

Lines changed: 197 additions & 225 deletions

File tree

webapp/ref/model/exercise.py

Lines changed: 84 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
from __future__ import annotations
22

33
import datetime
4-
import typing
54
from collections import defaultdict
6-
from typing import List
5+
from typing import TYPE_CHECKING, List, Optional
76

87
from flask import current_app
9-
from sqlalchemy import PickleType, and_
8+
from sqlalchemy import ForeignKey, PickleType, Text, and_
9+
from sqlalchemy.orm import Mapped, mapped_column, relationship
1010

1111
from ref import db
1212

1313
from .enums import ExerciseBuildStatus
14-
from .instance import Instance, Submission
1514
from .util import CommonDbOpsMixin, ModelToStringMixin
1615

16+
if TYPE_CHECKING:
17+
from .instance import Instance, InstanceService, Submission
18+
1719

1820
class ConfigParsingError(Exception):
19-
def __init__(self, msg: str, path: str = None):
21+
def __init__(self, msg: str, path: Optional[str] = None):
2022
if path:
2123
msg = f"{msg} ({path})"
2224
super().__init__(msg)
@@ -33,16 +35,17 @@ class RessourceLimits(CommonDbOpsMixin, ModelToStringMixin, db.Model):
3335
"memory_kernel_in_mb",
3436
]
3537
__tablename__ = "exercise_ressource_limits"
36-
id = db.Column(db.Integer, primary_key=True)
3738

38-
cpu_cnt_max: float = db.Column(db.Float(), nullable=True, default=None)
39-
cpu_shares: int = db.Column(db.Integer(), nullable=True, default=None)
39+
id: Mapped[int] = mapped_column(primary_key=True)
40+
41+
cpu_cnt_max: Mapped[Optional[float]] = mapped_column(default=None)
42+
cpu_shares: Mapped[Optional[int]] = mapped_column(default=None)
4043

41-
pids_max: int = db.Column(db.Integer(), nullable=True, default=None)
44+
pids_max: Mapped[Optional[int]] = mapped_column(default=None)
4245

43-
memory_in_mb: int = db.Column(db.Integer(), nullable=True, default=None)
44-
memory_swap_in_mb: int = db.Column(db.Integer(), nullable=True, default=None)
45-
memory_kernel_in_mb: int = db.Column(db.Integer(), nullable=True, default=None)
46+
memory_in_mb: Mapped[Optional[int]] = mapped_column(default=None)
47+
memory_swap_in_mb: Mapped[Optional[int]] = mapped_column(default=None)
48+
memory_kernel_in_mb: Mapped[Optional[int]] = mapped_column(default=None)
4649

4750

4851
class ExerciseEntryService(CommonDbOpsMixin, ModelToStringMixin, db.Model):
@@ -53,52 +56,47 @@ class ExerciseEntryService(CommonDbOpsMixin, ModelToStringMixin, db.Model):
5356

5457
__to_str_fields__ = ["id", "exercise_id"]
5558
__tablename__ = "exercise_entry_service"
56-
__allow_unmapped__ = True
5759

58-
id = db.Column(db.Integer, primary_key=True)
60+
id: Mapped[int] = mapped_column(primary_key=True)
5961

6062
# The exercise this entry service belongs to
61-
exercise_id: int = db.Column(
62-
db.Integer, db.ForeignKey("exercise.id", ondelete="RESTRICT"), nullable=False
63+
exercise_id: Mapped[int] = mapped_column(
64+
ForeignKey("exercise.id", ondelete="RESTRICT")
6365
)
64-
exercise: "Exercise" = db.relationship(
66+
exercise: Mapped["Exercise"] = relationship(
6567
"Exercise", foreign_keys=[exercise_id], back_populates="entry_service"
6668
)
6769

6870
# Path inside the container that is persistet
69-
persistance_container_path: str = db.Column(db.Text(), nullable=True)
71+
persistance_container_path: Mapped[Optional[str]] = mapped_column(Text)
7072

71-
files: List[str] = db.Column(PickleType(), nullable=True)
73+
files: Mapped[Optional[List[str]]] = mapped_column(PickleType)
7274

7375
# List of commands that are executed when building the service's Docker image.
74-
build_cmd: List[str] = db.Column(db.PickleType(), nullable=True)
76+
build_cmd: Mapped[Optional[List[str]]] = mapped_column(PickleType)
7577

76-
no_randomize_files: typing.Optional[List[str]] = db.Column(
77-
db.PickleType(), nullable=True
78-
)
78+
no_randomize_files: Mapped[Optional[List[str]]] = mapped_column(PickleType)
7979

80-
disable_aslr: bool = db.Column(db.Boolean(), nullable=False)
80+
disable_aslr: Mapped[bool]
8181

8282
# Command that is executed as soon a user connects (list)
83-
cmd: List[str] = db.Column(db.PickleType(), nullable=False)
83+
cmd: Mapped[List[str]] = mapped_column(PickleType)
8484

85-
readonly: bool = db.Column(db.Boolean(), nullable=False, default=False)
85+
readonly: Mapped[bool] = mapped_column(default=False)
8686

87-
allow_internet: bool = db.Column(db.Boolean(), nullable=False, default=False)
87+
allow_internet: Mapped[bool] = mapped_column(default=False)
8888

8989
# options for the flag that is placed inside the container
90-
flag_path: str = db.Column(db.Text(), nullable=True)
91-
flag_value: str = db.Column(db.Text(), nullable=True)
92-
flag_user: str = db.Column(db.Text(), nullable=True)
93-
flag_group: str = db.Column(db.Text(), nullable=True)
94-
flag_permission: str = db.Column(db.Text(), nullable=True)
95-
96-
ressource_limit_id: int = db.Column(
97-
db.Integer,
98-
db.ForeignKey("exercise_ressource_limits.id", ondelete="RESTRICT"),
99-
nullable=True,
90+
flag_path: Mapped[Optional[str]] = mapped_column(Text)
91+
flag_value: Mapped[Optional[str]] = mapped_column(Text)
92+
flag_user: Mapped[Optional[str]] = mapped_column(Text)
93+
flag_group: Mapped[Optional[str]] = mapped_column(Text)
94+
flag_permission: Mapped[Optional[str]] = mapped_column(Text)
95+
96+
ressource_limit_id: Mapped[Optional[int]] = mapped_column(
97+
ForeignKey("exercise_ressource_limits.id", ondelete="RESTRICT")
10098
)
101-
ressource_limit: RessourceLimits = db.relationship(
99+
ressource_limit: Mapped[Optional[RessourceLimits]] = relationship(
102100
"RessourceLimits", foreign_keys=[ressource_limit_id]
103101
)
104102

@@ -127,44 +125,41 @@ class ExerciseService(CommonDbOpsMixin, ModelToStringMixin, db.Model):
127125

128126
__to_str_fields__ = ["id", "exercise_id"]
129127
__tablename__ = "exercise_service"
130-
__allow_unmapped__ = True
131128

132-
id: int = db.Column(db.Integer, primary_key=True)
129+
id: Mapped[int] = mapped_column(primary_key=True)
133130

134-
name: str = db.Column(db.Text())
131+
name: Mapped[Optional[str]] = mapped_column(Text)
135132

136133
# Backref is exercise
137-
exercise_id: int = db.Column(
138-
db.Integer, db.ForeignKey("exercise.id", ondelete="RESTRICT"), nullable=False
134+
exercise_id: Mapped[int] = mapped_column(
135+
ForeignKey("exercise.id", ondelete="RESTRICT")
139136
)
140-
exercise: "Exercise" = db.relationship(
137+
exercise: Mapped["Exercise"] = relationship(
141138
"Exercise", foreign_keys=[exercise_id], back_populates="services"
142139
)
143140

144-
files: List[str] = db.Column(PickleType(), nullable=True)
145-
build_cmd: List[str] = db.Column(db.PickleType(), nullable=True)
141+
files: Mapped[Optional[List[str]]] = mapped_column(PickleType)
142+
build_cmd: Mapped[Optional[List[str]]] = mapped_column(PickleType)
146143

147-
disable_aslr: bool = db.Column(db.Boolean(), nullable=False)
148-
cmd: List[str] = db.Column(db.PickleType(), nullable=False)
144+
disable_aslr: Mapped[bool]
145+
cmd: Mapped[List[str]] = mapped_column(PickleType)
149146

150-
readonly: bool = db.Column(db.Boolean(), nullable=True, default=False)
147+
readonly: Mapped[Optional[bool]] = mapped_column(default=False)
151148

152-
allow_internet: bool = db.Column(db.Boolean(), nullable=True, default=False)
149+
allow_internet: Mapped[Optional[bool]] = mapped_column(default=False)
153150

154-
instances: List[Instance] = db.relationship(
151+
instances: Mapped[List["InstanceService"]] = relationship(
155152
"InstanceService",
156153
back_populates="exercise_service",
157154
lazy=True,
158155
passive_deletes="all",
159156
)
160157

161-
# health_check_cmd: List[str] = db.Column(db.PickleType(), nullable=False)
162-
163-
flag_path: str = db.Column(db.Text(), nullable=True)
164-
flag_value: str = db.Column(db.Text(), nullable=True)
165-
flag_user: str = db.Column(db.Text(), nullable=True)
166-
flag_group: str = db.Column(db.Text(), nullable=True)
167-
flag_permission: str = db.Column(db.Text(), nullable=True)
158+
flag_path: Mapped[Optional[str]] = mapped_column(Text)
159+
flag_value: Mapped[Optional[str]] = mapped_column(Text)
160+
flag_user: Mapped[Optional[str]] = mapped_column(Text)
161+
flag_group: Mapped[Optional[str]] = mapped_column(Text)
162+
flag_permission: Mapped[Optional[str]] = mapped_column(Text)
168163

169164
@property
170165
def image_name(self) -> str:
@@ -184,71 +179,66 @@ class Exercise(CommonDbOpsMixin, ModelToStringMixin, db.Model):
184179

185180
__to_str_fields__ = ["id", "short_name", "version", "category", "build_job_status"]
186181
__tablename__ = "exercise"
187-
__allow_unmapped__ = True
188182

189-
id: int = db.Column(db.Integer, primary_key=True)
183+
id: Mapped[int] = mapped_column(primary_key=True)
190184

191185
# The services that defines the entrypoint of this exercise
192-
entry_service: ExerciseEntryService = db.relationship(
186+
entry_service: Mapped[Optional[ExerciseEntryService]] = relationship(
193187
"ExerciseEntryService",
194188
uselist=False,
195189
back_populates="exercise",
196190
passive_deletes="all",
197191
)
198192

199193
# Additional services that are mapped into the network for this exercise.
200-
services: List[ExerciseService] = db.relationship(
194+
services: Mapped[List[ExerciseService]] = relationship(
201195
"ExerciseService", back_populates="exercise", lazy=True, passive_deletes="all"
202196
)
203197

204198
# Folder the template was initially imported from
205-
template_import_path: str = db.Column(db.Text(), nullable=False, unique=False)
199+
template_import_path: Mapped[str] = mapped_column(Text)
206200

207201
# Folder where a copy of the template is stored for persisting it after import
208-
template_path: str = db.Column(db.Text(), nullable=False, unique=True)
202+
template_path: Mapped[str] = mapped_column(Text, unique=True)
209203

210204
# Path to the folder that contains all persisted data of this exercise.
211-
persistence_path: str = db.Column(db.Text(), nullable=False, unique=True)
205+
persistence_path: Mapped[str] = mapped_column(Text, unique=True)
212206

213207
# Name that identifies the exercise
214-
short_name: str = db.Column(db.Text(), nullable=False, unique=False)
208+
short_name: Mapped[str] = mapped_column(Text)
215209

216210
# Version of the exercise used for updating mechanism.
217-
version: int = db.Column(db.Integer(), nullable=False)
211+
version: Mapped[int]
218212

219213
# Used to group the exercises
220-
category: str = db.Column(db.Text(), nullable=True, unique=False)
214+
category: Mapped[Optional[str]] = mapped_column(Text)
221215

222216
# Instances must be submitted before this point in time.
223-
submission_deadline_end: datetime.datetime = db.Column(db.DateTime(), nullable=True)
217+
submission_deadline_end: Mapped[Optional[datetime.datetime]]
224218

225-
submission_deadline_start: datetime.datetime = db.Column(
226-
db.DateTime(), nullable=True
227-
)
219+
submission_deadline_start: Mapped[Optional[datetime.datetime]]
228220

229-
submission_test_enabled: datetime.datetime = db.Column(db.Boolean(), nullable=False)
221+
submission_test_enabled: Mapped[bool]
230222

231223
# Max point a user can get for this exercise. Might be None.
232-
max_grading_points: int = db.Column(db.Integer, nullable=True)
224+
max_grading_points: Mapped[Optional[int]]
233225

234226
# Is this Exercise version deployed by default in case an instance is requested?
235227
# At most one exercise with same short_name can have this flag.
236-
is_default: bool = db.Column(db.Boolean(), nullable=False)
228+
is_default: Mapped[bool]
237229

238230
# Log of the last build run
239-
build_job_result: str = db.Column(db.Text(), nullable=True)
231+
build_job_result: Mapped[Optional[str]] = mapped_column(Text)
240232

241233
# Build status of the docker images that belong to the exercise
242-
build_job_status: ExerciseBuildStatus = db.Column(
243-
db.Enum(ExerciseBuildStatus), nullable=False
244-
)
234+
build_job_status: Mapped[ExerciseBuildStatus]
245235

246236
# All running instances of this exercise
247-
instances: List[Instance] = db.relationship(
237+
instances: Mapped[List["Instance"]] = relationship(
248238
"Instance", back_populates="exercise", lazy=True, passive_deletes="all"
249239
)
250240

251-
def get_users_instance(self, user) -> List[Instance]:
241+
def get_users_instance(self, user) -> List["Instance"]:
252242
for instance in self.instances:
253243
if instance.user == user:
254244
return instance
@@ -270,7 +260,7 @@ def predecessors(self) -> List[Exercise]:
270260
def is_update(self) -> bool:
271261
return len(self.predecessors()) > 0
272262

273-
def predecessor(self) -> Exercise:
263+
def predecessor(self) -> Optional[Exercise]:
274264
predecessors = self.predecessors()
275265
if predecessors:
276266
return predecessors[0]
@@ -298,29 +288,29 @@ def successors(self) -> List[Exercise]:
298288
)
299289
return exercises
300290

301-
def successor(self) -> Exercise:
291+
def successor(self) -> Optional[Exercise]:
302292
successors = self.successors()
303293
if successors:
304294
return successors[0]
305295
else:
306296
return None
307297

308-
def head(self) -> Exercise:
298+
def head(self) -> Optional[Exercise]:
309299
"""
310300
Returns the newest version of this exercise.
311301
"""
312302
ret = self.successors() + [self]
313303
return max(ret, key=lambda e: e.version, default=None)
314304

315-
def tail(self) -> Exercise:
305+
def tail(self) -> Optional[Exercise]:
316306
"""
317307
Returns the oldest version of this exercise.
318308
"""
319309
ret = self.predecessors() + [self]
320310
return min(ret, key=lambda e: e.version, default=None)
321311

322312
@staticmethod
323-
def get_default_exercise(short_name, for_update=False) -> Exercise:
313+
def get_default_exercise(short_name, for_update=False) -> Optional[Exercise]:
324314
"""
325315
Returns and locks the default exercise for the given short_name.
326316
"""
@@ -330,7 +320,7 @@ def get_default_exercise(short_name, for_update=False) -> Exercise:
330320
return q.one_or_none()
331321

332322
@staticmethod
333-
def get_exercise(short_name, version, for_update=False) -> Exercise:
323+
def get_exercise(short_name, version, for_update=False) -> Optional[Exercise]:
334324
exercise = Exercise.query.filter(
335325
and_(Exercise.short_name == short_name, Exercise.version == version)
336326
)
@@ -354,13 +344,15 @@ def has_started(self) -> bool:
354344
or datetime.datetime.now() > self.submission_deadline_start
355345
)
356346

357-
def submission_heads(self) -> List[Submission]:
347+
def submission_heads(self) -> List["Submission"]:
358348
"""
359349
Returns the most recent submission for this exercise for each user.
360350
Note: This function does not consider Submissions of other
361351
version of this exercise. Hence, the returned submissions might
362352
not be the most recent ones for an specific instance.
363353
"""
354+
from .instance import Instance
355+
364356
most_recent_instances = []
365357
instances_per_user = defaultdict(list)
366358
instances = Instance.query.filter(
@@ -374,7 +366,7 @@ def submission_heads(self) -> List[Submission]:
374366
most_recent_instances += [max(instances, key=lambda e: e.creation_ts)]
375367
return [e.submission for e in most_recent_instances if e.submission]
376368

377-
def submission_heads_global(self) -> List[Submission]:
369+
def submission_heads_global(self) -> List["Submission"]:
378370
"""
379371
Same as .submission_heads(), except only submissions
380372
that have no newer (based on a more recent exercise version)
@@ -400,15 +392,15 @@ def submission_heads_global(self) -> List[Submission]:
400392
return ret
401393

402394
@property
403-
def active_instances(self) -> List[Instance]:
395+
def active_instances(self) -> List["Instance"]:
404396
"""
405397
Get all instances of this exercise that are no submissions.
406398
Note: This function does not returns Instances that belong to
407399
another version of this exercise.
408400
"""
409401
return [i for i in self.instances if not i.submission]
410402

411-
def submissions(self, user=None) -> List[Submission]:
403+
def submissions(self, user=None) -> List["Submission"]:
412404
"""
413405
Get all submissions of this exercise.
414406
Note: This function does not returns Submissions that belong to
@@ -441,7 +433,7 @@ def has_graded_submissions(self) -> bool:
441433
return True
442434
return False
443435

444-
def avg_points(self) -> float:
436+
def avg_points(self) -> Optional[float]:
445437
"""
446438
Returns the average points calculated over all submission heads.
447439
If there are no submissions, None is returned.

0 commit comments

Comments
 (0)