Skip to content

Commit 68e13c7

Browse files
committed
merge with master
2 parents 60ac757 + f18eeca commit 68e13c7

File tree

414 files changed

+1658
-2921
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

414 files changed

+1658
-2921
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ Enter http://127.0.0.1:8080, and the initial credentials should appear in your t
7979

8080
After logging in, use [localhost admin](https://127.0.0.1:8080/admin) to modify entries in the database.
8181

82+
In case you want to enable the mail system:
83+
84+
1. Insert your mail details in the configuration file.
85+
2. Change the `DISABLE_MAIL` line value to False.
86+
8287

8388
## Code modification check list
8489
### Run flake8

lms/lmsdb/bootstrap.py

Lines changed: 63 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,38 @@ 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+
290+
def _last_status_view_migration() -> bool:
291+
Solution = models.Solution
292+
_migrate_column_in_table_if_needed(Solution, Solution.last_status_view)
293+
_migrate_column_in_table_if_needed(Solution, Solution.last_time_view)
294+
295+
243296
def _uuid_migration() -> bool:
244297
User = models.User
245298
_add_not_null_column(User, User.uuid, _add_uuid_to_users_table)
@@ -248,8 +301,15 @@ def _uuid_migration() -> bool:
248301

249302
def main():
250303
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+
307+
if models.database.table_exists(models.Solution.__name__.lower()):
308+
_last_status_view_migration()
309+
251310
if models.database.table_exists(models.User.__name__.lower()):
252311
_api_keys_migration()
312+
_last_course_viewed_migration()
253313
_uuid_migration()
254314

255315
models.database.create_tables(models.ALL_MODELS, safe=True)
@@ -258,6 +318,9 @@ def main():
258318
models.create_basic_roles()
259319
if models.User.select().count() == 0:
260320
models.create_demo_users()
321+
if models.Course.select().count() == 0:
322+
course = models.create_basic_course()
323+
_exercise_course_migration(course)
261324

262325
text_fixer.fix_texts()
263326
import_tests.load_tests_from_path('/app_dir/notebooks-tests')

lms/lmsdb/models.py

Lines changed: 141 additions & 10 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'
@@ -354,8 +446,20 @@ def to_choices(cls: enum.EnumMeta) -> Tuple[Tuple[str, str], ...]:
354446
return tuple((choice.name, choice.value) for choice in choices)
355447

356448

449+
class SolutionStatusView(enum.Enum):
450+
UPLOADED = 'Uploaded'
451+
NOT_CHECKED = 'Not checked'
452+
CHECKED = 'Checked'
453+
454+
@classmethod
455+
def to_choices(cls: enum.EnumMeta) -> Tuple[Tuple[str, str], ...]:
456+
choices = cast(Iterable[enum.Enum], tuple(cls))
457+
return tuple((choice.name, choice.value) for choice in choices)
458+
459+
357460
class Solution(BaseModel):
358461
STATES = SolutionState
462+
STATUS_VIEW = SolutionStatusView
359463
MAX_CHECK_TIME_SECONDS = 60 * 10
360464

361465
exercise = ForeignKeyField(Exercise, backref='solutions')
@@ -371,6 +475,12 @@ class Solution(BaseModel):
371475
)
372476
submission_timestamp = DateTimeField(index=True)
373477
hashed = TextField()
478+
last_status_view = CharField(
479+
choices=STATUS_VIEW.to_choices(),
480+
default=STATUS_VIEW.UPLOADED.name,
481+
index=True,
482+
)
483+
last_time_view = DateTimeField(default=datetime.now, null=True, index=True)
374484

375485
@property
376486
def solution_files(
@@ -412,6 +522,20 @@ def is_duplicate(
412522

413523
return last_submission_hash == hash_
414524

525+
def view_solution(self) -> None:
526+
self.last_time_view = datetime.now()
527+
if (
528+
self.last_status_view != self.STATUS_VIEW.NOT_CHECKED.name
529+
and self.state == self.STATES.CREATED.name
530+
):
531+
self.last_status_view = self.STATUS_VIEW.NOT_CHECKED.name
532+
elif (
533+
self.last_status_view != self.STATUS_VIEW.CHECKED.name
534+
and self.state == self.STATES.DONE.name
535+
):
536+
self.last_status_view = self.STATUS_VIEW.CHECKED.name
537+
self.save()
538+
415539
def start_checking(self) -> bool:
416540
return self.set_state(Solution.STATES.IN_CHECKING)
417541

@@ -437,10 +561,13 @@ def test_results(self) -> Iterable[dict]:
437561
@classmethod
438562
def of_user(
439563
cls, user_id: int, with_archived: bool = False,
564+
from_all_courses: bool = False,
440565
) -> Iterable[Dict[str, Any]]:
441-
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+
)
442570
exercises = Exercise.as_dicts(db_exercises)
443-
444571
solutions = (
445572
cls
446573
.select(cls.exercise, cls.id, cls.state, cls.checker)
@@ -701,7 +828,7 @@ def get_by_exercise(cls, exercise: Exercise):
701828

702829
class ExerciseTestName(BaseModel):
703830
FATAL_TEST_NAME = 'fatal_test_failure'
704-
FATAL_TEST_PRETTY_TEST_NAME = _('כישלון חמור')
831+
FATAL_TEST_PRETTY_TEST_NAME = _('Fatal error')
705832

706833
exercise_test = ForeignKeyField(model=ExerciseTest)
707834
test_name = TextField()
@@ -914,7 +1041,7 @@ def generate_string(
9141041
return ''.join(password)
9151042

9161043

917-
def create_demo_users():
1044+
def create_demo_users() -> None:
9181045
print('First run! Here are some users to get start with:') # noqa: T001
9191046
fields = ['username', 'fullname', 'mail_address', 'role']
9201047
student_role = Role.by_name('Student')
@@ -932,9 +1059,13 @@ def create_demo_users():
9321059
print(f"User: {user['username']}, Password: {password}") # noqa: T001
9331060

9341061

935-
def create_basic_roles():
1062+
def create_basic_roles() -> None:
9361063
for role in RoleOptions:
9371064
Role.create(name=role.value)
9381065

9391066

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

lms/lmstests/public/identical_tests/services.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def _clone_solution_comments(
101101
user=to_solution.solver,
102102
related_id=to_solution,
103103
message=_(
104-
'הפתרון שלך לתרגיל %(subject)s נבדק.',
104+
'Your solution for the %(subject)s exercise has been checked.',
105105
subject=to_solution.exercise.subject,
106106
),
107107
action_url=f'{routes.SOLUTIONS}/{to_solution.id}',

lms/lmstests/public/linters/services.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ def _fire_notification_if_needed(self):
9393
errors_len = len(self._errors)
9494
exercise_name = self.solution.exercise.subject
9595
msg = _(
96-
'הבודק האוטומטי נתן %(errors_num)d הערות על תרגילך %(name)s.',
96+
'The automatic checker gave you %(errors_num)d for your '
97+
'%(name)s solution.',
9798
errors_num=errors_len, name=exercise_name,
9899
)
99100
return notifications.send(

0 commit comments

Comments
 (0)