Skip to content

Commit 2a60bfb

Browse files
committed
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
1 parent 07cafa7 commit 2a60bfb

File tree

13 files changed

+252
-104
lines changed

13 files changed

+252
-104
lines changed

lms/lmsdb/models.py

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
from collections import Counter
1+
from collections import Counter, defaultdict
22
import enum
33
import html
44
import secrets
55
import string
66
from datetime import datetime
77
from typing import (
8-
Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple,
9-
Type, Union, cast,
8+
Any, DefaultDict, Dict, Iterable, List, Optional,
9+
TYPE_CHECKING, Tuple, Type, Union, cast,
1010
)
1111

1212
from flask_babel import gettext as _ # type: ignore
@@ -27,7 +27,7 @@
2727

2828

2929
database = database_config.get_db_instance()
30-
ExercisesDictById = Dict[int, Dict[str, Any]]
30+
ExercisesDictById = DefaultDict[str, Dict[int, Dict[str, Any]]]
3131
if TYPE_CHECKING:
3232
from lms.extractors.base import File
3333

@@ -121,6 +121,17 @@ def is_viewer(self) -> bool:
121121
return self.name == RoleOptions.VIEWER.value or self.is_manager
122122

123123

124+
class Course(BaseModel):
125+
name = CharField(unique=True)
126+
date = DateTimeField()
127+
due_date = DateTimeField(null=True)
128+
is_finished = BooleanField(default=False)
129+
close_registration_date = DateTimeField(default=datetime.now)
130+
131+
def __str__(self):
132+
return f'{self.name}: {self.date} - {self.due_date}'
133+
134+
124135
class User(UserMixin, BaseModel):
125136
username = CharField(unique=True)
126137
fullname = CharField()
@@ -199,6 +210,25 @@ def on_save_handler(model_class, instance, created):
199210
instance.api_key = generate_password_hash(instance.api_key)
200211

201212

213+
class UserCourse(BaseModel):
214+
user = ForeignKeyField(User, backref='usercourses')
215+
course = ForeignKeyField(Course, backref='usercourses')
216+
date = DateTimeField(default=datetime.now)
217+
218+
@classmethod
219+
def is_user_registered(cls, user_id: int, course_id: int) -> bool:
220+
return (
221+
cls.
222+
select()
223+
.join(User)
224+
.where(User.id == user_id)
225+
.switch()
226+
.join(Course)
227+
.where(Course.id == course_id)
228+
.exists()
229+
)
230+
231+
202232
class Notification(BaseModel):
203233
ID_FIELD_NAME = 'id'
204234
MAX_PER_USER = 10
@@ -288,15 +318,30 @@ class Exercise(BaseModel):
288318
due_date = DateTimeField(null=True)
289319
notebook_num = IntegerField(default=0)
290320
order = IntegerField(default=0, index=True)
321+
course = ForeignKeyField(Course, backref='exercise')
322+
number = IntegerField()
323+
324+
class Meta:
325+
indexes = (
326+
(('course_id', 'number'), True),
327+
)
291328

292329
def open_for_new_solutions(self) -> bool:
293330
if self.due_date is None:
294331
return not self.is_archived
295332
return datetime.now() < self.due_date and not self.is_archived
296333

297334
@classmethod
298-
def get_objects(cls, fetch_archived: bool = False):
299-
exercises = cls.select().order_by(Exercise.order)
335+
def get_objects(cls, user_id: int, fetch_archived: bool = False):
336+
exercises = (
337+
cls
338+
.select()
339+
.join(Course)
340+
.join(UserCourse)
341+
.where(UserCourse.user == user_id)
342+
.switch()
343+
.order_by(UserCourse.date, Exercise.order)
344+
)
300345
if not fetch_archived:
301346
exercises = exercises.where(cls.is_archived == False) # NOQA: E712
302347
return exercises
@@ -308,11 +353,17 @@ def as_dict(self) -> Dict[str, Any]:
308353
'is_archived': self.is_archived,
309354
'notebook': self.notebook_num,
310355
'due_date': self.due_date,
356+
'exercise_number': self.number,
357+
'course_id': self.course.id,
358+
'course_name': self.course.name,
311359
}
312360

