From 2a60bfb9167bd068f42536c238c411c6554b631c Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sat, 18 Sep 2021 10:34:13 +0300 Subject: [PATCH 01/22] feat: Manage courses - Added tables of courses and usercourses - Added some columns to exercise - Fixed the view template and user template - Fixed the upload method --- lms/lmsdb/models.py | 77 ++++++++++++++++++++++++++++++------ lms/lmsweb/views.py | 24 ++++++----- lms/models/upload.py | 33 ++++++++++------ lms/static/my.css | 8 ++-- lms/templates/exercises.html | 73 ++++++++++++++++++---------------- lms/templates/navbar.html | 6 --- lms/templates/upload.html | 4 +- lms/templates/user.html | 8 +++- tests/conftest.py | 35 +++++++++++++--- tests/samples/Upload 1.py | 2 + tests/test_exercises.py | 13 +++++- tests/test_extractor.py | 60 ++++++++++++++++++++++------ tests/test_solutions.py | 13 ++++-- 13 files changed, 252 insertions(+), 104 deletions(-) create mode 100644 tests/samples/Upload 1.py diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 8fd5b812..5eef3a89 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -1,12 +1,12 @@ -from collections import Counter +from collections import Counter, defaultdict import enum import html import secrets import string from datetime import datetime from typing import ( - Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, - Type, Union, cast, + Any, DefaultDict, Dict, Iterable, List, Optional, + TYPE_CHECKING, Tuple, Type, Union, cast, ) from flask_babel import gettext as _ # type: ignore @@ -27,7 +27,7 @@ database = database_config.get_db_instance() -ExercisesDictById = Dict[int, Dict[str, Any]] +ExercisesDictById = DefaultDict[str, Dict[int, Dict[str, Any]]] if TYPE_CHECKING: from lms.extractors.base import File @@ -121,6 +121,17 @@ def is_viewer(self) -> bool: return self.name == RoleOptions.VIEWER.value or self.is_manager +class Course(BaseModel): + name = CharField(unique=True) + date = DateTimeField() + due_date = DateTimeField(null=True) + is_finished = BooleanField(default=False) + close_registration_date = DateTimeField(default=datetime.now) + + def __str__(self): + return f'{self.name}: {self.date} - {self.due_date}' + + class User(UserMixin, BaseModel): username = CharField(unique=True) fullname = CharField() @@ -199,6 +210,25 @@ def on_save_handler(model_class, instance, created): instance.api_key = generate_password_hash(instance.api_key) +class UserCourse(BaseModel): + user = ForeignKeyField(User, backref='usercourses') + course = ForeignKeyField(Course, backref='usercourses') + date = DateTimeField(default=datetime.now) + + @classmethod + def is_user_registered(cls, user_id: int, course_id: int) -> bool: + return ( + cls. + select() + .join(User) + .where(User.id == user_id) + .switch() + .join(Course) + .where(Course.id == course_id) + .exists() + ) + + class Notification(BaseModel): ID_FIELD_NAME = 'id' MAX_PER_USER = 10 @@ -288,6 +318,13 @@ class Exercise(BaseModel): due_date = DateTimeField(null=True) notebook_num = IntegerField(default=0) order = IntegerField(default=0, index=True) + course = ForeignKeyField(Course, backref='exercise') + number = IntegerField() + + class Meta: + indexes = ( + (('course_id', 'number'), True), + ) def open_for_new_solutions(self) -> bool: if self.due_date is None: @@ -295,8 +332,16 @@ def open_for_new_solutions(self) -> bool: return datetime.now() < self.due_date and not self.is_archived @classmethod - def get_objects(cls, fetch_archived: bool = False): - exercises = cls.select().order_by(Exercise.order) + def get_objects(cls, user_id: int, fetch_archived: bool = False): + exercises = ( + cls + .select() + .join(Course) + .join(UserCourse) + .where(UserCourse.user == user_id) + .switch() + .order_by(UserCourse.date, Exercise.order) + ) if not fetch_archived: exercises = exercises.where(cls.is_archived == False) # NOQA: E712 return exercises @@ -308,11 +353,17 @@ def as_dict(self) -> Dict[str, Any]: 'is_archived': self.is_archived, 'notebook': self.notebook_num, 'due_date': self.due_date, + 'exercise_number': self.number, + 'course_id': self.course.id, + 'course_name': self.course.name, } @staticmethod def as_dicts(exercises: Iterable['Exercise']) -> ExercisesDictById: - return {exercise.id: exercise.as_dict() for exercise in exercises} + nested_dict = defaultdict(dict) + for exercise in exercises: + nested_dict[exercise.course.name][exercise.id] = exercise.as_dict() + return nested_dict def __str__(self): return self.subject @@ -422,10 +473,11 @@ def test_results(self) -> Iterable[dict]: @classmethod def of_user( cls, user_id: int, with_archived: bool = False, - ) -> Iterable[Dict[str, Any]]: - db_exercises = Exercise.get_objects(fetch_archived=with_archived) + ) -> Iterable[DefaultDict[str, Dict[str, Any]]]: + db_exercises = Exercise.get_objects( + user_id=user_id, fetch_archived=with_archived, + ) exercises = Exercise.as_dicts(db_exercises) - solutions = ( cls .select(cls.exercise, cls.id, cls.state, cls.checker) @@ -433,14 +485,15 @@ def of_user( .order_by(cls.submission_timestamp.desc()) ) for solution in solutions: - exercise = exercises[solution.exercise_id] + course_name = solution.exercise.course.name + exercise = exercises[course_name][solution.exercise_id] if exercise.get('solution_id') is None: exercise['solution_id'] = solution.id exercise['is_checked'] = solution.is_checked exercise['comments_num'] = len(solution.staff_comments) if solution.is_checked and solution.checker: exercise['checker'] = solution.checker.fullname - return tuple(exercises.values()) + return exercises @property def comments(self): diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index ad1d188e..68e41bc2 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -14,7 +14,7 @@ from lms.lmsdb.models import ( ALL_MODELS, Comment, Note, RoleOptions, SharedSolution, - Solution, SolutionFile, User, database, + Solution, SolutionFile, User, UserCourse, database, ) from lms.lmsweb import babel, limiter, routes, webapp from lms.lmsweb.admin import ( @@ -305,10 +305,12 @@ def comment(): return fail(400, f'Unknown or unset act value "{act}".') -@webapp.route('/send/') +@webapp.route('/send//') @login_required -def send(_exercise_id): - return render_template('upload.html') +def send(course_id: int, _exercise_number: Optional[int]): + if not UserCourse.is_user_registered(current_user.id, course_id): + return fail(403, "You aren't allowed to watch this page.") + return render_template('upload.html', course_id=course_id) @webapp.route('/user/') @@ -331,15 +333,17 @@ def user(user_id): ) -@webapp.route('/send', methods=['GET']) +@webapp.route('/send/', methods=['GET']) @login_required -def send_(): - return render_template('upload.html') +def send_(course_id: int): + if not UserCourse.is_user_registered(current_user.id, course_id): + return fail(403, "You aren't allowed to watch this page.") + return render_template('upload.html', course_id=course_id) -@webapp.route('/upload', methods=['POST']) +@webapp.route('/upload/', methods=['POST']) @login_required -def upload_page(): +def upload_page(course_id: int): user_id = current_user.id user = User.get_or_none(User.id == user_id) # should never happen if user is None: @@ -354,7 +358,7 @@ def upload_page(): return fail(422, 'No file was given.') try: - matches, misses = upload.new(user, file) + matches, misses = upload.new(user, file, course_id) except UploadError as e: log.debug(e) return fail(400, str(e)) diff --git a/lms/models/upload.py b/lms/models/upload.py index 40bcbb67..8af6d488 100644 --- a/lms/models/upload.py +++ b/lms/models/upload.py @@ -3,7 +3,7 @@ from werkzeug.datastructures import FileStorage from lms.extractors.base import Extractor, File -from lms.lmsdb.models import Exercise, Solution, User +from lms.lmsdb.models import Exercise, Solution, User, UserCourse from lms.lmstests.public.identical_tests import tasks as identical_tests_tasks from lms.lmstests.public.linters import tasks as linters_tasks from lms.lmstests.public.unittests import tasks as unittests_tasks @@ -23,22 +23,29 @@ def _is_uploaded_before( def _upload_to_db( - exercise_id: int, + exercise_number: int, + course_id: int, user: User, files: List[File], solution_hash: Optional[str] = None, ) -> Solution: - exercise = Exercise.get_or_none(exercise_id) + exercise = Exercise.get_or_none(course=course_id, number=exercise_number) if exercise is None: - raise UploadError(f'No such exercise id: {exercise_id}') + raise UploadError(f'No such exercise id: {exercise_number}') + elif not UserCourse.is_user_registered(user.id, course_id): + raise UploadError( + f'Exercise {exercise_number} is invalid for this user.', + ) elif not exercise.open_for_new_solutions(): raise UploadError( - f'Exercise {exercise_id} is closed for new solutions.') + f'Exercise {exercise_number} is closed for new solutions.') if solution_hash and _is_uploaded_before(user, exercise, solution_hash): raise AlreadyExists('You try to reupload an old solution.') elif not files: - raise UploadError(f'There are no files to upload for {exercise_id}.') + raise UploadError( + f'There are no files to upload for {exercise_number}.', + ) return Solution.create_solution( exercise=exercise, @@ -56,20 +63,24 @@ def _run_auto_checks(solution: Solution) -> None: check_ident.apply_async(args=(solution.id,)) -def new(user: User, file: FileStorage) -> Tuple[List[int], List[int]]: +def new( + user: User, file: FileStorage, course_id: int, +) -> Tuple[List[int], List[int]]: matches: List[int] = [] misses: List[int] = [] errors: List[Union[UploadError, AlreadyExists]] = [] - for exercise_id, files, solution_hash in Extractor(file): + for exercise_number, files, solution_hash in Extractor(file): try: - solution = _upload_to_db(exercise_id, user, files, solution_hash) + solution = _upload_to_db( + exercise_number, course_id, user, files, solution_hash, + ) _run_auto_checks(solution) except (UploadError, AlreadyExists) as e: log.debug(e) errors.append(e) - misses.append(exercise_id) + misses.append(exercise_number) else: - matches.append(exercise_id) + matches.append(exercise_number) if not matches and errors: raise UploadError(errors) diff --git a/lms/static/my.css b/lms/static/my.css index 8380ae18..f1dd077f 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -130,7 +130,7 @@ a { justify-content: end; } -.exercise-id { +.exercise-number { align-items: center; border-radius: 100%; border: 1px solid; @@ -142,11 +142,11 @@ a { width: 3rem; } -.rtl-language > .exercise-id { +.rtl-language > .exercise-number { margin-left: 2em; } -.ltr-language > .exercise-id { +.ltr-language > .exercise-number { margin-right: 2em; } @@ -876,7 +876,7 @@ code .grader-add .fa { } @media screen and (max-width: 768px) { - .exercise-id { + .exercise-number { display: none; } .which-notebook { diff --git a/lms/templates/exercises.html b/lms/templates/exercises.html index 9e668d2e..6d5ae782 100644 --- a/lms/templates/exercises.html +++ b/lms/templates/exercises.html @@ -9,44 +9,47 @@

