diff --git a/lms/lmsdb/models.py b/lms/lmsdb/models.py index dc29694d..f970c06d 100644 --- a/lms/lmsdb/models.py +++ b/lms/lmsdb/models.py @@ -375,6 +375,27 @@ def on_notification_saved( instance.delete_instance() +class Tag(BaseModel): + text = TextField() + course = ForeignKeyField(Course) + date_created = DateTimeField(default=datetime.now) + + class Meta: + indexes = ( + (('text', 'course_id'), True), + ) + + @classmethod + def create_tag(cls, course: Course, text: str) -> 'Tag': + instance, _ = cls.get_or_create( + **{cls.text.name: html.escape(text), cls.course.name: course}, + ) + return instance + + def __str__(self): + return self.text + + class Exercise(BaseModel): subject = CharField() date = DateTimeField() @@ -416,12 +437,8 @@ def is_number_exists(cls, course: Course, number: int) -> bool: ) @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 = ( + def by_user(cls, user_id: int): + return ( cls .select() .join(Course) @@ -430,10 +447,20 @@ def get_objects( .switch() .order_by(UserCourse.date, Exercise.number, Exercise.order) ) + + @classmethod + def get_objects( + cls, user_id: int, fetch_archived: bool = False, + from_all_courses: bool = False, exercise_tag: Optional[str] = None, + ): + user = User.get(User.id == user_id) + exercises = cls.by_user(user_id) if not from_all_courses: exercises = exercises.where( UserCourse.course == user.last_course_viewed, ) + if exercise_tag: + exercises = Exercise.by_tags(exercises, exercise_tag) if not fetch_archived: exercises = exercises.where(cls.is_archived == False) # NOQA: E712 return exercises @@ -448,12 +475,22 @@ def as_dict(self) -> Dict[str, Any]: 'exercise_number': self.number, 'course_id': self.course.id, 'course_name': self.course.name, + 'tags': ExerciseTag.by_exercise(self), } @staticmethod def as_dicts(exercises: Iterable['Exercise']) -> ExercisesDictById: return {exercise.id: exercise.as_dict() for exercise in exercises} + @staticmethod + def by_tags(exercises: Iterable['Exercise'], exercise_tag: str): + return ( + exercises + .join(ExerciseTag) + .join(Tag) + .where(Tag.text == exercise_tag) + ) + def __str__(self): return self.subject @@ -465,6 +502,23 @@ def exercise_number_save_handler(model_class, instance, created): instance.number = model_class.get_highest_number(instance.course) + 1 +class ExerciseTag(BaseModel): + exercise = ForeignKeyField(Exercise) + tag = ForeignKeyField(Tag) + date = DateTimeField(default=datetime.now) + + @classmethod + def by_exercise( + cls, exercise: Exercise, + ) -> Union[Iterable['ExerciseTag'], 'ExerciseTag']: + return ( + cls + .select() + .where(cls.exercise == exercise) + .order_by(cls.date) + ) + + class SolutionState(enum.Enum): CREATED = 'Created' IN_CHECKING = 'In checking' @@ -638,11 +692,11 @@ def test_results(self) -> Iterable[dict]: @classmethod def of_user( cls, user_id: int, with_archived: bool = False, - from_all_courses: bool = False, + from_all_courses: bool = False, exercise_tag: Optional[str] = None, ) -> Iterable[Dict[str, Any]]: db_exercises = Exercise.get_objects( user_id=user_id, fetch_archived=with_archived, - from_all_courses=from_all_courses, + from_all_courses=from_all_courses, exercise_tag=exercise_tag, ) exercises = Exercise.as_dicts(db_exercises) solutions = ( diff --git a/lms/lmsweb/views.py b/lms/lmsweb/views.py index 617ddc05..c41f4f78 100644 --- a/lms/lmsweb/views.py +++ b/lms/lmsweb/views.py @@ -357,6 +357,31 @@ def exercises_page(): ) +@webapp.route('/exercises/') +@login_required +def exercises_tag_page(tagname: str): + fetch_archived = bool(request.args.get('archived')) + try: + solutions.is_tag_name_exists( + tagname, current_user.last_course_viewed.id, + ) + except LmsError as e: + error_message, status_code = e.args + return fail(status_code, error_message) + + exercises = Solution.of_user( + current_user.id, fetch_archived, exercise_tag=tagname, + ) + is_manager = current_user.role.is_manager + return render_template( + 'exercises.html', + exercises=exercises, + is_manager=is_manager, + fetch_archived=fetch_archived, + tag_name=tagname, + ) + + @webapp.route('/notifications') @login_required def get_notifications(): @@ -480,7 +505,7 @@ def comment(): @webapp.route('/send//') @login_required -def send(course_id: int, _exercise_number: Optional[int]): +def send(course_id: int, _exercise_number: Optional[int] = None): 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) diff --git a/lms/models/solutions.py b/lms/models/solutions.py index 6cf1c80f..ec5991d8 100644 --- a/lms/models/solutions.py +++ b/lms/models/solutions.py @@ -14,7 +14,7 @@ from lms.lmstests.public.general import tasks as general_tasks from lms.lmstests.public.identical_tests import tasks as identical_tests_tasks from lms.lmsweb import config, routes -from lms.models import comments, notifications +from lms.models import comments, notifications, tags from lms.models.errors import ForbiddenPermission, ResourceNotFound from lms.utils.files import ALLOWED_IMAGES_EXTENSIONS @@ -213,3 +213,11 @@ def get_files_tree(files: Iterable[SolutionFile]) -> List[Dict[str, Any]]: for file in file_details: del file['fullpath'] return file_details + + +def is_tag_name_exists(tag_name: str, course_id: int) -> None: + if not tags.get_exercises_of(course_id, tag_name): + raise ResourceNotFound( + f'No such tag {tag_name} for course ' + f'{current_user.last_course_viewed.name}.', 404, + ) diff --git a/lms/models/tags.py b/lms/models/tags.py new file mode 100644 index 00000000..abbbf4e9 --- /dev/null +++ b/lms/models/tags.py @@ -0,0 +1,30 @@ +from typing import Iterable, Union + +from lms.lmsdb.models import Exercise, ExerciseTag, Tag + + +def get_exercises_of( + course_id: int, tag_name: str, +) -> Union[Iterable['ExerciseTag'], 'ExerciseTag']: + return ( + ExerciseTag + .select(ExerciseTag.exercise) + .join(Tag) + .where(Tag.text == tag_name, Tag.course == course_id) + ) + + +def by_exercise_id( + exercise_id: int, +) -> Union[Iterable['ExerciseTag'], 'ExerciseTag']: + return ExerciseTag.select().where(ExerciseTag.exercise == exercise_id) + + +def by_course(course: int) -> Union[Iterable['ExerciseTag'], 'ExerciseTag']: + return ExerciseTag.select().join(Exercise).where(Exercise.course == course) + + +def by_exercise_number( + course: int, number: int, +) -> Union[Iterable['ExerciseTag'], 'ExerciseTag']: + return by_course(course).where(Exercise.number == number) diff --git a/lms/static/my.css b/lms/static/my.css index 42cab66d..550f153b 100644 --- a/lms/static/my.css +++ b/lms/static/my.css @@ -177,6 +177,16 @@ a { line-height: 3rem; } +.exercise-tags { + align-items: center; + display: flex; + font-size: 1em; + height: 3rem; + justify-content: center; + text-align: center; + white-space: normal; +} + .exercise-send { display: flex; flex-direction: row; diff --git a/lms/templates/exercises.html b/lms/templates/exercises.html index 0b08335f..28ef8e0d 100644 --- a/lms/templates/exercises.html +++ b/lms/templates/exercises.html @@ -5,7 +5,7 @@
-

{{ _('Exercises') }}

+

{{ _('Exercises') }}{% if tag_name %} - #{{ tag_name | e }}{% endif %}

@@ -14,6 +14,11 @@

{{ _('Exercises') }}

{{ exercise['exercise_number'] }}
{{ exercise['exercise_name'] | e }}
+
+ {% for tag in exercise.tags %} + #{{ tag.tag | e }} + {% endfor %} +
diff --git a/lms/templates/navbar.html b/lms/templates/navbar.html index d2e49864..d027c660 100644 --- a/lms/templates/navbar.html +++ b/lms/templates/navbar.html @@ -66,7 +66,7 @@ {% endif -%} - {%- if not exercises or fetch_archived %} + {%- if not exercises or fetch_archived or tag_name %}