313361
@staticmethod
314362
def as_dicts(exercises: Iterable['Exercise']) -> ExercisesDictById:
315-
return {exercise.id: exercise.as_dict() for exercise in exercises}
363+
nested_dict = defaultdict(dict)
364+
for exercise in exercises:
365+
nested_dict[exercise.course.name][exercise.id] = exercise.as_dict()
366+
return nested_dict
316367

317368
def __str__(self):
318369
return self.subject
@@ -422,25 +473,27 @@ def test_results(self) -> Iterable[dict]:
422473
@classmethod
423474
def of_user(
424475
cls, user_id: int, with_archived: bool = False,
425-
) -> Iterable[Dict[str, Any]]:
426-
db_exercises = Exercise.get_objects(fetch_archived=with_archived)
476+
) -> Iterable[DefaultDict[str, Dict[str, Any]]]:
477+
db_exercises = Exercise.get_objects(
478+
user_id=user_id, fetch_archived=with_archived,
479+
)
427480
exercises = Exercise.as_dicts(db_exercises)
428-
429481
solutions = (
430482
cls
431483
.select(cls.exercise, cls.id, cls.state, cls.checker)
432484
.where(cls.exercise.in_(db_exercises), cls.solver == user_id)
433485
.order_by(cls.submission_timestamp.desc())
434486
)
435487
for solution in solutions:
436-
exercise = exercises[solution.exercise_id]
488+
course_name = solution.exercise.course.name
489+
exercise = exercises[course_name][solution.exercise_id]
437490
if exercise.get('solution_id') is None:
438491
exercise['solution_id'] = solution.id
439492
exercise['is_checked'] = solution.is_checked
440493
exercise['comments_num'] = len(solution.staff_comments)
441494
if solution.is_checked and solution.checker:
442495
exercise['checker'] = solution.checker.fullname
443-
return tuple(exercises.values())
496+
return exercises
444497

445498
@property
446499
def comments(self):

lms/lmsweb/views.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from lms.lmsdb.models import (
1616
ALL_MODELS, Comment, Note, RoleOptions, SharedSolution,
17-
Solution, SolutionFile, User, database,
17+
Solution, SolutionFile, User, UserCourse, database,
1818
)
1919
from lms.lmsweb import babel, limiter, routes, webapp
2020
from lms.lmsweb.admin import (
@@ -305,10 +305,12 @@ def comment():
305305
return fail(400, f'Unknown or unset act value "{act}".')
306306

307307

308-
@webapp.route('/send/<int:_exercise_id>')
308+
@webapp.route('/send/<int:course_id>/<int:_exercise_number>')
309309
@login_required
310-
def send(_exercise_id):
311-
return render_template('upload.html')
310+
def send(course_id: int, _exercise_number: Optional[int]):
311+
if not UserCourse.is_user_registered(current_user.id, course_id):
312+
return fail(403, "You aren't allowed to watch this page.")
313+
return render_template('upload.html', course_id=course_id)
312314

313315

314316
@webapp.route('/user/<int:user_id>')
@@ -331,15 +333,17 @@ def user(user_id):
331333
)
332334

333335

334-
@webapp.route('/send', methods=['GET'])
336+
@webapp.route('/send/<int:course_id>', methods=['GET'])
335337
@login_required
336-
def send_():
337-
return render_template('upload.html')
338+
def send_(course_id: int):
339+
if not UserCourse.is_user_registered(current_user.id, course_id):
340+
return fail(403, "You aren't allowed to watch this page.")
341+
return render_template('upload.html', course_id=course_id)
338342

339343

340-
@webapp.route('/upload', methods=['POST'])
344+
@webapp.route('/upload/<int:course_id>', methods=['POST'])
341345
@login_required
342-
def upload_page():
346+
def upload_page(course_id: int):
343347
user_id = current_user.id
344348
user = User.get_or_none(User.id == user_id) # should never happen
345349
if user is None:
@@ -354,7 +358,7 @@ def upload_page():
354358
return fail(422, 'No file was given.')
355359

356360
try:
357-
matches, misses = upload.new(user, file)
361+
matches, misses = upload.new(user, file, course_id)
358362
except UploadError as e:
359363
log.debug(e)
360364
return fail(400, str(e))

lms/models/upload.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from werkzeug.datastructures import FileStorage
44

