Skip to content

Commit f18eeca

Browse files
authored
Manage courses (#307)
* feat: Manage courses - Added tables of courses and usercourses - Added some columns to exercise and a constraint - Fixed the view template and user template - Fixed the upload method - Added tests - Added migration - Added to the navbar the Course dropdown choice
1 parent cf0ec2f commit f18eeca

File tree

17 files changed

+487
-111
lines changed

17 files changed

+487
-111
lines changed

lms/lmsdb/bootstrap.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,27 @@ def _add_api_keys_to_users_table(table: Model, _column: Field) -> None:
226226
user.save()
227227

228228

229+
def _add_course_and_numbers_to_exercises_table(
230+
table: Model, course: models.Course,
231+
) -> None:
232+
log.info(
233+
'Adding Course, Numbers for exercises, might take some extra time...',
234+
)
235+
with db_config.database.transaction():
236+
for exercise in table:
237+
exercise.number = exercise.id
238+
exercise.course = course
239+
exercise.save()
240+
241+
242+
def _create_usercourses_objects(table: Model, course: models.Course) -> None:
243+
log.info('Adding UserCourse for all users, might take some extra time...')
244+
UserCourse = models.UserCourse
245+
with db_config.database.transaction():
246+
for user in table:
247+
UserCourse.create(user=user, course=course)
248+
249+
229250
def _add_uuid_to_users_table(table: Model, _column: Field) -> None:
230251
log.info('Adding UUIDs for all users, might take some extra time...')
231252
with db_config.database.transaction():
@@ -240,6 +261,32 @@ def _api_keys_migration() -> bool:
240261
return True
241262

242263

264+
def _last_course_viewed_migration() -> bool:
265+
User = models.User
266+
_add_not_null_column(User, User.last_course_viewed)
267+
return True
268+
269+
270+
def _exercise_course_migration(course: models.Course) -> bool:
271+
Exercise = models.Exercise
272+
_create_usercourses_objects(models.User, course)
273+
_add_course_and_numbers_to_exercises_table(Exercise, course)
274+
return True
275+
276+
277+
def _add_exercise_course_id_and_number_columns_constraint() -> bool:
278+
Exercise = models.Exercise
279+
migrator = db_config.get_migrator_instance()
280+
with db_config.database.transaction():
281+
course_not_exists = _add_not_null_column(Exercise, Exercise.course)
282+
number_not_exists = _add_not_null_column(Exercise, Exercise.number)
283+
if course_not_exists and number_not_exists:
284+
migrate(
285+
migrator.add_index('exercise', ('course_id', 'number'), True),
286+
)
287+
db_config.database.commit()
288+
289+
243290
def _last_status_view_migration() -> bool:
244291
Solution = models.Solution
245292
_migrate_column_in_table_if_needed(Solution, Solution.last_status_view)
@@ -254,11 +301,15 @@ def _uuid_migration() -> bool:
254301

255302
def main():
256303
with models.database.connection_context():
304+
if models.database.table_exists(models.Exercise.__name__.lower()):
305+
_add_exercise_course_id_and_number_columns_constraint()
306+
257307
if models.database.table_exists(models.Solution.__name__.lower()):
258308
_last_status_view_migration()
259309

260310
if models.database.table_exists(models.User.__name__.lower()):
261311
_api_keys_migration()
312+
_last_course_viewed_migration()
262313
_uuid_migration()
263314

264315
models.database.create_tables(models.ALL_MODELS, safe=True)
@@ -267,6 +318,9 @@ def main():
267318
models.create_basic_roles()
268319
if models.User.select().count() == 0:
269320
models.create_demo_users()
321+
if models.Course.select().count() == 0:
322+
course = models.create_basic_course()
323+
_exercise_course_migration(course)
270324

271325
text_fixer.fix_texts()
272326
import_tests.load_tests_from_path('/app_dir/notebooks-tests')

lms/lmsdb/models.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
from collections import Counter
66
from datetime import datetime
77
from typing import (
8-
Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple,
9-
Type, Union, cast,
8+
Any, Dict, Iterable, List, Optional,
9+
TYPE_CHECKING, Tuple, Type, Union, cast,
1010
)
1111
from uuid import uuid4
1212

@@ -132,21 +132,50 @@ def is_viewer(self) -> bool:
132132
return self.name == RoleOptions.VIEWER.value or self.is_manager
133133

134134

135+
class Course(BaseModel):
136+
name = CharField(unique=True)
137+
date = DateTimeField(default=datetime.now)
138+
end_date = DateTimeField(null=True)
139+
close_registration_date = DateTimeField(default=datetime.now)
140+
invite_code = CharField(null=True)
141+
is_public = BooleanField(default=False)
142+
143+
def has_user(self, user_id: int) -> bool:
144+
return UserCourse.is_user_registered(user_id, self)
145+
146+
@classmethod
147+
def fetch(cls, user: 'User') -> Iterable['Course']:
148+
return (
149+
cls
150+
.select()
151+
.join(UserCourse)
152+
.where(UserCourse.user == user.id)
153+
.order_by(Course.name.desc())
154+
)
155+
156+
def __str__(self):
157+
return f'{self.name}: {self.date} - {self.end_date}'
158+
159+
135160
class User(UserMixin, BaseModel):
136161
username = CharField(unique=True)
137162
fullname = CharField()
138163
mail_address = CharField(unique=True)
139164
password = CharField()
140165
role = ForeignKeyField(Role, backref='users')
141166
api_key = CharField()
167+
last_course_viewed = ForeignKeyField(Course, null=True)
142168
uuid = UUIDField(default=uuid4, unique=True)
143169

144170
def get_id(self):
145171
return str(self.uuid)
146172

147-
def is_password_valid(self, password):
173+
def is_password_valid(self, password) -> bool:
148174
return check_password_hash(self.password, password)
149175

176+
def has_course(self, course_id: int) -> bool:
177+
return UserCourse.is_user_registered(self, course_id)
178+
150179
@classmethod
151180
def get_system_user(cls) -> 'User':
152181
instance, _ = cls.get_or_create(**{
@@ -168,6 +197,9 @@ def random_password(cls, stronger: bool = False) -> str:
168197
def get_notifications(self) -> Iterable['Notification']:
169198
return Notification.fetch(self)
170199

200+
def get_courses(self) -> Iterable['Course']:
201+
return Course.fetch(self)
202+
171203
def notes(self) -> Iterable['Note']:
172204
fields = (
173205
Note.id, Note.creator.fullname, CommentText.text,
@@ -215,6 +247,24 @@ def on_save_handler(model_class, instance, created):
215247
instance.api_key = generate_password_hash(instance.api_key)
216248

217249

250+
class UserCourse(BaseModel):
251+
user = ForeignKeyField(User, backref='usercourses')
252+
course = ForeignKeyField(Course, backref='usercourses')
253+
date = DateTimeField(default=datetime.now)
254+
255+
@classmethod
256+
def is_user_registered(cls, user_id: int, course_id: int) -> bool:
257+
return (
258+
cls.
259+
select()
260+
.where(
261+
cls.user == user_id,
262+
cls.course == course_id,
263+
)
264+
.exists()
265+
)
266+
267+
218268
class Notification(BaseModel):
219269
ID_FIELD_NAME = 'id'
220270
MAX_PER_USER = 10
@@ -304,15 +354,46 @@ class Exercise(BaseModel):
304354
due_date = DateTimeField(null=True)
305355
notebook_num = IntegerField(default=0)
306356
order = IntegerField(default=0, index=True)
357+
course = ForeignKeyField(Course, backref='exercise')
358+
number = IntegerField(default=1)
359+
360+
class Meta:
361+
indexes = (
362+
(('course_id', 'number'), True),
363+
)
307364

308365
def open_for_new_solutions(self) -> bool:
309366
if self.due_date is None:
310367
return not self.is_archived
311368
return datetime.now() < self.due_date and not self.is_archived
312369

313370
@classmethod
314-
def get_objects(cls, fetch_archived: bool = False):
315-
exercises = cls.select().order_by(Exercise.order)
371+
def get_highest_number(cls):
372+
return cls.select(fn.MAX(cls.number)).scalar()
373+
374+
@classmethod
375+
def is_number_exists(cls, number: int) -> bool:
376+
return cls.select().where(cls.number == number).exists()
377+
378+
@classmethod
379+
def get_objects(
380+
cls, user_id: int, fetch_archived: bool = False,
381+
from_all_courses: bool = False,
382+
):
383+
user = User.get(User.id == user_id)
384+
exercises = (
385+
cls
386+
.select()
387+
.join(Course)
388+
.join(UserCourse)
389+
.where(UserCourse.user == user_id)
390+
.switch()
391+
.order_by(UserCourse.date, Exercise.number, Exercise.order)
392+
)
393+
if not from_all_courses:
394+
exercises = exercises.where(
395+
UserCourse.course == user.last_course_viewed,
396+
)
316397
if not fetch_archived:
317398
exercises = exercises.where(cls.is_archived == False) # NOQA: E712
318399
return exercises
@@ -324,6 +405,9 @@ def as_dict(self) -> Dict[str, Any]:
324405
'is_archived': self.is_archived,
325406
'notebook': self.notebook_num,
326407
'due_date': self.due_date,
408+
'exercise_number': self.number,
409+
'course_id': self.course.id,
410+
'course_name': self.course.name,
327411
}
328412

329413
@staticmethod
@@ -334,6 +418,14 @@ def __str__(self):
334418
return self.subject
335419

336420

421+
@pre_save(sender=Exercise)
422+
def exercise_number_save_handler(model_class, instance, created):
423+
"""Change the exercise number to the highest consecutive number."""
424+
425+
if model_class.is_number_exists(instance.number):
426+
instance.number = model_class.get_highest_number() + 1
427+
428+
337429
class SolutionState(enum.Enum):
338430
CREATED = 'Created'
339431
IN_CHECKING = 'In checking'
@@ -469,10 +561,13 @@ def test_results(self) -> Iterable[dict]:
469561
@classmethod
470562
def of_user(
471563
cls, user_id: int, with_archived: bool = False,
564+
from_all_courses: bool = False,
472565
) -> Iterable[Dict[str, Any]]:
473-
db_exercises = Exercise.get_objects(fetch_archived=with_archived)
566+
db_exercises = Exercise.get_objects(
567+
user_id=user_id, fetch_archived=with_archived,
568+
from_all_courses=from_all_courses,
569+
)
474570
exercises = Exercise.as_dicts(db_exercises)
475-
476571
solutions = (
477572
cls
478573
.select(cls.exercise, cls.id, cls.state, cls.checker)
@@ -946,7 +1041,7 @@ def generate_string(
9461041
return ''.join(password)
9471042

9481043

949-
def create_demo_users():
1044+
def create_demo_users() -> None:
9501045
print('First run! Here are some users to get start with:') # noqa: T001
9511046
fields = ['username', 'fullname', 'mail_address', 'role']
9521047
student_role = Role.by_name('Student')
@@ -964,9 +1059,13 @@ def create_demo_users():
9641059
print(f"User: {user['username']}, Password: {password}") # noqa: T001
9651060

9661061

967-
def create_basic_roles():
1062+
def create_basic_roles() -> None:
9681063
for role in RoleOptions:
9691064
Role.create(name=role.value)
9701065

9711066

1067+
def create_basic_course() -> Course:
1068+
return Course.create(name='Python Course', date=datetime.now())
1069+
1070+
9721071
ALL_MODELS = BaseModel.__subclasses__()

0 commit comments

Comments
 (0)