{{ _('תרגילים') }}

- {%- for exercise in exercises %} -
-
-
{{ exercise['exercise_id'] }}
-
{{ exercise['exercise_name'] | e }}
-
-
-
- {{ exercise['comments_num'] }} - {{ _("הערות על התרגיל") }} + {% for course, values in exercises.items() %} +

{{ course }}

+ {%- for exercise in values.values() %} +
+
+
{{ exercise['exercise_number'] }}
+
{{ exercise['exercise_name'] | e }}
- {%- if exercise['notebook'] %} -
- - {{ exercise['notebook'] }} +
+
+ {{ exercise['comments_num'] }} + {{ _("הערות על התרגיל") }} +
+ {%- if exercise['notebook'] %} +
+ + {{ exercise['notebook'] }} +
+ {%- endif %} + {%- if exercise.get('is_checked') is none %} + {% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['course_id']} %} + {% elif not exercise.get('is_checked') %} + {% set details = {'page': 'view', 'icon': 'eye', 'text': _('הצצה'), 'css': 'view', 'page_id': exercise['solution_id']} %} + {% else %} + {% set details = {'page': 'view', 'icon': 'check-circle-o', 'text': _('לבדיקה'), 'css': 'checked', 'page_id': exercise['solution_id']} %} + {% endif -%} + {%- if not exercise.get('is_archived') or exercise.get('is_checked') is not none %} + + {{ details['text'] | e }} + + + {% endif -%} + {% if is_manager %} + + + + {% endif %}
- {%- endif %} - {%- if exercise.get('is_checked') is none %} - {% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['exercise_id']} %} - {% elif not exercise.get('is_checked') %} - {% set details = {'page': 'view', 'icon': 'eye', 'text': _('הצצה'), 'css': 'view', 'page_id': exercise['solution_id']} %} - {% else %} - {% set details = {'page': 'view', 'icon': 'check-circle-o', 'text': _('לבדיקה'), 'css': 'checked', 'page_id': exercise['solution_id']} %} - {% endif -%} - {%- if not exercise.get('is_archived') or exercise.get('is_checked') is not none %} - - {{ details['text'] | e }} - - - {% endif -%} - {% if is_manager %} - - - - {% endif %}
-
- {% endfor -%} + {% endfor -%} + {% endfor %} {%- if not fetch_archived %}
diff --git a/lms/templates/navbar.html b/lms/templates/navbar.html index 3c6304b7..1c9050b7 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -37,12 +37,6 @@
- {%- if not exercises or fetch_archived %} + {%- if not exercises or fetch_archived %}
diff --git a/tests/test_exercises.py b/tests/test_exercises.py index 9729af02..13d1dd86 100644 --- a/tests/test_exercises.py +++ b/tests/test_exercises.py @@ -1,6 +1,6 @@ import datetime -from lms.lmsdb.models import Course, Exercise, Solution, User +from lms.lmsdb.models import Course, Exercise, User from tests import conftest @@ -23,51 +23,32 @@ def test_due_date(self, exercise: Exercise): assert exercise.open_for_new_solutions() @staticmethod - def test_courses_exercises(course: Course, student_user: User): + def test_courses_exercises( + course: Course, student_user: User, captured_templates, + ): course2 = conftest.create_course(index=1) conftest.create_usercourse(student_user, course) conftest.create_exercise(course2, 1) - assert len(list(Exercise.get_objects(student_user.id))) == 0 - + conftest.create_exercise(course2, 2) + assert len(list( + Exercise.get_objects(student_user.id, select_all=True), + )) == 0 + + client = conftest.get_logged_user(username=student_user.username) + client.get(f'change-course/{course.id}') + template, _ = captured_templates[-1] + assert template.name == 'exercises.html' conftest.create_exercise(course, 1, index=1) assert len(list(Exercise.get_objects(student_user.id))) == 1 - @staticmethod - def test_course_objects_structure(course: Course, student_user: User): - # Create more courses - course2 = conftest.create_course(index=1) - course3 = conftest.create_course(index=2) - - # Create another student user - student_user2 = conftest.create_student_user(index=1) - - # Assign users to courses - conftest.create_usercourse(student_user, course) - conftest.create_usercourse(student_user, course3) - conftest.create_usercourse(student_user2, course2) - - # Create exercises for the courses - conftest.create_exercise(course, 1) - ex2_c1 = conftest.create_exercise(course, 2) - ex1_c2 = conftest.create_exercise(course2, 1) - conftest.create_exercise(course2, 2) - conftest.create_exercise(course3, 1) - - # Create solutions - conftest.create_solution(ex2_c1, student_user) - conftest.create_solution(ex1_c2, student_user2) + unregistered_response = client.get(f'change-course/{course2.id}') + assert unregistered_response.status_code == 403 - # Dicts of dicts structures - user_structure1 = Solution.of_user(student_user) - user_structure2 = Solution.of_user(student_user2) + fail_response = client.get(f'change-course/123456') + assert fail_response.status_code == 404 - assert len(user_structure1) == 2 - assert len(user_structure2) == 1 - assert 'solution_id' in user_structure1.get('course 0').get(2) - assert len(user_structure1.get('course 0')) == 2 - assert len(user_structure1.get('course 2')) == 1 - assert len(user_structure2.get('course 1')) == 2 - assert ( - user_structure2.get('course 1').get(4).get('exercise_number') == 2, - ) - assert 'solution_id' in user_structure2.get('course 1').get(3) + conftest.create_usercourse(student_user, course2) + client.get(f'change-course/{course2.id}') + template, _ = captured_templates[-1] + assert template.name == 'exercises.html' + assert len(list(Exercise.get_objects(student_user.id))) == 2 From f40f81c67b1cec454daa5f2a50891ac70b08432a Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Fri, 24 Sep 2021 15:45:18 +0300 Subject: [PATCH 06/22] removed unforced string format --- tests/test_exercises.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_exercises.py b/tests/test_exercises.py index 13d1dd86..50edb438 100644 --- a/tests/test_exercises.py +++ b/tests/test_exercises.py @@ -44,7 +44,7 @@ def test_courses_exercises( unregistered_response = client.get(f'change-course/{course2.id}') assert unregistered_response.status_code == 403 - fail_response = client.get(f'change-course/123456') + fail_response = client.get('change-course/123456') assert fail_response.status_code == 404 conftest.create_usercourse(student_user, course2) From e67e43acb5dc6932080e7719a7663dd6f7791b72 Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Fri, 24 Sep 2021 18:08:42 +0300 Subject: [PATCH 07/22] Add migration to `last_course_viewed` attribute` --- lms/lmsdb/bootstrap.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index 0bc54d0c..b55c7172 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -237,6 +237,12 @@ def _api_keys_migration() -> bool: return True +def _last_course_viewed_migration() -> bool: + User = models.User + _add_not_null_column(User, User.last_course_viewed) + return True + + def main(): with models.database.connection_context(): models.database.create_tables(models.ALL_MODELS, safe=True) @@ -247,6 +253,7 @@ def main(): models.create_demo_users() _api_keys_migration() + _last_course_viewed_migration() text_fixer.fix_texts() import_tests.load_tests_from_path('/app_dir/notebooks-tests') From 5d4f10c4d6ff3737dea785b977405c9afcf1c4da Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Fri, 24 Sep 2021 18:16:59 +0300 Subject: [PATCH 08/22] Add migration to last_course_viewed attribute of User table --- lms/lmsdb/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index b55c7172..66251e29 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -239,7 +239,7 @@ def _api_keys_migration() -> bool: def _last_course_viewed_migration() -> bool: User = models.User - _add_not_null_column(User, User.last_course_viewed) + _migrate_column_in_table_if_needed(User, User.last_course_viewed) return True From ef1ac3fa656d0be723be35c017bc8b3aa79a55b4 Mon Sep 17 00:00:00 2001 From: "sourcery-ai[bot]" <58596630+sourcery-ai[bot]@users.noreply.github.com> Date: Fri, 24 Sep 2021 19:06:36 +0300 Subject: [PATCH 09/22] 'Refactored by Sourcery' (#313) Co-authored-by: Sourcery AI <> --- lms/lmsdb/bootstrap.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index 66251e29..d4bf9303 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -218,9 +218,7 @@ def _drop_constraint_if_needed(table: Type[Model], column_name: str) -> bool: def has_column_named(table: Model, column_name: str) -> bool: db = db_config.database columns = {col.name for col in db.get_columns(table.__name__.lower())} - if column_name not in columns: - return False - return True + return column_name in columns def _add_api_keys_to_users_table(table: Model, _column: Field) -> None: From 70b110e618a38dfe873c2b988519b241e3cb7f9b Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sat, 25 Sep 2021 10:16:41 +0300 Subject: [PATCH 10/22] Fixed paths, added return values --- lms/lmsdb/models.py | 12 ++++++------ lms/lmsweb/views.py | 2 +- lms/utils/courses.py | 4 ++-- tests/test_exercises.py | 2 +- tests/test_extractor.py | 11 ++++++----- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 915b6047..532dc42f 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -136,13 +136,13 @@ class Course(BaseModel): name = CharField(unique=True) date = DateTimeField() due_date = DateTimeField(null=True) - is_finished = BooleanField(default=False) + is_closed = BooleanField(default=False) close_registration_date = DateTimeField(default=datetime.now) invite_code = CharField(default=generate_invite_code, unique=True) is_public = BooleanField(default=False) @classmethod - def fetch(cls, user: 'User'): + def fetch(cls, user: 'User') -> Iterable['Course']: return ( cls .select() @@ -360,7 +360,7 @@ def open_for_new_solutions(self) -> bool: @classmethod def get_objects( cls, user_id: int, fetch_archived: bool = False, - select_all: bool = False, + from_all_courses: bool = False, ): user = User.get(User.id == user_id) exercises = ( @@ -372,7 +372,7 @@ def get_objects( .switch() .order_by(UserCourse.date, Exercise.number, Exercise.order) ) - if not select_all: + if not from_all_courses: exercises = exercises.where( UserCourse.course == user.last_course_viewed, ) @@ -503,11 +503,11 @@ def test_results(self) -> Iterable[dict]: @classmethod def of_user( cls, user_id: int, with_archived: bool = False, - select_all: bool = False, + from_all_courses: bool = False, ) -> Iterable[Dict[str, Any]]: db_exercises = Exercise.get_objects( user_id=user_id, fetch_archived=with_archived, - select_all=select_all, + from_all_courses=from_all_courses, ) exercises = Exercise.as_dicts(db_exercises) solutions = ( diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index fadf79ea..4070124d 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -413,7 +413,7 @@ def user(user_id): return render_template( 'user.html', solutions=Solution.of_user( - target_user.id, with_archived=True, select_all=True, + target_user.id, with_archived=True, from_all_courses=True, ), user=target_user, is_manager=is_manager, diff --git a/lms/utils/courses.py b/lms/utils/courses.py index 1f71570d..d9a8d01a 100644 --- a/lms/utils/courses.py +++ b/lms/utils/courses.py @@ -2,9 +2,9 @@ import string -def generate_invite_code(length: int = 10): +def generate_invite_code(length: int = 10) -> str: return ''.join( secrets.SystemRandom().choices( - string.ascii_letters + string.digits, k=10, + string.ascii_letters + string.digits, k=length, ), ) diff --git a/tests/test_exercises.py b/tests/test_exercises.py index 50edb438..63b9b7ef 100644 --- a/tests/test_exercises.py +++ b/tests/test_exercises.py @@ -31,7 +31,7 @@ def test_courses_exercises( conftest.create_exercise(course2, 1) conftest.create_exercise(course2, 2) assert len(list( - Exercise.get_objects(student_user.id, select_all=True), + Exercise.get_objects(student_user.id, from_all_courses=True), )) == 0 client = conftest.get_logged_user(username=student_user.username) diff --git a/tests/test_extractor.py b/tests/test_extractor.py index e5881db3..202ca2e1 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -1,4 +1,5 @@ from io import BufferedReader, BytesIO +from pathlib import Path from tempfile import SpooledTemporaryFile from typing import Iterator, List, Tuple from zipfile import ZipFile @@ -80,22 +81,22 @@ def teardown(self): bytes_io.close() def ipynb_file(self): - return open(f'{SAMPLES_DIR}/{self.IPYNB_NAME}', encoding='utf-8') + return open(Path(SAMPLES_DIR) / self.IPYNB_NAME, encoding='utf-8') @staticmethod def py_files(filenames: Iterator[str]): for file_name in filenames: - yield open(f'{SAMPLES_DIR}/{file_name}') + yield open(Path(SAMPLES_DIR) / file_name) @staticmethod def get_bytes_io_file(file_name) -> BytesIO: - with open(f'{SAMPLES_DIR}/{file_name}', 'br') as open_file: + with open(Path(SAMPLES_DIR) / file_name, 'br') as open_file: return BytesIO(open_file.read()), file_name @staticmethod def zip_files(filenames: Tuple[str, ...]) -> Iterator[BufferedReader]: - for filename in filenames: - yield open(f'{SAMPLES_DIR}/{filename}', 'br') + for file_name in filenames: + yield open(Path(SAMPLES_DIR) / file_name, 'br') @staticmethod def create_zipfile_storage( From 9ed9b4cf3c103f5ed3eded49c03a71e00871a2fd Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sat, 25 Sep 2021 11:44:41 +0300 Subject: [PATCH 11/22] added check registration functions to User and Course models --- lms/lmsdb/models.py | 8 +++++++- lms/models/upload.py | 4 ++-- lms/templates/exercises.html | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index 532dc42f..fcd43511 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -141,6 +141,9 @@ class Course(BaseModel): invite_code = CharField(default=generate_invite_code, unique=True) is_public = BooleanField(default=False) + def has_user(self, user_id: int) -> bool: + return UserCourse.is_user_registered(user_id, self) + @classmethod def fetch(cls, user: 'User') -> Iterable['Course']: return ( @@ -164,9 +167,12 @@ class User(UserMixin, BaseModel): api_key = CharField() last_course_viewed = ForeignKeyField(Course, null=True) - def is_password_valid(self, password): + def is_password_valid(self, password) -> bool: return check_password_hash(self.password, password) + def is_registered(self, course_id: int) -> bool: + return UserCourse.is_user_registered(self, course_id) + @classmethod def get_system_user(cls) -> 'User': instance, _ = cls.get_or_create(**{ diff --git a/lms/models/upload.py b/lms/models/upload.py index 409c75fe..8533b593 100644 --- a/lms/models/upload.py +++ b/lms/models/upload.py @@ -3,7 +3,7 @@ from werkzeug.datastructures import FileStorage from lms.extractors.base import Extractor, File -from lms.lmsdb.models import Exercise, Solution, User, UserCourse +from lms.lmsdb.models import Exercise, Solution, User from lms.lmstests.public.identical_tests import tasks as identical_tests_tasks from lms.lmstests.public.linters import tasks as linters_tasks from lms.lmstests.public.unittests import tasks as unittests_tasks @@ -32,7 +32,7 @@ def _upload_to_db( exercise = Exercise.get_or_none(course=course_id, number=exercise_number) if exercise is None: raise UploadError(f'No such exercise id: {exercise_number}') - elif not UserCourse.is_user_registered(user.id, course_id): + elif not user.is_registered(course_id): raise UploadError( f'Exercise {exercise_number} is invalid for this user.', ) diff --git a/lms/templates/exercises.html b/lms/templates/exercises.html index a35531d7..a907ea5c 100644 --- a/lms/templates/exercises.html +++ b/lms/templates/exercises.html @@ -27,7 +27,7 @@