55
from lms.extractors.base import Extractor, File
6-
from lms.lmsdb.models import Exercise, Solution, User
6+
from lms.lmsdb.models import Exercise, Solution, User, UserCourse
77
from lms.lmstests.public.identical_tests import tasks as identical_tests_tasks
88
from lms.lmstests.public.linters import tasks as linters_tasks
99
from lms.lmstests.public.unittests import tasks as unittests_tasks
@@ -23,22 +23,29 @@ def _is_uploaded_before(
2323

2424

2525
def _upload_to_db(
26-
exercise_id: int,
26+
exercise_number: int,
27+
course_id: int,
2728
user: User,
2829
files: List[File],
2930
solution_hash: Optional[str] = None,
3031
) -> Solution:
31-
exercise = Exercise.get_or_none(exercise_id)
32+
exercise = Exercise.get_or_none(course=course_id, number=exercise_number)
3233
if exercise is None:
33-
raise UploadError(f'No such exercise id: {exercise_id}')
34+
raise UploadError(f'No such exercise id: {exercise_number}')
35+
elif not UserCourse.is_user_registered(user.id, course_id):
36+
raise UploadError(
37+
f'Exercise {exercise_number} is invalid for this user.',
38+
)
3439
elif not exercise.open_for_new_solutions():
3540
raise UploadError(
36-
f'Exercise {exercise_id} is closed for new solutions.')
41+
f'Exercise {exercise_number} is closed for new solutions.')
3742

3843
if solution_hash and _is_uploaded_before(user, exercise, solution_hash):
3944
raise AlreadyExists('You try to reupload an old solution.')
4045
elif not files:
41-
raise UploadError(f'There are no files to upload for {exercise_id}.')
46+
raise UploadError(
47+
f'There are no files to upload for {exercise_number}.',
48+
)
4249

