Skip to content

Commit

Permalink
Add enrollment_type to Course, fix invitation students counter, add l…
Browse files Browse the repository at this point in the history
…isteners and students counter (#872)

* Complete

* Test fix

* Tests
  • Loading branch information
Dmi4er4 authored Sep 4, 2024
1 parent 7c38023 commit 767ba17
Show file tree
Hide file tree
Showing 25 changed files with 582 additions and 268 deletions.
2 changes: 1 addition & 1 deletion apps/admission/locale/ru/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-30 12:30+0000\n"
"POT-Creation-Date: 2024-09-04 09:52+0000\n"
"PO-Revision-Date: 2024-07-23 17:04+0000\n"
"Last-Translator: Дмитрий Чернушевич <[email protected]>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
Expand Down
23 changes: 23 additions & 0 deletions apps/courses/migrations/0051_auto_20240903_0840.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2024-09-03 08:40

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('courses', '0050_auto_20240830_0818'),
]

operations = [
migrations.RenameField(
model_name='course',
old_name='capacity',
new_name='learners_capacity',
),
migrations.AlterField(
model_name='courseclass',
name='is_conducted_by_invited',
field=models.BooleanField(default=False, verbose_name='Is conducted by invited'),
),
]
33 changes: 33 additions & 0 deletions apps/courses/migrations/0052_auto_20240903_1001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 3.2.18 on 2024-09-03 10:01

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('courses', '0051_auto_20240903_0840'),
]

operations = [
migrations.AddField(
model_name='course',
name='enrollment_type',
field=models.CharField(choices=[('Regular', 'InvitationEnrollmentTypes|Regular'), ('lections', 'InvitationEnrollmentTypes|Lections'), ('Any', 'InvitationEnrollmentTypes|Any')], default='Any', max_length=100, verbose_name='Enrollment|type'),
),
migrations.AddField(
model_name='course',
name='listeners_capacity',
field=models.PositiveSmallIntegerField(default=0, help_text='0 - unlimited', verbose_name='CourseOffering|listeners_capacity'),
),
migrations.AddField(
model_name='course',
name='listeners_count',
field=models.PositiveIntegerField(default=0, editable=False),
),
migrations.AlterField(
model_name='course',
name='learners_capacity',
field=models.PositiveSmallIntegerField(default=0, help_text='0 - unlimited', verbose_name='CourseOffering|learners_capacity'),
),
]
70 changes: 56 additions & 14 deletions apps/courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from courses.utils import TermPair, get_current_term_pair
from files.models import ConfigurableStorageFileField
from files.storage import private_storage
from learning.settings import GradeTypes, GradingSystems
from learning.settings import GradeTypes, GradingSystems, InvitationEnrollmentTypes
from learning.utils import humanize_duration

