diff --git a/lms/lmsdb/bootstrap.py b/lms/lmsdb/bootstrap.py index c4472f33..740c1f20 100644 --- a/lms/lmsdb/bootstrap.py +++ b/lms/lmsdb/bootstrap.py @@ -226,6 +226,27 @@ def _add_api_keys_to_users_table(table: Model, _column: Field) -> None: user.save() +def _add_course_and_numbers_to_exercises_table( + table: Model, course: models.Course, +) -> None: + log.info( + 'Adding Course, Numbers for exercises, might take some extra time...', + ) + with db_config.database.transaction(): + for exercise in table: + exercise.number = exercise.id + exercise.course = course + exercise.save() + + +def _create_usercourses_objects(table: Model, course: models.Course) -> None: + log.info('Adding UserCourse for all users, might take some extra time...') + UserCourse = models.UserCourse + with db_config.database.transaction(): + for user in table: + UserCourse.create(user=user, course=course) + + def _add_uuid_to_users_table(table: Model, _column: Field) -> None: log.info('Adding UUIDs for all users, might take some extra time...') with db_config.database.transaction(): @@ -240,6 +261,32 @@ 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 _exercise_course_migration(course: models.Course) -> bool: + Exercise = models.Exercise + _create_usercourses_objects(models.User, course) + _add_course_and_numbers_to_exercises_table(Exercise, course) + return True + + +def _add_exercise_course_id_and_number_columns_constraint() -> bool: + Exercise = models.Exercise + migrator = db_config.get_migrator_instance() + with db_config.database.transaction(): + course_not_exists = _add_not_null_column(Exercise, Exercise.course) + number_not_exists = _add_not_null_column(Exercise, Exercise.number) + if course_not_exists and number_not_exists: + migrate( + migrator.add_index('exercise', ('course_id', 'number'), True), + ) + db_config.database.commit() + + def _last_status_view_migration() -> bool: Solution = models.Solution _migrate_column_in_table_if_needed(Solution, Solution.last_status_view) @@ -254,11 +301,15 @@ def _uuid_migration() -> bool: def main(): with models.database.connection_context(): + if models.database.table_exists(models.Exercise.__name__.lower()): + _add_exercise_course_id_and_number_columns_constraint() + if models.database.table_exists(models.Solution.__name__.lower()): _last_status_view_migration() if models.database.table_exists(models.User.__name__.lower()): _api_keys_migration() + _last_course_viewed_migration() _uuid_migration() models.database.create_tables(models.ALL_MODELS, safe=True) @@ -267,6 +318,9 @@ def main(): models.create_basic_roles() if models.User.select().count() == 0: models.create_demo_users() + if models.Course.select().count() == 0: + course = models.create_basic_course() + _exercise_course_migration(course) text_fixer.fix_texts() import_tests.load_tests_from_path('/app_dir/notebooks-tests') diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index f311296c..cf3c8bc1 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -5,8 +5,8 @@ from collections import Counter from datetime import datetime from typing import ( - Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, - Type, Union, cast, + Any, Dict, Iterable, List, Optional, + TYPE_CHECKING, Tuple, Type, Union, cast, ) from uuid import uuid4 @@ -132,6 +132,31 @@ def is_viewer(self) -> bool: return self.name == RoleOptions.VIEWER.value or self.is_manager +class Course(BaseModel): + name = CharField(unique=True) + date = DateTimeField(default=datetime.now) + end_date = DateTimeField(null=True) + close_registration_date = DateTimeField(default=datetime.now) + invite_code = CharField(null=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 ( + cls + .select() + .join(UserCourse) + .where(UserCourse.user == user.id) + .order_by(Course.name.desc()) + ) + + def __str__(self): + return f'{self.name}: {self.date} - {self.end_date}' + + class User(UserMixin, BaseModel): username = CharField(unique=True) fullname = CharField() @@ -139,14 +164,18 @@ class User(UserMixin, BaseModel): password = CharField() role = ForeignKeyField(Role, backref='users') api_key = CharField() + last_course_viewed = ForeignKeyField(Course, null=True) uuid = UUIDField(default=uuid4, unique=True) def get_id(self): return str(self.uuid) - def is_password_valid(self, password): + def is_password_valid(self, password) -> bool: return check_password_hash(self.password, password) + def has_course(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(**{ @@ -168,6 +197,9 @@ def random_password(cls, stronger: bool = False) -> str: def get_notifications(self) -> Iterable['Notification']: return Notification.fetch(self) + def get_courses(self) -> Iterable['Course']: + return Course.fetch(self) + def notes(self) -> Iterable['Note']: fields = ( Note.id, Note.creator.fullname, CommentText.text, @@ -215,6 +247,24 @@ 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() + .where( + cls.user == user_id, + cls.course == course_id, + ) + .exists() + ) + + class Notification(BaseModel): ID_FIELD_NAME = 'id' MAX_PER_USER = 10 @@ -304,6 +354,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(default=1) + + class Meta: + indexes = ( + (('course_id', 'number'), True), + ) def open_for_new_solutions(self) -> bool: if self.due_date is None: @@ -311,8 +368,32 @@ 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_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, + from_all_courses: bool = False, + ): + user = User.get(User.id == user_id) + exercises = ( + cls + .select() + .join(Course) + .join(UserCourse) + .where(UserCourse.user == user_id) + .switch() + .order_by(UserCourse.date, Exercise.number, Exercise.order) + ) + if not from_all_courses: + exercises = exercises.where( + UserCourse.course == user.last_course_viewed, + ) if not fetch_archived: exercises = exercises.where(cls.is_archived == False) # NOQA: E712 return exercises @@ -324,6 +405,9 @@ 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 @@ -334,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' @@ -469,10 +561,13 @@ def test_results(self) -> Iterable[dict]: @classmethod def of_user( cls, user_id: int, with_archived: bool = False, + from_all_courses: bool = False, ) -> Iterable[Dict[str, Any]]: - db_exercises = Exercise.get_objects(fetch_archived=with_archived) + db_exercises = Exercise.get_objects( + user_id=user_id, fetch_archived=with_archived, + from_all_courses=from_all_courses, + ) exercises = Exercise.as_dicts(db_exercises) - solutions = ( cls .select(cls.exercise, cls.id, cls.state, cls.checker) @@ -946,7 +1041,7 @@ def generate_string( return ''.join(password) -def create_demo_users(): +def create_demo_users() -> None: print('First run! Here are some users to get start with:') # noqa: T001 fields = ['username', 'fullname', 'mail_address', 'role'] student_role = Role.by_name('Student') @@ -964,9 +1059,13 @@ def create_demo_users(): print(f"User: {user['username']}, Password: {password}") # noqa: T001 -def create_basic_roles(): +def create_basic_roles() -> None: for role in RoleOptions: Role.create(name=role.value) +def create_basic_course() -> Course: + return Course.create(name='Python Course', date=datetime.now()) + + ALL_MODELS = BaseModel.__subclasses__() diff --git a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po index 29a622f6..bffb9c09 100644 --- a/lms/lmsweb/translations/he/LC_MESSAGES/messages.po +++ b/lms/lmsweb/translations/he/LC_MESSAGES/messages.po @@ -1,13 +1,13 @@ -# Hebrew translations for PROJECT. +# Hebrew translations for LMS prjoect. # Copyright (C) 2021 ORGANIZATION -# This file is distributed under the same license as the PROJECT project. +# This file is distributed under the same license as the LMS project. # FIRST AUTHOR , 2021. # msgid "" msgstr "" -"Project-Id-Version: 1.0\n" +"Project-Id-Version: 1.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2021-09-29 11:58+0300\n" +"POT-Creation-Date: 2021-10-01 10:15+0300\n" "PO-Revision-Date: 2021-09-29 11:30+0300\n" "Last-Translator: Or Ronai\n" "Language: he\n" @@ -18,7 +18,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.9.1\n" -#: lmsdb/models.py:736 +#: lmsdb/models.py:815 msgid "Fatal error" msgstr "כישלון חמור" @@ -223,23 +223,28 @@ msgstr "הודעות" msgid "Mark all as read" msgstr "סמן הכל כנקרא" -#: templates/navbar.html:44 +#: templates/navbar.html:45 +#, fuzzy +msgid "Courses List" +msgstr "רשימת הקורסים" + +#: templates/navbar.html:65 msgid "Upload Exercises" msgstr "העלאת תרגילים" -#: templates/navbar.html:51 +#: templates/navbar.html:73 msgid "Exercises List" msgstr "רשימת התרגילים" -#: templates/navbar.html:59 +#: templates/navbar.html:81 msgid "Exercises Archive" msgstr "ארכיון התרגילים" -#: templates/navbar.html:69 +#: templates/navbar.html:91 msgid "Check Exercises" msgstr "בדוק תרגילים" -#: templates/navbar.html:76 +#: templates/navbar.html:98 msgid "Logout" msgstr "התנתקות" @@ -289,7 +294,7 @@ msgstr "חמ\"ל תרגילים" msgid "Name" msgstr "שם" -#: templates/status.html:13 templates/user.html:41 +#: templates/status.html:13 templates/user.html:43 msgid "Checked" msgstr "נבדק/ו" @@ -342,46 +347,51 @@ msgid "Exercises Submitted" msgstr "תרגילים שהוגשו" #: templates/user.html:29 +#, fuzzy +msgid "Course name" +msgstr "שם קורס" + +#: templates/user.html:30 msgid "Exercise name" msgstr "שם תרגיל" -#: templates/user.html:30 +#: templates/user.html:31 msgid "Submission status" msgstr "מצב הגשה" -#: templates/user.html:31 +#: templates/user.html:32 msgid "Submission" msgstr "הגשה" -#: templates/user.html:32 +#: templates/user.html:33 msgid "Checker" msgstr "בודק" -#: templates/user.html:41 +#: templates/user.html:43 msgid "Submitted" msgstr "הוגש" -#: templates/user.html:41 +#: templates/user.html:43 msgid "Not submitted" msgstr "לא הוגש" -#: templates/user.html:52 +#: templates/user.html:54 msgid "Notes" msgstr "פתקיות" -#: templates/user.html:57 templates/user.html:59 +#: templates/user.html:59 templates/user.html:61 msgid "New Note" msgstr "פתקית חדשה" -#: templates/user.html:63 +#: templates/user.html:65 msgid "Related Exercise" msgstr "תרגיל משויך" -#: templates/user.html:72 +#: templates/user.html:74 msgid "Privacy Level" msgstr "רמת פרטיות" -#: templates/user.html:78 +#: templates/user.html:80 msgid "Add Note" msgstr "הוסף פתקית" @@ -407,7 +417,7 @@ msgid "This solution is not up to date!" msgstr "פתרון זה אינו פתרון עדכני!" #: templates/view.html:15 -#, fuzzy, python-format +#, fuzzy msgid "Your solution hasn't been checked." msgstr "הפתרון שלך לתרגיל %(subject)s נבדק." diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index 608b15ea..cc5c9689 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -15,8 +15,8 @@ from werkzeug.utils import redirect from lms.lmsdb.models import ( - ALL_MODELS, Comment, Note, Role, RoleOptions, SharedSolution, - Solution, SolutionFile, User, database, + ALL_MODELS, Comment, Course, Note, Role, RoleOptions, SharedSolution, + Solution, SolutionFile, User, UserCourse, database, ) from lms.lmsweb import babel, limiter, routes, webapp from lms.lmsweb.admin import ( @@ -328,6 +328,20 @@ def status(): ) +@webapp.route('/course/') +@login_required +def change_last_course_viewed(course_id: int): + course = Course.get_or_none(course_id) + if course is None: + return fail(404, f'No such course {course_id}.') + user = User.get(User.id == current_user.id) + if not UserCourse.is_user_registered(user.id, course.id): + return fail(403, "You're not allowed to access this page.") + user.last_course_viewed = course + user.save() + return redirect(url_for('exercises_page')) + + @webapp.route('/exercises') @login_required def exercises_page(): @@ -463,10 +477,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/') @@ -482,22 +498,26 @@ def user(user_id): return render_template( 'user.html', - solutions=Solution.of_user(target_user.id, with_archived=True), + solutions=Solution.of_user( + target_user.id, with_archived=True, from_all_courses=True, + ), user=target_user, is_manager=is_manager, notes_options=Note.get_note_options(), ) -@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: @@ -512,7 +532,7 @@ def upload_page(): return fail(422, 'No file was given.') try: - matches, misses = upload.new(user, file) + matches, misses = upload.new(user.id, course_id, file) 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..b231c157 100644 --- a/lms/models/upload.py +++ b/lms/models/upload.py @@ -23,22 +23,30 @@ def _is_uploaded_before( def _upload_to_db( - exercise_id: int, - user: User, + exercise_number: int, + course_id: int, + user_id: int, 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) + user = User.get_by_id(user_id) if exercise is None: - raise UploadError(f'No such exercise id: {exercise_id}') + raise UploadError(f'No such exercise id: {exercise_number}') + elif not user.has_course(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 +64,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_id: int, course_id: int, file: FileStorage, +) -> 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_id, 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 f0d9d564..a637d906 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -150,7 +150,7 @@ a { justify-content: end; } -.exercise-id { +.exercise-number { align-items: center; border-radius: 100%; border: 1px solid; @@ -162,14 +162,6 @@ a { width: 3rem; } -.rtl-language > .exercise-id { - margin-left: 2em; -} - -.ltr-language > .exercise-id { - margin-right: 2em; -} - .exercise-name { align-items: center; display: flex; @@ -778,12 +770,19 @@ code .grader-add .fa { margin-inline-end: 1rem; } -#notification-icon { +#courses-list { + overflow-y: scroll; + max-height: 10em; +} + +#notification-icon, +#courses-icon { cursor: pointer; } /* -.notification { +.notification, +.course { flex-direction: column; text-align: start; } @@ -896,7 +895,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 3f274494..0b08335f 100644 --- a/lms/templates/exercises.html +++ b/lms/templates/exercises.html @@ -12,7 +12,7 @@

{{ _('Exercises') }}

{%- for exercise in exercises %}
-
{{ exercise['exercise_id'] }}
+
{{ exercise['exercise_number'] }}
{{ exercise['exercise_name'] | e }}
@@ -27,7 +27,7 @@

{{ _('Exercises') }}

{%- endif %} {%- if exercise.get('is_checked') is none %} - {% set details = {'page': 'send', 'icon': 'upload', 'text': _('Send'), 'css': 'send', 'page_id': exercise['exercise_id']} %} + {% set details = {'page': 'send', 'icon': 'upload', 'text': _('Send'), 'css': 'send', 'page_id': exercise['course_id'] ~ '/' ~ exercise['exercise_id']} %} {% elif not exercise.get('is_checked') %} {% set details = {'page': 'view', 'icon': 'eye', 'text': _('View'), 'css': 'view', 'page_id': exercise['solution_id']} %} {% else %} diff --git a/lms/templates/navbar.html b/lms/templates/navbar.html index 6dd4d40c..d2e49864 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -38,12 +38,34 @@ - + {%- if current_user.last_course_viewed %} + + {% endif -%} {%- if not exercises or fetch_archived %}