4350
return Solution.create_solution(
4451
exercise=exercise,
@@ -56,20 +63,24 @@ def _run_auto_checks(solution: Solution) -> None:
5663
check_ident.apply_async(args=(solution.id,))
5764

5865

59-
def new(user: User, file: FileStorage) -> Tuple[List[int], List[int]]:
66+
def new(
67+
user: User, file: FileStorage, course_id: int,
68+
) -> Tuple[List[int], List[int]]:
6069
matches: List[int] = []
6170
misses: List[int] = []
6271
errors: List[Union[UploadError, AlreadyExists]] = []
63-
for exercise_id, files, solution_hash in Extractor(file):
72+
for exercise_number, files, solution_hash in Extractor(file):
6473
try:
65-
solution = _upload_to_db(exercise_id, user, files, solution_hash)
74+
solution = _upload_to_db(
75+
exercise_number, course_id, user, files, solution_hash,
76+
)
6677
_run_auto_checks(solution)
6778
except (UploadError, AlreadyExists) as e:
6879
log.debug(e)
6980
errors.append(e)
70-
misses.append(exercise_id)
81+
misses.append(exercise_number)
7182
else:
72-
matches.append(exercise_id)
83+
matches.append(exercise_number)
7384

7485
if not matches and errors:
7586
raise UploadError(errors)

lms/static/my.css

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ a {
130130
justify-content: end;
131131
}
132132

133-
.exercise-id {
133+
.exercise-number {
134134
align-items: center;
135135
border-radius: 100%;
136136
border: 1px solid;
@@ -142,11 +142,11 @@ a {
142142
width: 3rem;
143143
}
144144

145-
.rtl-language > .exercise-id {
145+
.rtl-language > .exercise-number {
146146
margin-left: 2em;
147147
}
148148

149-
.ltr-language > .exercise-id {
149+
.ltr-language > .exercise-number {
150150
margin-right: 2em;
151151
}
152152

@@ -876,7 +876,7 @@ code .grader-add .fa {
876876
}
877877

878878
@media screen and (max-width: 768px) {
879-
.exercise-id {
879+
.exercise-number {
880880
display: none;
881881
}
882882
.which-notebook {

lms/templates/exercises.html

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,47 @@ <h1 id="exercises-head">{{ _('תרגילים') }}</h1>
99
</div>
1010
</div>
1111
<div id="exercises">
12-
{%- for exercise in exercises %}
13-
<div class="exercise">
14-
<div class="right-side {{ direction }}-language">
15-
<div class="exercise-id">{{ exercise['exercise_id'] }}</div>
16-
<div class="exercise-name"><div class="ex-title">{{ exercise['exercise_name'] | e }}</div></div>
17-
</div>
18-
<div class="left-side">
19-
<div class="comments-count">
20-
<span class="badge bg-secondary">{{ exercise['comments_num'] }}</span>
21-
<span class="visually-hidden">{{ _("הערות על התרגיל") }}</span>
12+
{% for course, values in exercises.items() %}
13+
<h3>{{ course }}</h3>
14+
{%- for exercise in values.values() %}
15+
<div class="exercise">
16+
<div class="right-side {{ direction }}-language">
17+
<div class="exercise-number">{{ exercise['exercise_number'] }}</div>
18+
<div class="exercise-name"><div class="ex-title">{{ exercise['exercise_name'] | e }}</div></div>
2219
</div>
23-
{%- if exercise['notebook'] %}
24-
<div class="which-notebook">
25-
<i class="fa fa-book" aria-hidden="true"></i>
26-
{{ exercise['notebook'] }}
20+
<div class="left-side">
21+
<div class="comments-count">
22+
<span class="badge bg-secondary">{{ exercise['comments_num'] }}</span>
23+
<span class="visually-hidden">{{ _("הערות על התרגיל") }}</span>
24+
</div>
25+
{%- if exercise['notebook'] %}
26+
<div class="which-notebook">
27+
<i class="fa fa-book" aria-hidden="true"></i>
28+
{{ exercise['notebook'] }}
29+
</div>
30+
{%- endif %}
31+
{%- if exercise.get('is_checked') is none %}
32+
{% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['course_id']} %}
33+
{% elif not exercise.get('is_checked') %}
34+
{% set details = {'page': 'view', 'icon': 'eye', 'text': _('הצצה'), 'css': 'view', 'page_id': exercise['solution_id']} %}
35+
{% else %}
36+
{% set details = {'page': 'view', 'icon': 'check-circle-o', 'text': _('לבדיקה'), 'css': 'checked', 'page_id': exercise['solution_id']} %}
37+
{% endif -%}
38+
{%- if not exercise.get('is_archived') or exercise.get('is_checked') is not none %}
39+
<a class="our-button go-{{ details['css'] }}" href="{{ details['page'] }}/{{ details['page_id'] }}">
40+
{{ details['text'] | e }}
41+
<i class="fa fa-{{ details['icon'] }}" aria-hidden="true"></i>
42+
</a>
43+
{% endif -%}
44+
{% if is_manager %}
45+
<a class="our-button our-button-narrow go-grader" href="check/{{ exercise['exercise_id'] }}">
46+
<i class="fa fa-graduation-cap" aria-hidden="true"></i>
47+
</a>
48+
{% endif %}
2749
</div>
28-
{%- endif %}
29-
{%- if exercise.get('is_checked') is none %}
30-
{% set details = {'page': 'send', 'icon': 'upload', 'text': _('שלח'), 'css': 'send', 'page_id': exercise['exercise_id']} %}
31-
{% elif not exercise.get('is_checked') %}
32-
{% set details = {'page': 'view', 'icon': 'eye', 'text': _('הצצה'), 'css': 'view', 'page_id': exercise['solution_id']} %}
33-
{% else %}
34-
{% set details = {'page': 'view', 'icon': 'check-circle-o', 'text': _('לבדיקה'), 'css': 'checked', 'page_id': exercise['solution_id']} %}
35-
{% endif -%}
36-
{%- if not exercise.get('is_archived') or exercise.get('is_checked') is not none %}
37-
<a class="our-button go-{{ details['css'] }}" href="{{ details['page'] }}/{{ details['page_id'] }}">
38-
{{ details['text'] | e }}
39-
<i class="fa fa-{{ details['icon'] }}" aria-hidden="true"></i>
40-
</a>
41-
{% endif -%}
42-
{% if is_manager %}
43-
<a class="our-button our-button-narrow go-grader" href="check/{{ exercise['exercise_id'] }}">
44-
<i class="fa fa-graduation-cap" aria-hidden="true"></i>
45-
</a>
46-
{% endif %}
4750
</div>
48-
</div>
49-
{% endfor -%}
51+
{% endfor -%}
52+
{% endfor %}
5053
{%- if not fetch_archived %}
5154
<div class="exercise centered">
5255
<div id="show-all-exercises-action" class="right-side">

0 commit comments

Comments
 (0)