{{ _('תרגילים') }}

{%- endif %} {%- if exercise.get('is_checked') is none %} - {% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['course_id']} %} + {% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['course_id'] ~ '/' ~ exercise['exercise_id']} %} {% elif not exercise.get('is_checked') %} {% set details = {'page': 'view', 'icon': 'eye', 'text': _('הצצה'), 'css': 'view', 'page_id': exercise['solution_id']} %} {% else %} From 0fb4d198f6a9bf3750407f11b461035f041aa794 Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sat, 25 Sep 2021 14:35:55 +0300 Subject: [PATCH 12/22] add navbar upload item --- lms/templates/navbar.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lms/templates/navbar.html b/lms/templates/navbar.html index b18d35d4..e52a0b76 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -63,6 +63,14 @@ {% endfor -%}
+ {%- if current_user.last_course_viewed %} + + {% endif -%} {%- if not exercises or fetch_archived %} {%- if current_user.last_course_viewed %} {% endif -%} From f5f6fc0092c60e47ce3166ffa7292cdc183ce009 Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sun, 3 Oct 2021 11:24:05 +0300 Subject: [PATCH 21/22] changed column name and added the pre_save to the numebr column --- lms/lmsdb/models.py | 22 +++++++++++++++++++--- tests/test_extractor.py | 2 +- tests/test_users.py | 4 ++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index b8090ee6..cf3c8bc1 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -135,7 +135,7 @@ def is_viewer(self) -> bool: class Course(BaseModel): name = CharField(unique=True) date = DateTimeField(default=datetime.now) - close_date = DateTimeField(null=True) + end_date = DateTimeField(null=True) close_registration_date = DateTimeField(default=datetime.now) invite_code = CharField(null=True) is_public = BooleanField(default=False) @@ -154,7 +154,7 @@ def fetch(cls, user: 'User') -> Iterable['Course']: ) def __str__(self): - return f'{self.name}: {self.date} - {self.close_date}' + return f'{self.name}: {self.date} - {self.end_date}' class User(UserMixin, BaseModel): @@ -355,7 +355,7 @@ class Exercise(BaseModel): notebook_num = IntegerField(default=0) order = IntegerField(default=0, index=True) course = ForeignKeyField(Course, backref='exercise') - number = IntegerField() + number = IntegerField(default=1) class Meta: indexes = ( @@ -367,6 +367,14 @@ def open_for_new_solutions(self) -> bool: return not self.is_archived return datetime.now() < self.due_date and not self.is_archived + @classmethod + def get_highest_number(cls): + return cls.select(fn.MAX(cls.number)).scalar() + + @classmethod + def is_number_exists(cls, number: int) -> bool: + return cls.select().where(cls.number == number).exists() + @classmethod def get_objects( cls, user_id: int, fetch_archived: bool = False, @@ -410,6 +418,14 @@ def __str__(self): return self.subject +@pre_save(sender=Exercise) +def exercise_number_save_handler(model_class, instance, created): + """Change the exercise number to the highest consecutive number.""" + + if model_class.is_number_exists(instance.number): + instance.number = model_class.get_highest_number() + 1 + + class SolutionState(enum.Enum): CREATED = 'Created' IN_CHECKING = 'In checking' diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 202ca2e1..61ba4bab 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -89,7 +89,7 @@ def py_files(filenames: Iterator[str]): yield open(Path(SAMPLES_DIR) / file_name) @staticmethod - def get_bytes_io_file(file_name) -> BytesIO: + def get_bytes_io_file(file_name: str) -> Tuple[BytesIO, str]: with open(Path(SAMPLES_DIR) / file_name, 'br') as open_file: return BytesIO(open_file.read()), file_name diff --git a/tests/test_users.py b/tests/test_users.py index 3f56e916..18ae9c1c 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -181,3 +181,7 @@ def test_expired_token(client: FlaskClient): def test_user_registered_to_course(student_user: User, course: Course): conftest.create_usercourse(student_user, course) assert course.has_user(student_user) + + course2 = conftest.create_course(index=1) + assert not course2.has_user(student_user) + From 1aa7b55629ec882275995b3e1e0f028552571078 Mon Sep 17 00:00:00 2001 From: Or Ronai Date: Sun, 3 Oct 2021 21:49:03 +0300 Subject: [PATCH 22/22] removed css comment --- lms/static/my.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lms/static/my.css b/lms/static/my.css index 5d6cc64f..a637d906 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -162,14 +162,6 @@ a { width: 3rem; } -/* .rtl-language > .exercise-number { - margin-left: 2em; -} - -.ltr-language > .exercise-number { - margin-right: 2em; -} */ - .exercise-name { align-items: center; display: flex;