from .constants import ClassTypes, SemesterTypes
Expand Down Expand Up @@ -262,10 +262,19 @@ class Course(TimezoneAwareMixin, TimeStampedModel, DerivableFieldsMixin):
verbose_name=_("CourseOffering|grading_type"),
choices=GradingSystems.choices,
default=GradingSystems.BASE)
capacity = models.PositiveSmallIntegerField(
verbose_name=_("CourseOffering|capacity"),
learners_capacity = models.PositiveSmallIntegerField(
verbose_name=_("CourseOffering|learners_capacity"),
default=0,
help_text=_("0 - unlimited"))
listeners_capacity = models.PositiveSmallIntegerField(
verbose_name=_("CourseOffering|listeners_capacity"),
default=0,
help_text=_("0 - unlimited"))
enrollment_type = models.CharField(
verbose_name=_("Enrollment|type"),
max_length=100,
choices=InvitationEnrollmentTypes.choices,
default=InvitationEnrollmentTypes.ANY)
hours = models.PositiveSmallIntegerField(
verbose_name=_("CourseOffering|hours"),
blank=True,
Expand Down Expand Up @@ -392,15 +401,15 @@ class Course(TimezoneAwareMixin, TimeStampedModel, DerivableFieldsMixin):
help_text="Helpful for getting thumbnail on /videos/ page",
blank=True)
learners_count = models.PositiveIntegerField(editable=False, default=0)
listeners_count = models.PositiveIntegerField(editable=False, default=0)

objects = CourseDefaultManager()

derivable_fields = [
'public_videos_count',
'public_slides_count',
'public_attachments_count',
'youtube_video_id',
'learners_count',
'youtube_video_id'
]

class Meta:
Expand Down Expand Up @@ -472,11 +481,6 @@ def _compute_public_attachments_count(self):

return False

def _compute_learners_count(self):
"""
Calculate this value with external signal on adding new learner.
"""
return False

def save(self, *args, **kwargs):
# Make sure `self.completed_at` always has value
Expand All @@ -503,6 +507,12 @@ def clean(self):
msg = _("Deadline should be later than the start of "
"the enrollment period")
raise ValidationError(msg)
if self.enrollment_type == InvitationEnrollmentTypes.REGULAR and self.listeners_capacity != 0:
msg = _("You can not set listeners capacity with REGULAR enrollment type")
raise ValidationError(msg)
if self.enrollment_type == InvitationEnrollmentTypes.LECTIONS_ONLY and self.learners_capacity != 0:
msg = _("You can not set learners capacity with LECTIONS_ONLY enrollment type")
raise ValidationError(msg)

@property
def url_kwargs(self) -> dict:
Expand Down Expand Up @@ -625,16 +635,48 @@ def enrollment_is_open(self):
return starts_on <= today <= ends_on

@property
def is_learners_capacity_limited(self):
return self.learners_capacity > 0

@property
def is_listeners_capacity_limited(self):
return self.listeners_capacity > 0
@property
def is_capacity_limited(self):
return self.capacity > 0
if self.enrollment_type == InvitationEnrollmentTypes.REGULAR:
return self.is_learners_capacity_limited
elif self.enrollment_type == InvitationEnrollmentTypes.LECTIONS_ONLY:
return self.is_listeners_capacity_limited
elif self.enrollment_type == InvitationEnrollmentTypes.ANY:
return self.is_learners_capacity_limited and self.is_listeners_capacity_limited
else:
assert not "reachable"

@property
def places_left(self):
if self.is_capacity_limited:
return max(0, self.capacity - self.learners_count)
def learners_places_left(self):
if self.is_learners_capacity_limited:
return max(0, self.learners_capacity - self.learners_count)
else:
return float("inf")

@property
def listeners_places_left(self):
if self.is_listeners_capacity_limited:
return max(0, self.listeners_capacity - self.listeners_count)
else:
return float("inf")

@property
def places_left(self):
if self.enrollment_type == InvitationEnrollmentTypes.REGULAR:
return self.learners_places_left
elif self.enrollment_type == InvitationEnrollmentTypes.LECTIONS_ONLY:
return self.listeners_places_left
elif self.enrollment_type == InvitationEnrollmentTypes.ANY:
return self.listeners_places_left + self.learners_places_left
else:
assert not "reachable"

@property
def grading_system(self):
return GradingSystems.get_choice(self.grading_type)
Expand Down
6 changes: 4 additions & 2 deletions apps/courses/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Assignment, Course, CourseBranch, CourseGroupModes, CourseTeacher, CourseDurations
)
from courses.tests.factories import CourseFactory, MetaCourseFactory, SemesterFactory
from learning.settings import Branches, GradingSystems, GradeTypes
from learning.settings import Branches, GradingSystems, GradeTypes, InvitationEnrollmentTypes
from users.tests.factories import CuratorFactory, TeacherFactory


Expand All @@ -34,7 +34,9 @@ def _get_course_post_data(course=None):
"grading_type": GradingSystems.BASE,
"duration": CourseDurations.FULL,
"default_grade": GradeTypes.NOT_GRADED,
"capacity": 0,
"learners_capacity": 0,
"listeners_capacity": 0,
"enrollment_type": InvitationEnrollmentTypes.ANY,
"language": "ru",
"materials_visibility": MaterialVisibilityTypes.PUBLIC,
}
Expand Down
2 changes: 2 additions & 0 deletions apps/courses/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from learning.permissions import CreateCourseNews, ViewOwnEnrollments, ViewStudentGroup, EnrollInCourseByInvitation, \
InvitationEnrollPermissionObject
from learning.services import course_access_role
from learning.settings import InvitationEnrollmentTypes
from learning.teaching.utils import get_student_groups_url

__all__ = ('CourseDetailView', 'CourseUpdateView')
Expand Down Expand Up @@ -79,6 +80,7 @@ def get_context_data(self, *args, **kwargs):
context = {
'CourseGroupModes': CourseGroupModes,
'CourseDurations': CourseDurations,
'InvitationEnrollmentTypes': InvitationEnrollmentTypes,
'cad_add_news': can_add_news,
'can_add_assignment': can_add_assignment,
'can_add_course_classes': can_add_course_classes,
Expand Down
2 changes: 1 addition & 1 deletion apps/htmlpages/locale/ru/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: django\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-30 12:30+0000\n"
"POT-Creation-Date: 2024-09-04 09:52+0000\n"
"PO-Revision-Date: 2015-03-18 08:34+0000\n"
"Last-Translator: Jannis Leidel <[email protected]>\n"
"Language-Team: Russian (http://www.transifex.com/projects/p/django/language/"
Expand Down
5 changes: 5 additions & 0 deletions apps/learning/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,14 @@ class CourseEnrollmentForm(forms.Form):

def __init__(self, **kwargs):
ask_enrollment_reason = kwargs.pop('ask_enrollment_reason', None)
enrollment_type = kwargs.pop('enrollment_type')
super().__init__(**kwargs)
if not ask_enrollment_reason:
self.fields.pop('reason')
if enrollment_type != InvitationEnrollmentTypes.ANY:
assert enrollment_type in EnrollmentTypes.values
self.fields["type"].initial = enrollment_type
self.fields["type"].disabled = True

self.helper = FormHelper(self)
self.helper.layout.append(Submit('enroll', 'Записаться на курс'))
Expand Down
7 changes: 7 additions & 0 deletions apps/learning/gradebook/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
from users.tests.factories import (
CuratorFactory, StudentFactory, TeacherFactory, UserFactory
)
from django.utils.translation import gettext as _


# TODO: test redirect to gradebook for teachers if only 1 course in current term
Expand Down Expand Up @@ -326,6 +327,12 @@ def test_empty_gradebook_view(client):
field = 'final_grade_{}'.format(enrollment.pk)
assert field in response.context_data['form'].fields
assert len(students) == len(response.context_data['form'].fields)
assert smart_bytes(_("Students") + f":&nbsp;{len(students)}") in response.content
assert smart_bytes(_("Students and listeners") + f":&nbsp;{len(students)}") in response.content
EnrollmentFactory.create(course=co1, type=EnrollmentTypes.LECTIONS_ONLY)
response = client.get(co1.get_gradebook_url())
assert smart_bytes(_("Students") + f":&nbsp;{len(students)}") in response.content
assert smart_bytes(_("Students and listeners") + f":&nbsp;{len(students) + 1}") in response.content
for co in [co1, co2]:
url = co.get_gradebook_url()
assert smart_bytes(url) in response.content
Expand Down
2 changes: 1 addition & 1 deletion apps/learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ class CourseInvitation(models.Model):
on_delete=models.CASCADE)
token = models.CharField(verbose_name=_("Token"), max_length=128)
capacity = models.PositiveSmallIntegerField(
verbose_name=_("CourseOffering|capacity"),
verbose_name=_("CourseInvitation|capacity"),
default=0,
help_text=_("0 - unlimited"))
enrollment_type = models.CharField(
Expand Down
3 changes: 3 additions & 0 deletions apps/learning/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ def enroll_in_course(user, permission_object: EnrollPermissionObject):
logger.debug("Enrollment is closed")
return False
if course.is_capacity_limited and not course.places_left:
logger.debug("No places left")
return False
if not student_profile:
logger.debug("No student profile")
return
if not student_profile.is_active:
logger.debug("Permissions for student with inactive profile "
Expand All @@ -50,6 +52,7 @@ def enroll_in_course(user, permission_object: EnrollPermissionObject):
if student_profile.type == StudentTypes.INVITED:
invitation = student_profile.invitation
if invitation is None:
logger.debug("No invitation for invited student")
return False
course_invitation = invitation.courseinvitation_set.filter(course=permission_object.course)
return course_invitation.exists() and invitation.is_active
Expand Down
24 changes: 22 additions & 2 deletions apps/learning/services/enrollment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ def enroll(cls, student_profile: StudentProfile, course: Course,
learners_count = get_learners_count_subquery(
outer_ref=OuterRef('course_id')
)
filters.append(Q(course__capacity__gt=learners_count))
listeners_count = get_listeners_count_subquery(
outer_ref=OuterRef('course_id')
)
filters.append(Q(course__learners_capacity__gt=learners_count) | Q(course__learners_capacity=0))
filters.append(Q(course__listeners_capacity__gt=listeners_count) | Q(course__listeners_capacity=0))
attrs.update({
"is_deleted": False,
"student_profile": student_profile,
Expand Down Expand Up @@ -125,7 +129,18 @@ def get_learners_count_subquery(outer_ref: OuterRef) -> Func:
from learning.models import Enrollment
return Coalesce(Subquery(
(Enrollment.active
.filter(course_id=outer_ref)
.filter(course_id=outer_ref, type=EnrollmentTypes.REGULAR, invitation__isnull=True)
.order_by()
.values('course') # group by
.annotate(total=Count("*"))
.values("total"))
), Value(0))

def get_listeners_count_subquery(outer_ref: OuterRef) -> Func:
from learning.models import Enrollment
return Coalesce(Subquery(
(Enrollment.active
.filter(course_id=outer_ref, type=EnrollmentTypes.LECTIONS_ONLY, invitation__isnull=True)
.order_by()
.values('course') # group by
.annotate(total=Count("*"))
Expand All @@ -138,6 +153,11 @@ def update_course_learners_count(course_id: int) -> None:
learners_count=get_learners_count_subquery(outer_ref=OuterRef('id'))
)

def update_course_listeners_count(course_id: int) -> None:
Course.objects.filter(id=course_id).update(
listeners_count=get_listeners_count_subquery(outer_ref=OuterRef('id'))
)


def recreate_assignments_for_student(enrollment: Enrollment) -> None:
"""
Expand Down
5 changes: 3 additions & 2 deletions apps/learning/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class EnrollmentTypes(DjangoChoices):
REGULAR = C("Regular", _("EnrollmentTypes|Regular"))
LECTIONS_ONLY = C("lections", _("EnrollmentTypes|Lections"))

# TODO rename as it is used not only in invitation (ex. Course model)
class InvitationEnrollmentTypes(DjangoChoices):
REGULAR = C("Regular", _("InvitationEnrollmentTypes|Regular"))
LECTIONS_ONLY = C("lections", _("InvitationEnrollmentTypes|Lections"))
Expand Down Expand Up @@ -126,10 +127,10 @@ class GradeTypes(DjangoChoices):
FOUR.value, FIVE.value, SIX.value, SEVEN.value, EIGHT.value, NINE.value, TEN.value}
unsatisfactory_grades = {NOT_GRADED.value, UNSATISFACTORY.value, WITHOUT_GRADE.value,
ONE.value, TWO.value, THREE.value}
default_grades = {
default_grades = (
(NOT_GRADED.value, NOT_GRADED.label),
(WITHOUT_GRADE.value, WITHOUT_GRADE.label),
}
)

@classmethod
def is_suitable_for_grading_system(cls, choice, grading_system):
Expand Down
15 changes: 10 additions & 5 deletions apps/learning/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
CourseNewsNotification, Enrollment, StudentAssignment, StudentGroup
)
from learning.services import StudentGroupService
from learning.services.enrollment_service import update_course_learners_count
from learning.services.enrollment_service import update_course_learners_count, update_course_listeners_count
from learning.settings import EnrollmentTypes
# FIXME: post_delete нужен? Что лучше - удалять StudentGroup + SET_NULL у Enrollment или делать soft-delete?
# FIXME: группу лучше удалить, т.к. она будет предлагаться для новых заданий, хотя типа уже удалена.
from learning.tasks import convert_assignment_submission_ipynb_file_to_html
Expand Down Expand Up @@ -44,12 +45,16 @@ def delete_student_group_if_course_branch_deleted(sender, instance: CourseBranch


@receiver(post_save, sender=Enrollment)
def compute_course_learners_count(sender, instance: Enrollment, created,
def compute_course_student_counts(sender, instance: Enrollment, created,
*args, **kwargs):
if created and instance.is_deleted:
if created and (instance.is_deleted or instance.invitation is not None):
return
update_course_learners_count(instance.course_id)

if instance.type == EnrollmentTypes.REGULAR:
update_course_learners_count(instance.course_id)
elif instance.type == EnrollmentTypes.LECTIONS_ONLY:
update_course_listeners_count(instance.course_id)
else:
assert not "possible"

@receiver(post_save, sender=CourseNews)
def create_notifications_about_course_news(sender, instance: CourseNews,
Expand Down
Loading

0 comments on commit 767ba17

Please sign in to comment.