Skip to content
# Generated by Django 1.11.8 on 2017-12-29 03:26
from django.db import migrations, models
import course.models
class Migration(migrations.Migration):
dependencies = [
('course', '0109_add_manage_authentication_tokens_permssion'),
]
operations = [
migrations.AddField(
model_name='course',
name='force_lang',
field=models.CharField(blank=True, default='', help_text='Which language is forced to be used for this course.', max_length=200, null=True, validators=[course.models.validate_course_specific_language], verbose_name='Course language forcibly used'),
),
]
# Generated by Django 1.11.8 on 2018-01-02 06:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0110_add_force_lang_field_to_course'),
]
operations = [
migrations.AlterField(
model_name='course',
name='git_source',
field=models.CharField(help_text="A Git URL from which to pull course updates. If you're just starting out, enter <tt>git://github.com/inducer/relate-sample</tt> to get some sample content.", max_length=200, verbose_name='git source'),
),
]
from django.db import migrations
def add_use_git_endpoint_permission(apps, schema_editor):
from course.constants import participation_permission as pperm
ParticipationRolePermission = apps.get_model("course", "ParticipationRolePermission")
roles_pks = (
ParticipationRolePermission.objects.filter(
permission=pperm.edit_course)
.values_list("role", flat=True)
)
if roles_pks.count():
for pk in roles_pks:
ParticipationRolePermission.objects.get_or_create(
role_id=pk,
permission=pperm.use_git_endpoint
)
class Migration(migrations.Migration):
dependencies = [
('course', '0111_alter_git_source_in_course_to_a_required_field'),
]
operations = [
migrations.RunPython(add_use_git_endpoint_permission)
]
# Generated by Django 2.2.4 on 2019-09-19 17:17
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0111_alter_git_source_in_course_to_a_required_field'),
]
operations = [
migrations.AlterField(
model_name='participationtag',
name='name',
field=models.CharField(help_text='Format is lower-case-with-hyphens. Do not use spaces.', max_length=100, verbose_name='Name of participation tag'),
),
]
# Generated by Django 2.2.4 on 2019-09-19 19:08
from typing import Any, List
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('course', '0112_drop_name_uniqueness_on_participationtag'),
('course', '0112_add_use_git_endpoint_permission'),
]
operations = [] # type: List[Any]
# Generated by Django 3.0.8 on 2020-10-25 03:44
import django.core.validators
import jsonfield.fields
from django.db import migrations, models
import course.models
class Migration(migrations.Migration):
dependencies = [
('course', '0113_merge_20190919_1408'),
]
operations = [
migrations.AlterField(
model_name='participationrole',
name='identifier',
field=models.CharField(help_text="A symbolic name for this role, used in course code. Should be a valid identifier. The name 'unenrolled' is special and refers to anyone not enrolled in the course.", max_length=100, verbose_name='Role identifier'),
),
migrations.AlterField(
model_name='participationtag',
name='name',
field=models.CharField(help_text='Should be a valid identifier.', max_length=100, verbose_name='Name of participation tag'),
),
migrations.AlterField(
model_name='participationtag',
name='shown_to_participant',
field=models.BooleanField(default=False, verbose_name='Shown to participant'),
),
]
# Generated by Django 3.0.14 on 2021-04-16 00:24
import django.core.validators
import jsonfield.fields
from django.db import migrations, models
import course.models
class Migration(migrations.Migration):
dependencies = [
('course', '0114_alter_helptext_for_ptag_and_prole_fix_typo'),
]
operations = [
migrations.AlterModelOptions(
name='authenticationtoken',
options={'ordering': ('participation', 'creation_time'), 'verbose_name': 'Authentication token', 'verbose_name_plural': 'Authentication tokens'},
),
migrations.AlterField(
model_name='course',
name='identifier',
field=models.CharField(db_index=True, help_text="A course identifier. Alphanumeric with dashes, no spaces. This is visible in URLs and determines the location on your file system where the course's git repository lives. This should <em>not</em> be changed after the course has been created without also moving the course's git on the server.", max_length=200, unique=True, validators=[django.core.validators.RegexValidator('^(?P<course_identifier>[-a-zA-Z0-9]+)$', message="Identifier may only contain letters, numbers, and hyphens ('-').")], verbose_name='Course identifier'),
),
migrations.AlterField(
model_name='event',
name='kind',
field=models.CharField(help_text='Should be lower_case_with_underscores, no spaces allowed.', max_length=50, validators=[django.core.validators.RegexValidator('^(?P<event_kind>[_a-z0-9]+)$', message='Should be lower_case_with_underscores, no spaces allowed.')], verbose_name='Kind of event'),
),
migrations.AlterField(
model_name='flowaccessexception',
name='stipulations',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, help_text='A dictionary of the same things that can be added to a flow access rule, such as allowed_session_count or credit_percent. If not specified here, values will default to the stipulations in the course content.', null=True, validators=[course.models.validate_stipulations], verbose_name='Stipulations'),
),
migrations.AlterField(
model_name='flowpagebulkfeedback',
name='bulk_feedback',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, null=True, verbose_name='Bulk feedback'),
),
migrations.AlterField(
model_name='flowpagedata',
name='data',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, null=True, verbose_name='Data'),
),
migrations.AlterField(
model_name='flowpagevisit',
name='answer',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, null=True, verbose_name='Answer'),
),
migrations.AlterField(
model_name='flowpagevisitgrade',
name='feedback',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, null=True, verbose_name='Feedback'),
),
migrations.AlterField(
model_name='flowpagevisitgrade',
name='grade_data',
field=jsonfield.fields.JSONField(blank=True, dump_kwargs={'ensure_ascii': False}, null=True, verbose_name='Grade data'),
),
migrations.AlterField(
model_name='gradingopportunity',
name='identifier',
field=models.CharField(help_text='A symbolic name for this grade. lower_case_with_underscores, no spaces.', max_length=200, validators=[django.core.validators.RegexValidator('^(?P<grading_opp_id>[-_a-zA-Z0-9]+)$', message="Identifier may only contain letters, numbers, and hyphens ('-').")], verbose_name='Grading opportunity ID'),
),
migrations.AlterField(
model_name='participationpermission',
name='permission',
field=models.CharField(choices=[('edit_course', 'Edit course'), ('use_admin_interface', 'Use admin interface'), ('manage_authentication_tokens', 'Manage authentication tokens'), ('impersonate_role', 'Impersonate role'), ('set_fake_time', 'Set fake time'), ('set_pretend_facility', 'Pretend to be in facility'), ('edit_course_permissions', 'Edit course permissions'), ('view_hidden_course_page', 'View hidden course page'), ('view_calendar', 'View calendar'), ('send_instant_message', 'Send instant message'), ('access_files_for', 'Access files for'), ('included_in_grade_statistics', 'Included in grade statistics'), ('skip_during_manual_grading', 'Skip during manual grading'), ('edit_exam', 'Edit exam'), ('issue_exam_ticket', 'Issue exam ticket'), ('batch_issue_exam_ticket', 'Batch issue exam ticket'), ('view_participant_masked_profile', "View participants' masked profile only"), ('view_flow_sessions_from_role', 'View flow sessions from role'), ('view_gradebook', 'View gradebook'), ('edit_grading_opportunity', 'Edit grading opportunity'), ('assign_grade', 'Assign grade'), ('view_grader_stats', 'View grader stats'), ('batch_import_grade', 'Batch-import grades'), ('batch_export_grade', 'Batch-export grades'), ('batch_download_submission', 'Batch-download submissions'), ('impose_flow_session_deadline', 'Impose flow session deadline'), ('batch_impose_flow_session_deadline', 'Batch-impose flow session deadline'), ('end_flow_session', 'End flow session'), ('batch_end_flow_session', 'Batch-end flow sessions'), ('regrade_flow_session', 'Regrade flow session'), ('batch_regrade_flow_session', 'Batch-regrade flow sessions'), ('recalculate_flow_session_grade', 'Recalculate flow session grade'), ('batch_recalculate_flow_session_grade', 'Batch-recalculate flow sesssion grades'), ('reopen_flow_session', 'Reopen flow session'), ('grant_exception', 'Grant exception'), ('view_analytics', 'View analytics'), ('preview_content', 'Preview content'), ('update_content', 'Update content'), ('use_git_endpoint', 'Use direct git endpoint'), ('use_markup_sandbox', 'Use markup sandbox'), ('use_page_sandbox', 'Use page sandbox'), ('test_flow', 'Test flow'), ('edit_events', 'Edit events'), ('query_participation', 'Query participation'), ('edit_participation', 'Edit participation'), ('preapprove_participation', 'Preapprove participation'), ('manage_instant_flow_requests', 'Manage instant flow requests')], db_index=True, max_length=200, verbose_name='Permission'),
),
migrations.AlterField(
model_name='participationrole',
name='identifier',
field=models.CharField(help_text="A symbolic name for this role, used in course code. Should be a valid identifier (as defined by Python). The name 'unenrolled' is special and refers to anyone not enrolled in the course.", max_length=100, verbose_name='Role identifier'),
),
migrations.AlterField(
model_name='participationrolepermission',
name='permission',
field=models.CharField(choices=[('edit_course', 'Edit course'), ('use_admin_interface', 'Use admin interface'), ('manage_authentication_tokens', 'Manage authentication tokens'), ('impersonate_role', 'Impersonate role'), ('set_fake_time', 'Set fake time'), ('set_pretend_facility', 'Pretend to be in facility'), ('edit_course_permissions', 'Edit course permissions'), ('view_hidden_course_page', 'View hidden course page'), ('view_calendar', 'View calendar'), ('send_instant_message', 'Send instant message'), ('access_files_for', 'Access files for'), ('included_in_grade_statistics', 'Included in grade statistics'), ('skip_during_manual_grading', 'Skip during manual grading'), ('edit_exam', 'Edit exam'), ('issue_exam_ticket', 'Issue exam ticket'), ('batch_issue_exam_ticket', 'Batch issue exam ticket'), ('view_participant_masked_profile', "View participants' masked profile only"), ('view_flow_sessions_from_role', 'View flow sessions from role'), ('view_gradebook', 'View gradebook'), ('edit_grading_opportunity', 'Edit grading opportunity'), ('assign_grade', 'Assign grade'), ('view_grader_stats', 'View grader stats'), ('batch_import_grade', 'Batch-import grades'), ('batch_export_grade', 'Batch-export grades'), ('batch_download_submission', 'Batch-download submissions'), ('impose_flow_session_deadline', 'Impose flow session deadline'), ('batch_impose_flow_session_deadline', 'Batch-impose flow session deadline'), ('end_flow_session', 'End flow session'), ('batch_end_flow_session', 'Batch-end flow sessions'), ('regrade_flow_session', 'Regrade flow session'), ('batch_regrade_flow_session', 'Batch-regrade flow sessions'), ('recalculate_flow_session_grade', 'Recalculate flow session grade'), ('batch_recalculate_flow_session_grade', 'Batch-recalculate flow sesssion grades'), ('reopen_flow_session', 'Reopen flow session'), ('grant_exception', 'Grant exception'), ('view_analytics', 'View analytics'), ('preview_content', 'Preview content'), ('update_content', 'Update content'), ('use_git_endpoint', 'Use direct git endpoint'), ('use_markup_sandbox', 'Use markup sandbox'), ('use_page_sandbox', 'Use page sandbox'), ('test_flow', 'Test flow'), ('edit_events', 'Edit events'), ('query_participation', 'Query participation'), ('edit_participation', 'Edit participation'), ('preapprove_participation', 'Preapprove participation'), ('manage_instant_flow_requests', 'Manage instant flow requests')], db_index=True, max_length=200, verbose_name='Permission'),
),
migrations.AlterField(
model_name='participationtag',
name='name',
field=models.CharField(help_text='Should be a valid identifier (as defined by Python).', max_length=100, verbose_name='Name of participation tag'),
),
]
# Generated by Django 3.0.14 on 2021-04-16 00:27
from django.db import migrations, models
def trust_existing_courses_for_markup(apps, schema_editor):
Course = apps.get_model("course", "Course")
for course in Course.objects.all():
# Existing courses are grandfathered in.
course.trusted_for_markup = True
course.save()
class Migration(migrations.Migration):
dependencies = [
("course", "0115_catch_up"),
]
operations = [
migrations.AddField(
model_name="course",
name="trusted_for_markup",
field=models.BooleanField(
default=False,
verbose_name="May present arbitrary HTML to course participants",
),
),
migrations.RunPython(trust_existing_courses_for_markup),
]
# Generated by Django 3.2.2 on 2021-05-07 22:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0116_add_course_trust_flag'),
]
operations = [
migrations.AlterField(
model_name='authenticationtoken',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='course',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='event',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='exam',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='examticket',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowaccessexception',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowaccessexceptionentry',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowpagebulkfeedback',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowpagedata',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowpagevisit',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowpagevisit',
name='is_submitted_answer',
field=models.BooleanField(null=True, verbose_name='Is submitted answer'),
),
migrations.AlterField(
model_name='flowpagevisitgrade',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowruleexception',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='flowsession',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='gradechange',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='gradingopportunity',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='instantflowrequest',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='instantmessage',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participation',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participationpermission',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participationpreapproval',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participationrole',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participationrolepermission',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='participationtag',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]
# Generated by Django 3.2.8 on 2022-01-11 21:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0117_django32_bigauto_nullboolean'),
]
operations = [
migrations.AlterField(
model_name='course',
name='git_source',
field=models.CharField(help_text="A Git URL from which to pull course updates. If you're just starting out, enter <tt>https://github.com/inducer/relate-sample.git</tt> to get some sample content.", max_length=200, verbose_name='git source'),
),
]
# Generated by Django 4.2.4 on 2023-08-30 20:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0118_git_to_http_in_git_src_descr'),
]
operations = [
migrations.AddField(
model_name='examticket',
name='require_login',
field=models.BooleanField(default=False, help_text='If set, the exam ticket can only be used once logged in'),
),
]
# Generated by Django 4.2.4 on 2023-09-20 20:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0119_examticket_require_login'),
]
operations = [
migrations.AlterField(
model_name='examticket',
name='code',
field=models.CharField(max_length=50),
),
]
# Generated by Django 5.1 on 2024-09-11 20:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("course", "0120_examticket_drop_code_uniqueness"),
]
operations = [
migrations.AlterField(
model_name="flowaccessexceptionentry",
name="permission",
field=models.CharField(
choices=[
("view", "View the flow"),
("submit_answer", "Submit answers"),
("end_session", "End session"),
("change_answer", "Change already-graded answer"),
("see_correctness", "See whether an answer is correct"),
(
"see_answer_before_submission",
"See the correct answer before answering",
),
(
"see_answer_after_submission",
"See the correct answer after answering",
),
("cannot_see_flow_result", "Cannot see flow result"),
(
"set_roll_over_expiration_mode",
"Set the session to 'roll over' expiration mode",
),
("see_session_time", "See session time"),
("lock_down_as_exam_session", "Lock down as exam session"),
(
"send_email_about_flow_page",
"Send emails about the flow page to course staff",
),
("hide_point_count", "Hide point count"),
],
max_length=50,
verbose_name="Permission",
),
),
migrations.AlterField(
model_name="participationpermission",
name="permission",
field=models.CharField(
choices=[
("edit_course", "Edit course"),
("use_admin_interface", "Use admin interface"),
("manage_authentication_tokens", "Manage authentication tokens"),
("impersonate_role", "Impersonate role"),
("set_fake_time", "Set fake time"),
("set_pretend_facility", "Pretend to be in facility"),
("edit_course_permissions", "Edit course permissions"),
("view_hidden_course_page", "View hidden course page"),
("view_calendar", "View calendar"),
("send_instant_message", "Send instant message"),
("access_files_for", "Access files for"),
("included_in_grade_statistics", "Included in grade statistics"),
("skip_during_manual_grading", "Skip during manual grading"),
("edit_exam", "Edit exam"),
("issue_exam_ticket", "Issue exam ticket"),
("batch_issue_exam_ticket", "Batch issue exam ticket"),
(
"view_participant_masked_profile",
"View participants' masked profile only",
),
("view_flow_sessions_from_role", "View flow sessions from role"),
("view_gradebook", "View gradebook"),
("edit_grading_opportunity", "Edit grading opportunity"),
("assign_grade", "Assign grade"),
("view_grader_stats", "View grader stats"),
("batch_import_grade", "Batch-import grades"),
("batch_export_grade", "Batch-export grades"),
("batch_download_submission", "Batch-download submissions"),
("impose_flow_session_deadline", "Impose flow session deadline"),
(
"batch_impose_flow_session_deadline",
"Batch-impose flow session deadline",
),
("end_flow_session", "End flow session"),
("batch_end_flow_session", "Batch-end flow sessions"),
("regrade_flow_session", "Regrade flow session"),
("batch_regrade_flow_session", "Batch-regrade flow sessions"),
(
"recalculate_flow_session_grade",
"Recalculate flow session grade",
),
(
"batch_recalculate_flow_session_grade",
"Batch-recalculate flow session grades",
),
("reopen_flow_session", "Reopen flow session"),
("grant_exception", "Grant exception"),
("view_analytics", "View analytics"),
("preview_content", "Preview content"),
("update_content", "Update content"),
("use_git_endpoint", "Use direct git endpoint"),
("use_markup_sandbox", "Use markup sandbox"),
("use_page_sandbox", "Use page sandbox"),
("test_flow", "Test flow"),
("edit_events", "Edit events"),
("query_participation", "Query participation"),
("edit_participation", "Edit participation"),
("preapprove_participation", "Preapprove participation"),
("manage_instant_flow_requests", "Manage instant flow requests"),
],
db_index=True,
max_length=200,
verbose_name="Permission",
),
),
migrations.AlterField(
model_name="participationpreapproval",
name="roles",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="course.participationrole",
verbose_name="Roles",
),
),
migrations.AlterField(
model_name="participationrolepermission",
name="permission",
field=models.CharField(
choices=[
("edit_course", "Edit course"),
("use_admin_interface", "Use admin interface"),
("manage_authentication_tokens", "Manage authentication tokens"),
("impersonate_role", "Impersonate role"),
("set_fake_time", "Set fake time"),
("set_pretend_facility", "Pretend to be in facility"),
("edit_course_permissions", "Edit course permissions"),
("view_hidden_course_page", "View hidden course page"),
("view_calendar", "View calendar"),
("send_instant_message", "Send instant message"),
("access_files_for", "Access files for"),
("included_in_grade_statistics", "Included in grade statistics"),
("skip_during_manual_grading", "Skip during manual grading"),
("edit_exam", "Edit exam"),
("issue_exam_ticket", "Issue exam ticket"),
("batch_issue_exam_ticket", "Batch issue exam ticket"),
(
"view_participant_masked_profile",
"View participants' masked profile only",
),
("view_flow_sessions_from_role", "View flow sessions from role"),
("view_gradebook", "View gradebook"),
("edit_grading_opportunity", "Edit grading opportunity"),
("assign_grade", "Assign grade"),
("view_grader_stats", "View grader stats"),
("batch_import_grade", "Batch-import grades"),
("batch_export_grade", "Batch-export grades"),
("batch_download_submission", "Batch-download submissions"),
("impose_flow_session_deadline", "Impose flow session deadline"),
(
"batch_impose_flow_session_deadline",
"Batch-impose flow session deadline",
),
("end_flow_session", "End flow session"),
("batch_end_flow_session", "Batch-end flow sessions"),
("regrade_flow_session", "Regrade flow session"),
("batch_regrade_flow_session", "Batch-regrade flow sessions"),
(
"recalculate_flow_session_grade",
"Recalculate flow session grade",
),
(
"batch_recalculate_flow_session_grade",
"Batch-recalculate flow session grades",
),
("reopen_flow_session", "Reopen flow session"),
("grant_exception", "Grant exception"),
("view_analytics", "View analytics"),
("preview_content", "Preview content"),
("update_content", "Update content"),
("use_git_endpoint", "Use direct git endpoint"),
("use_markup_sandbox", "Use markup sandbox"),
("use_page_sandbox", "Use page sandbox"),
("test_flow", "Test flow"),
("edit_events", "Edit events"),
("query_participation", "Query participation"),
("edit_participation", "Edit participation"),
("preapprove_participation", "Preapprove participation"),
("manage_instant_flow_requests", "Manage instant flow requests"),
],
db_index=True,
max_length=200,
verbose_name="Permission",
),
),
]
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, unicode_literals
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,43 +23,57 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from typing import cast, Any, Optional, Text, Iterable # noqa
from collections.abc import Iterable
from decimal import Decimal
from typing import (
TYPE_CHECKING,
Any,
cast,
)
import six
from django.db import models
from django.utils.timezone import now
from django.urls import reverse
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.utils.translation import (
ugettext_lazy as _, pgettext_lazy, string_concat)
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from course.constants import ( # noqa
user_status, USER_STATUS_CHOICES,
participation_status, PARTICIPATION_STATUS_CHOICES,
flow_permission, FLOW_PERMISSION_CHOICES,
flow_session_expiration_mode, FLOW_SESSION_EXPIRATION_MODE_CHOICES,
grade_aggregation_strategy, GRADE_AGGREGATION_STRATEGY_CHOICES,
grade_state_change_types, GRADE_STATE_CHANGE_CHOICES,
flow_rule_kind, FLOW_RULE_KIND_CHOICES,
exam_ticket_states, EXAM_TICKET_STATE_CHOICES,
participation_permission, PARTICIPATION_PERMISSION_CHOICES,
COURSE_ID_REGEX, GRADING_OPP_ID_REGEX
)
from course.page.base import AnswerFeedback
COURSE_ID_REGEX,
EVENT_KIND_REGEX,
EXAM_TICKET_STATE_CHOICES,
FLOW_PERMISSION_CHOICES,
FLOW_RULE_KIND_CHOICES,
FLOW_SESSION_EXPIRATION_MODE_CHOICES,
GRADE_AGGREGATION_STRATEGY_CHOICES,
GRADE_STATE_CHANGE_CHOICES,
GRADING_OPP_ID_REGEX,
PARTICIPATION_PERMISSION_CHOICES,
PARTICIPATION_STATUS_CHOICES,
USER_STATUS_CHOICES,
exam_ticket_states,
flow_permission,
flow_rule_kind,
flow_session_expiration_mode,
grade_aggregation_strategy,
grade_state_change_types,
participation_permission,
participation_status,
user_status,
)
from relate.utils import not_none, string_concat
# {{{ mypy
if False:
from course.content import FlowDesc # noqa
if TYPE_CHECKING:
import datetime
from course.content import FlowDesc
from course.page.base import AnswerFeedback
# }}}
......@@ -71,6 +84,18 @@ from yamlfield.fields import YAMLField
# {{{ course
def validate_course_specific_language(value: str) -> None:
if not value.strip():
# the default value is ""
return
if value not in (
[lang_code for lang_code, lang_descr in settings.LANGUAGES]
+ [settings.LANGUAGE_CODE]):
raise ValidationError(
_("'%s' is currently not supported as a course specific "
"language at this site.") % value)
class Course(models.Model):
identifier = models.CharField(max_length=200, unique=True,
help_text=_("A course identifier. Alphanumeric with dashes, "
......@@ -78,116 +103,123 @@ class Course(models.Model):
"on your file system where the course's git repository lives. "
"This should <em>not</em> be changed after the course has been created "
"without also moving the course's git on the server."),
verbose_name=_('Course identifier'),
verbose_name=_("Course identifier"),
db_index=True,
validators=[
RegexValidator(
"^"+COURSE_ID_REGEX+"$",
message=_(
"Identifier may only contain letters, "
"numbers, and hypens ('-').")),
"numbers, and hyphens ('-').")),
]
)
name = models.CharField(
null=True, blank=False,
max_length=200,
verbose_name=_('Course name'),
verbose_name=_("Course name"),
help_text=_("A human-readable name for the course. "
"(e.g. 'Numerical Methods')"))
number = models.CharField(
null=True, blank=False,
max_length=200,
verbose_name=_('Course number'),
verbose_name=_("Course number"),
help_text=_("A human-readable course number/ID "
"for the course (e.g. 'CS123')"))
time_period = models.CharField(
null=True, blank=False,
max_length=200,
verbose_name=_('Time Period'),
verbose_name=_("Time Period"),
help_text=_("A human-readable description of the "
"time period for the course (e.g. 'Fall 2014')"))
start_date = models.DateField(
verbose_name=_('Start date'),
verbose_name=_("Start date"),
null=True, blank=True)
end_date = models.DateField(
verbose_name=_('End date'),
verbose_name=_("End date"),
null=True, blank=True)
hidden = models.BooleanField(
default=True,
help_text=_("Is the course only accessible to course staff?"),
verbose_name=_('Only visible to course staff'))
verbose_name=_("Only visible to course staff"))
listed = models.BooleanField(
default=True,
help_text=_("Should the course be listed on the main page?"),
verbose_name=_('Listed on main page'))
verbose_name=_("Listed on main page"))
accepts_enrollment = models.BooleanField(
default=True,
verbose_name=_('Accepts enrollment'))
verbose_name=_("Accepts enrollment"))
git_source = models.CharField(max_length=200, blank=True,
git_source = models.CharField(max_length=200, blank=False,
help_text=_("A Git URL from which to pull course updates. "
"If you're just starting out, enter "
"<tt>git://github.com/inducer/relate-sample</tt> "
"<tt>https://github.com/inducer/relate-sample.git</tt> "
"to get some sample content."),
verbose_name=_('git source'))
verbose_name=_("git source"))
ssh_private_key = models.TextField(blank=True,
help_text=_("An SSH private key to use for Git authentication. "
"Not needed for the sample URL above."
"You may use <a href='/generate-ssh-key'>this tool</a> to generate "
"a key pair."),
verbose_name=_('SSH private key'))
verbose_name=_("SSH private key"))
course_root_path = models.CharField(max_length=200, blank=True,
help_text=_(
'Subdirectory <em>within</em> the git repository to use as '
'course root directory. Not required, and usually blank. '
'Use only if your course content lives in a subdirectory '
'of your git repository. '
'Should not include trailing slash.'),
verbose_name=_('Course root in repository'))
"Subdirectory <em>within</em> the git repository to use as "
"course root directory. Not required, and usually blank. "
"Use only if your course content lives in a subdirectory "
"of your git repository. "
"Should not include trailing slash."),
verbose_name=_("Course root in repository"))
course_file = models.CharField(max_length=200,
default="course.yml",
help_text=_("Name of a YAML file in the git repository that "
"contains the root course descriptor."),
verbose_name=_('Course file'))
verbose_name=_("Course file"))
events_file = models.CharField(max_length=200,
default="events.yml",
help_text=_("Name of a YAML file in the git repository that "
"contains calendar information."),
verbose_name=_('Events file'))
verbose_name=_("Events file"))
enrollment_approval_required = models.BooleanField(
default=False,
help_text=_("If set, each enrolling student must be "
"individually approved."),
verbose_name=_('Enrollment approval required'))
verbose_name=_("Enrollment approval required"))
preapproval_require_verified_inst_id = models.BooleanField(
default=True,
help_text=_("If set, students cannot get participation "
"preapproval using institutional ID if "
"the institutional ID they provided is not "
"verified."),
verbose_name=_('Prevent preapproval by institutional ID if not '
'verified?'))
verbose_name=_("Prevent preapproval by institutional ID if not "
"verified?"))
enrollment_required_email_suffix = models.CharField(
max_length=200, blank=True, null=True,
help_text=_("Enrollee's email addresses must end in the "
"specified suffix, such as '@illinois.edu'."),
verbose_name=_('Enrollment required email suffix'))
verbose_name=_("Enrollment required email suffix"))
from_email = models.EmailField(
# Translators: replace "RELATE" with the brand name of your
# website if necessary.
help_text=_("This email address will be used in the 'From' line "
"of automated emails sent by RELATE."),
verbose_name=_('From email'))
verbose_name=_("From email"))
notify_email = models.EmailField(
help_text=_("This email address will receive "
"notifications about the course."),
verbose_name=_('Notify email'))
verbose_name=_("Notify email"))
force_lang = models.CharField(max_length=200, blank=True, null=True,
default="",
validators=[validate_course_specific_language],
help_text=_(
"Which language is forced to be used for this course."),
verbose_name=_("Course language forcibly used"))
# {{{ XMPP
......@@ -195,46 +227,53 @@ class Course(models.Model):
help_text=_("(Required only if the instant message feature is "
"desired.) The Jabber/XMPP ID (JID) the course will use to sign "
"in to an XMPP server."),
verbose_name=_('Course xmpp ID'))
verbose_name=_("Course xmpp ID"))
course_xmpp_password = models.CharField(max_length=200, blank=True, null=True,
help_text=_("(Required only if the instant message feature is "
"desired.) The password to go with the JID above."),
verbose_name=_('Course xmpp password'))
verbose_name=_("Course xmpp password"))
recipient_xmpp_id = models.CharField(max_length=200, blank=True, null=True,
help_text=_("(Required only if the instant message feature is "
"desired.) The JID to which instant messages will be sent."),
verbose_name=_('Recipient xmpp ID'))
verbose_name=_("Recipient xmpp ID"))
# }}}
active_git_commit_sha = models.CharField(max_length=200, null=False,
blank=False,
verbose_name=_('Active git commit SHA'))
verbose_name=_("Active git commit SHA"))
participants = models.ManyToManyField(settings.AUTH_USER_MODEL,
through='Participation')
through="course.Participation")
trusted_for_markup = models.BooleanField(
default=False,
verbose_name=_("May present arbitrary HTML to course participants"))
class Meta:
verbose_name = _("Course")
verbose_name_plural = _("Courses")
def __unicode__(self):
def __str__(self) -> str:
return self.identifier
if six.PY3:
__str__ = __unicode__
def clean(self) -> None:
if self.force_lang:
self.force_lang = self.force_lang.strip()
def get_absolute_url(self):
def get_absolute_url(self) -> str:
return reverse("relate-course_page", args=(self.identifier,))
def get_from_email(self):
def get_from_email(self) -> str:
if settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
return self.from_email
else:
return settings.DEFAULT_FROM_EMAIL
return getattr(
settings, "NOTIFICATION_EMAIL_FROM",
settings.ROBOT_EMAIL_FROM)
def get_reply_to_email(self):
def get_reply_to_email(self) -> str:
# this functionality need more fields in Course model,
# about the preference of the course.
if settings.RELATE_EMAIL_SMTP_ALLOW_NONAUTHORIZED_SENDER:
......@@ -242,6 +281,10 @@ class Course(models.Model):
else:
return self.notify_email
def save(self, *args, **kwargs):
self.full_clean() # performs regular validation then clean()
super().save(*args, **kwargs)
# }}}
......@@ -253,28 +296,35 @@ class Event(models.Model):
"""
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
kind = models.CharField(max_length=50,
# Translators: format of event kind in Event model
help_text=_("Should be lower_case_with_underscores, no spaces "
"allowed."),
verbose_name=_('Kind of event'))
verbose_name=_("Kind of event"),
validators=[
RegexValidator(
"^" + EVENT_KIND_REGEX + "$",
message=_("Should be lower_case_with_underscores, no spaces "
"allowed.")),
]
)
ordinal = models.IntegerField(blank=True, null=True,
# Translators: ordinal of event of the same kind
verbose_name=_('Ordinal of event'))
verbose_name=_("Ordinal of event"))
time = models.DateTimeField(verbose_name=_('Start time'))
time = models.DateTimeField(verbose_name=_("Start time"))
end_time = models.DateTimeField(null=True, blank=True,
verbose_name=_('End time'))
verbose_name=_("End time"))
all_day = models.BooleanField(default=False,
# Translators: for when the due time is "All day", how the webpage
# of a event is displayed.
help_text=_("Only affects the rendering in the class calendar, "
"in that a start time is not shown"),
verbose_name=_('All day'))
verbose_name=_("All day"))
shown_in_calendar = models.BooleanField(default=True,
verbose_name=_('Shown in calendar'))
verbose_name=_("Shown in calendar"))
class Meta:
verbose_name = _("Event")
......@@ -282,14 +332,46 @@ class Event(models.Model):
ordering = ("course", "time")
unique_together = (("course", "kind", "ordinal"))
def __unicode__(self):
def __str__(self) -> str:
if self.ordinal is not None:
return "%s %s" % (self.kind, self.ordinal)
return f"{self.kind} {self.ordinal}"
else:
return self.kind
if six.PY3:
__str__ = __unicode__
def clean(self):
super().clean()
try:
self.course # noqa: B018
except ObjectDoesNotExist:
raise ValidationError(
{"course":
_("Course must be given.")})
if self.end_time:
if self.end_time < self.time:
raise ValidationError(
{"end_time":
_("End time must not be ahead of start time.")})
if self.ordinal is None:
null_ordinal_qset = Event.objects.filter(
course=self.course,
kind=self.kind,
ordinal__isnull=True)
if self.pk:
null_ordinal_qset = null_ordinal_qset.exclude(id=self.pk)
if null_ordinal_qset.exists():
raise ValidationError(
_("May not create multiple ordinal-less events "
"of kind '{evt_kind}' in course '{course}'")
.format(evt_kind=self.kind, course=self.course))
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)
# }}}
......@@ -298,31 +380,26 @@ class Event(models.Model):
class ParticipationTag(models.Model):
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
name = models.CharField(max_length=100, unique=True,
verbose_name=_("Course"), on_delete=models.CASCADE)
name = models.CharField(max_length=100,
blank=False, null=False,
# Translators: name format of ParticipationTag
help_text=_("Format is lower-case-with-hyphens. "
"Do not use spaces."),
verbose_name=_('Name of participation tag'))
help_text=_("Should be a valid identifier (as defined by Python)."),
verbose_name=_("Name of participation tag"))
shown_to_participant = models.BooleanField(default=False,
verbose_name=_('Shown to pariticpant'))
verbose_name=_("Shown to participant"))
def clean(self):
super(ParticipationTag, self).clean()
import re
name_valid_re = re.compile(r"^\w+$")
super().clean()
if name_valid_re.match(self.name) is None:
# Translators: "Name" is the name of a ParticipationTag
if not self.name.isidentifier():
field_name = "name"
raise ValidationError(
{"name": _("Name contains invalid characters.")})
{field_name:
_("'%s' contains invalid characters.") % field_name})
def __unicode__(self):
return "%s (%s)" % (self.name, self.course)
if six.PY3:
__str__ = __unicode__
def __str__(self) -> str:
return f"{self.name} ({self.course})"
class Meta:
verbose_name = _("Participation tag")
......@@ -333,41 +410,61 @@ class ParticipationTag(models.Model):
class ParticipationRole(models.Model):
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
identifier = models.CharField(
max_length=100, blank=False, null=False,
help_text=_("A symbolic name for this role, used in course code. "
"lower_case_with_underscores, no spaces. May be any string. The "
"Should be a valid identifier (as defined by Python). The "
"name 'unenrolled' is special and refers to anyone not enrolled "
"in the course."),
verbose_name=_('Role identifier'))
verbose_name=_("Role identifier"))
name = models.CharField(max_length=200, blank=False, null=False,
help_text=_("A human-readable description of this role."),
verbose_name=_('Role name'))
verbose_name=_("Role name"))
is_default_for_new_participants = models.BooleanField(default=False,
verbose_name=_('Is default role for new participants'))
verbose_name=_("Is default role for new participants"))
is_default_for_unenrolled = models.BooleanField(default=False,
verbose_name=_('Is default role for unenrolled users'))
verbose_name=_("Is default role for unenrolled users"))
def clean(self):
super(ParticipationRole, self).clean()
import re
name_valid_re = re.compile(r"^\w+$")
super().clean()
if name_valid_re.match(self.identifier) is None:
# Translators: "Name" is the name of a ParticipationTag
if not self.identifier.isidentifier():
field_name = "identifier"
raise ValidationError(
{"name": _("Name contains invalid characters.")})
{field_name:
_("'%s' contains invalid characters.") % field_name})
def __unicode__(self):
def __str__(self) -> str:
return _("%(identifier)s in %(course)s") % {
"identifier": self.identifier,
"course": self.course}
if six.PY3:
__str__ = __unicode__
# {{{ permissions handling
_permissions_cache: frozenset[tuple[str, str | None]] | None = None
def permission_tuples(self) -> frozenset[tuple[str, str | None]]:
if self._permissions_cache is not None:
return self._permissions_cache
perm = list(
ParticipationRolePermission.objects.filter(role=self)
.values_list("permission", "argument"))
fset_perm = frozenset(
(permission, argument) if argument else (permission, None)
for permission, argument in perm)
self._permissions_cache = fset_perm
return fset_perm
def has_permission(self, perm: str, argument: str | None = None) -> bool:
return (perm, argument) in self.permission_tuples()
# }}}
class Meta:
verbose_name = _("Participation role")
......@@ -379,29 +476,32 @@ class ParticipationRole(models.Model):
class ParticipationPermissionBase(models.Model):
permission = models.CharField(max_length=200, blank=False, null=False,
choices=PARTICIPATION_PERMISSION_CHOICES,
verbose_name=_('Permission'),
verbose_name=_("Permission"),
db_index=True)
argument = models.CharField(max_length=200, blank=True, null=True,
verbose_name=_('Argument'))
verbose_name=_("Argument"))
class Meta:
abstract = True
def __unicode__(self):
def __str__(self) -> str:
if self.argument:
return "%s %s" % (self.permission, self.argument)
return f"{self.permission} {self.argument}"
else:
return self.permission
if six.PY3:
__str__ = __unicode__
class ParticipationRolePermission(ParticipationPermissionBase):
role = models.ForeignKey(ParticipationRole,
verbose_name=_('Role'), on_delete=models.CASCADE,
verbose_name=_("Role"), on_delete=models.CASCADE,
related_name="permissions")
def __str__(self) -> str:
# Translators: permissions for roles
return _("%(permission)s for %(role)s") % {
"permission": super().__str__(),
"role": self.role}
class Meta:
verbose_name = _("Participation role permission")
verbose_name_plural = _("Participation role permissions")
......@@ -410,13 +510,13 @@ class ParticipationRolePermission(ParticipationPermissionBase):
class Participation(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL,
verbose_name=_('User ID'), on_delete=models.CASCADE,
verbose_name=_("User ID"), on_delete=models.CASCADE,
related_name="participations")
course = models.ForeignKey(Course, related_name="participations",
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
enroll_time = models.DateTimeField(default=now,
verbose_name=_('Enroll time'))
verbose_name=_("Enroll time"))
role = models.CharField(max_length=50,
verbose_name=_("Role (unused)"),)
roles = models.ManyToManyField(ParticipationRole, blank=True,
......@@ -424,25 +524,25 @@ class Participation(models.Model):
status = models.CharField(max_length=50,
choices=PARTICIPATION_STATUS_CHOICES,
verbose_name=_('Participation status'))
verbose_name=_("Participation status"))
time_factor = models.DecimalField(
max_digits=10, decimal_places=2,
default=1,
help_text=_("Multiplier for time available on time-limited "
"flows"),
verbose_name=_('Time factor'))
verbose_name=_("Time factor"))
preview_git_commit_sha = models.CharField(max_length=200, null=True,
blank=True,
verbose_name=_('Preview git commit SHA'))
verbose_name=_("Preview git commit SHA"))
tags = models.ManyToManyField(ParticipationTag, blank=True,
verbose_name=_('Tags'))
verbose_name=_("Tags"))
notes = models.TextField(blank=True, null=True,
verbose_name=_('Notes'))
verbose_name=_("Notes"))
def __unicode__(self):
def __str__(self) -> str:
# Translators: displayed format of Participation: some user in some
# course as some role
return _("%(user)s in %(course)s as %(role)s") % {
......@@ -452,9 +552,6 @@ class Participation(models.Model):
for role in self.roles.all())
}
if six.PY3:
__str__ = __unicode__
class Meta:
verbose_name = _("Participation")
verbose_name_plural = _("Participations")
......@@ -466,11 +563,12 @@ class Participation(models.Model):
# {{{ permissions handling
def permissions(self):
try:
_permissions_cache: frozenset[tuple[str, str | None]] | None = None
def permissions(self) -> frozenset[tuple[str, str | None]]:
if self._permissions_cache is not None:
return self._permissions_cache
except AttributeError:
pass
perm = (
list(
......@@ -478,20 +576,19 @@ class Participation(models.Model):
role__course=self.course,
role__participation=self)
.values_list("permission", "argument"))
+
list(
+ list(
ParticipationPermission.objects.filter(
participation=self)
.values_list("permission", "argument")))
perm = frozenset(
fset_perm = frozenset(
(permission, argument) if argument else (permission, None)
for permission, argument in perm)
self._permissions_cache = perm
return perm
self._permissions_cache = fset_perm
return fset_perm
def has_permission(self, perm, argument=None):
def has_permission(self, perm: str, argument: str | None = None) -> bool:
return (perm, argument) in self.permissions()
# }}}
......@@ -499,7 +596,7 @@ class Participation(models.Model):
class ParticipationPermission(ParticipationPermissionBase):
participation = models.ForeignKey(Participation,
verbose_name=_('Participation'), on_delete=models.CASCADE,
verbose_name=_("Participation"), on_delete=models.CASCADE,
related_name="individual_permissions")
class Meta:
......@@ -510,22 +607,22 @@ class ParticipationPermission(ParticipationPermissionBase):
class ParticipationPreapproval(models.Model):
email = models.EmailField(max_length=254, null=True, blank=True,
verbose_name=_('Email'))
verbose_name=_("Email"))
institutional_id = models.CharField(max_length=254, null=True, blank=True,
verbose_name=_('Institutional ID'))
verbose_name=_("Institutional ID"))
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
role = models.CharField(max_length=50,
verbose_name=_("Role (unused)"),)
roles = models.ManyToManyField(ParticipationRole, blank=True,
verbose_name=_("Roles"), related_name="+")
creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
verbose_name=_('Creator'), on_delete=models.SET_NULL)
verbose_name=_("Creator"), on_delete=models.SET_NULL)
creation_time = models.DateTimeField(default=now, db_index=True,
verbose_name=_('Creation time'))
verbose_name=_("Creation time"))
def __unicode__(self):
def __str__(self) -> str:
if self.email:
# Translators: somebody's email in some course in Participation
# Preapproval
......@@ -536,9 +633,9 @@ class ParticipationPreapproval(models.Model):
# Participation Preapproval
return _("Institutional ID %(inst_id)s in %(course)s") % {
"inst_id": self.institutional_id, "course": self.course}
if six.PY3:
__str__ = __unicode__
else:
return _("Preapproval with pk %(pk)s in %(course)s") % {
"pk": self.pk, "course": self.course}
class Meta:
verbose_name = _("Participation preapproval")
......@@ -585,7 +682,9 @@ def add_default_roles_and_permissions(course,
argument="student").save()
rpm(role=role, permission=pp.view_gradebook).save()
rpm(role=role, permission=pp.assign_grade).save()
rpm(role=role, permission=pp.skip_during_manual_grading).save()
rpm(role=role, permission=pp.view_grader_stats).save()
rpm(role=role, permission=pp.batch_download_submission).save()
rpm(role=role, permission=pp.impose_flow_session_deadline).save()
rpm(role=role, permission=pp.end_flow_session).save()
......@@ -606,11 +705,12 @@ def add_default_roles_and_permissions(course,
add_student_permissions(role)
def add_instructor_permissions(role):
rpm(role=role, permission=pp.use_admin_interface)
rpm(role=role, permission=pp.use_admin_interface).save()
rpm(role=role, permission=pp.impersonate_role,
argument="ta").save()
rpm(role=role, permission=pp.edit_course_permissions).save()
rpm(role=role, permission=pp.edit_course).save()
rpm(role=role, permission=pp.manage_authentication_tokens).save()
rpm(role=role, permission=pp.access_files_for,
argument="instructor").save()
......@@ -622,7 +722,6 @@ def add_default_roles_and_permissions(course,
rpm(role=role, permission=pp.edit_grading_opportunity).save()
rpm(role=role, permission=pp.batch_import_grade).save()
rpm(role=role, permission=pp.batch_export_grade).save()
rpm(role=role, permission=pp.batch_download_submission).save()
rpm(role=role, permission=pp.batch_impose_flow_session_deadline).save()
rpm(role=role, permission=pp.batch_end_flow_session).save()
......@@ -673,25 +772,73 @@ def _set_up_course_permissions(sender, instance, created, raw, using, update_fie
# }}}
# {{{ auth token
class AuthenticationToken(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL,
verbose_name=_("User ID"), on_delete=models.CASCADE)
participation = models.ForeignKey(Participation,
verbose_name=_("Participation"), on_delete=models.CASCADE)
restrict_to_participation_role = models.ForeignKey(ParticipationRole,
verbose_name=_("Restrict to role"), on_delete=models.CASCADE,
blank=True, null=True)
description = models.CharField(max_length=200,
verbose_name=_("Description"))
creation_time = models.DateTimeField(
default=now, verbose_name=_("Creation time"))
last_use_time = models.DateTimeField(
verbose_name=_("Last use time"),
blank=True, null=True)
valid_until = models.DateTimeField(
default=None, verbose_name=_("Valid until"),
blank=True, null=True)
revocation_time = models.DateTimeField(
default=None, verbose_name=_("Revocation time"),
blank=True, null=True)
token_hash = models.CharField(max_length=200,
help_text=_("A hash of the authentication token to be "
"used for direct git access."),
null=True, blank=True, unique=True,
verbose_name=_("Hash of git authentication token"))
def __str__(self) -> str:
return _("Token %(id)d for %(participation)s: %(description)s") % {
"id": self.id,
"participation": self.participation,
"description": self.description}
class Meta:
verbose_name = _("Authentication token")
verbose_name_plural = _("Authentication tokens")
ordering = ("participation", "creation_time")
# }}}
# {{{ instant flow request
class InstantFlowRequest(models.Model):
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
flow_id = models.CharField(max_length=200,
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
start_time = models.DateTimeField(default=now,
verbose_name=_('Start time'))
verbose_name=_("Start time"))
end_time = models.DateTimeField(
verbose_name=_('End time'))
verbose_name=_("End time"))
cancelled = models.BooleanField(default=False,
verbose_name=_('Cancelled'))
verbose_name=_("Cancelled"))
class Meta:
verbose_name = _("Instant flow request")
verbose_name_plural = _("Instant flow requests")
def __unicode__(self):
def __str__(self) -> str:
return _("Instant flow request for "
"%(flow_id)s in %(course)s at %(start_time)s") \
% {
......@@ -700,9 +847,6 @@ class InstantFlowRequest(models.Model):
"start_time": self.start_time,
}
if six.PY3:
__str__ = __unicode__
# }}}
......@@ -712,28 +856,28 @@ class FlowSession(models.Model):
# This looks like it's redundant with 'participation', below--but it's not.
# 'participation' is nullable.
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
participation = models.ForeignKey(Participation, null=True, blank=True,
db_index=True, related_name="flow_sessions",
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
# This looks like it's redundant with participation, above--but it's not.
# Again--'participation' is nullable, and it is useful to be able to
# remember what user a session belongs to, even if they're not enrolled.
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
verbose_name=_('User'), on_delete=models.SET_NULL)
verbose_name=_("User"), on_delete=models.SET_NULL)
active_git_commit_sha = models.CharField(max_length=200,
verbose_name=_('Active git commit SHA'))
verbose_name=_("Active git commit SHA"))
flow_id = models.CharField(max_length=200, db_index=True,
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
start_time = models.DateTimeField(default=now,
verbose_name=_('Start time'))
verbose_name=_("Start time"))
completion_time = models.DateTimeField(null=True, blank=True,
verbose_name=_('Completion time'))
verbose_name=_("Completion time"))
page_count = models.IntegerField(null=True, blank=True,
verbose_name=_('Page count'))
verbose_name=_("Page count"))
# This field allows avoiding redundant checks for whether the
# page data is in line with the course material and the current
......@@ -741,20 +885,20 @@ class FlowSession(models.Model):
# See course.flow.adjust_flow_session_page_data.
page_data_at_revision_key = models.CharField(
max_length=200, null=True, blank=True,
verbose_name=_('Page data at course revision'),
verbose_name=_("Page data at course revision"),
help_text=_(
"Page set-up data is up-to date for this revision of the "
"course material"))
in_progress = models.BooleanField(default=None,
verbose_name=_('In progress'))
verbose_name=_("In progress"))
access_rules_tag = models.CharField(max_length=200, null=True,
blank=True,
verbose_name=_('Access rules tag'))
verbose_name=_("Access rules tag"))
expiration_mode = models.CharField(max_length=20, null=True,
default=flow_session_expiration_mode.end,
choices=FLOW_SESSION_EXPIRATION_MODE_CHOICES,
verbose_name=_('Expiration mode'))
verbose_name=_("Expiration mode"))
# Non-normal: These fields can be recomputed, albeit at great expense.
#
......@@ -763,34 +907,30 @@ class FlowSession(models.Model):
points = models.DecimalField(max_digits=10, decimal_places=2,
blank=True, null=True,
verbose_name=_('Points'))
verbose_name=_("Points"))
max_points = models.DecimalField(max_digits=10, decimal_places=2,
blank=True, null=True,
verbose_name=_('Max point'))
verbose_name=_("Max point"))
result_comment = models.TextField(blank=True, null=True,
verbose_name=_('Result comment'))
verbose_name=_("Result comment"))
class Meta:
verbose_name = _("Flow session")
verbose_name_plural = _("Flow sessions")
ordering = ("course", "-start_time")
def __unicode__(self):
def __str__(self) -> str:
if self.participation is None:
return _("anonymous session %(session_id)d on '%(flow_id)s'") % {
'session_id': self.id,
'flow_id': self.flow_id}
"session_id": self.id,
"flow_id": self.flow_id}
else:
return _("%(user)s's session %(session_id)d on '%(flow_id)s'") % {
'user': self.participation.user,
'session_id': self.id,
'flow_id': self.flow_id}
if six.PY3:
__str__ = __unicode__
"user": self.participation.user,
"session_id": self.id,
"flow_id": self.flow_id}
def append_comment(self, s):
# type: (Text) -> None
def append_comment(self, s: str | None) -> None:
if s is None:
return
......@@ -812,7 +952,10 @@ class FlowSession(models.Model):
from course.flow import assemble_answer_visits
return assemble_answer_visits(self)
def last_activity(self):
def last_activity(self) -> datetime.datetime | None:
if self.pk is None:
return None
for visit in (FlowPageVisit.objects
.filter(
flow_session=self,
......@@ -835,58 +978,55 @@ class FlowSession(models.Model):
class FlowPageData(models.Model):
flow_session = models.ForeignKey(FlowSession, related_name="page_data",
verbose_name=_('Flow session'), on_delete=models.CASCADE)
ordinal = models.IntegerField(null=True, blank=True,
verbose_name=_('Ordinal'))
verbose_name=_("Flow session"), on_delete=models.CASCADE)
page_ordinal = models.IntegerField(null=True, blank=True,
verbose_name=_("Page ordinal"))
# This exists to catch changing page types in course content,
# which will generally lead to an inconsistency disaster.
page_type = models.CharField(max_length=200,
verbose_name=_('Page type as indicated in course content'),
verbose_name=_("Page type as indicated in course content"),
null=True, blank=True)
group_id = models.CharField(max_length=200,
verbose_name=_('Group ID'))
verbose_name=_("Group ID"))
page_id = models.CharField(max_length=200,
verbose_name=_('Page ID'))
verbose_name=_("Page ID"))
data = JSONField(null=True, blank=True,
# Show correct characters in admin for non ascii languages.
dump_kwargs={'ensure_ascii': False},
verbose_name=_('Data'))
dump_kwargs={"ensure_ascii": False},
verbose_name=_("Data"))
title = models.CharField(max_length=1000,
verbose_name=_('Page Title'), null=True, blank=True)
verbose_name=_("Page Title"), null=True, blank=True)
bookmarked = models.BooleanField(default=False,
help_text=_("A user-facing 'marking' feature to allow participants to "
"easily return to pages that still need their attention."),
verbose_name=_('Bookmarked'))
verbose_name=_("Bookmarked"))
class Meta:
verbose_name = _("Flow page data")
verbose_name_plural = _("Flow page data")
def __unicode__(self):
def __str__(self) -> str:
# flow page data
return (_("Data for page '%(group_id)s/%(page_id)s' "
"(ordinal %(ordinal)s) in %(flow_session)s") % {
'group_id': self.group_id,
'page_id': self.page_id,
'ordinal': self.ordinal,
'flow_session': self.flow_session})
if six.PY3:
__str__ = __unicode__
"(page ordinal %(page_ordinal)s) in %(flow_session)s") % {
"group_id": self.group_id,
"page_id": self.page_id,
"page_ordinal": self.page_ordinal,
"flow_session": self.flow_session})
# Django's templates are a little daft. No arithmetic--really?
def previous_ordinal(self):
return self.ordinal - 1
return self.page_ordinal - 1
def next_ordinal(self):
return self.ordinal + 1
return self.page_ordinal + 1
def human_readable_ordinal(self):
return self.ordinal + 1
return self.page_ordinal + 1
# }}}
......@@ -898,34 +1038,34 @@ class FlowPageVisit(models.Model):
# page_data), but it helps the admin site understand the link
# and provide editing.
flow_session = models.ForeignKey(FlowSession, db_index=True,
verbose_name=_('Flow session'), on_delete=models.CASCADE)
verbose_name=_("Flow session"), on_delete=models.CASCADE)
page_data = models.ForeignKey(FlowPageData, db_index=True,
verbose_name=_('Page data'), on_delete=models.CASCADE)
verbose_name=_("Page data"), on_delete=models.CASCADE)
visit_time = models.DateTimeField(default=now, db_index=True,
verbose_name=_('Visit time'))
verbose_name=_("Visit time"))
remote_address = models.GenericIPAddressField(null=True, blank=True,
verbose_name=_('Remote address'))
verbose_name=_("Remote address"))
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
blank=True, related_name="visitor",
verbose_name=_('User'), on_delete=models.SET_NULL)
verbose_name=_("User"), on_delete=models.SET_NULL)
impersonated_by = models.ForeignKey(settings.AUTH_USER_MODEL,
null=True, blank=True, related_name="impersonator",
verbose_name=_('Impersonated by'), on_delete=models.SET_NULL)
verbose_name=_("Impersonated by"), on_delete=models.SET_NULL)
is_synthetic = models.BooleanField(default=False,
help_text=_("Synthetic flow page visits are generated for "
"unvisited pages once a flow is finished. This is needed "
"since grade information is attached to a visit, and it "
"needs a place to go."),
verbose_name=_('Is synthetic'))
verbose_name=_("Is synthetic"))
answer = JSONField(null=True, blank=True,
# Show correct characters in admin for non ascii languages.
dump_kwargs={'ensure_ascii': False},
dump_kwargs={"ensure_ascii": False},
# Translators: "Answer" is a Noun.
verbose_name=_('Answer'))
verbose_name=_("Answer"))
# is_submitted_answer may seem redundant with answers being
# non-NULL, but it isn't. This supports saved (but as
......@@ -935,12 +1075,13 @@ class FlowPageVisit(models.Model):
# (Should coincide with 'answer is None')
# True means it's a final, submitted answer fit for grading.
# False means it's just a saved answer.
is_submitted_answer = models.NullBooleanField(
is_submitted_answer = models.BooleanField(
# Translators: determine whether the answer is a final,
# submitted answer fit for grading.
verbose_name=_('Is submitted answer'))
verbose_name=_("Is submitted answer"),
null=True)
def __unicode__(self):
def __str__(self) -> str:
result = (
# Translators: flow page visit
_("'%(group_id)s/%(page_id)s' in '%(session)s' "
......@@ -953,21 +1094,17 @@ class FlowPageVisit(models.Model):
if self.answer is not None:
# Translators: flow page visit: if an answer is
# provided by user then append the string.
result += six.text_type(_(" (with answer)"))
result += str(_(" (with answer)"))
return result
if six.PY3:
__str__ = __unicode__
class Meta:
verbose_name = _("Flow page visit")
verbose_name_plural = _("Flow page visits")
# These must be distinguishable, to figure out what came later.
unique_together = (("page_data", "visit_time"),)
def get_most_recent_grade(self):
# type: () -> Optional[FlowPageVisitGrade]
def get_most_recent_grade(self) -> FlowPageVisitGrade | None:
grades = self.grades.order_by("-grade_time")[:1]
......@@ -997,22 +1134,22 @@ class FlowPageVisit(models.Model):
class FlowPageVisitGrade(models.Model):
visit = models.ForeignKey(FlowPageVisit, related_name="grades",
verbose_name=_('Visit'), on_delete=models.CASCADE)
verbose_name=_("Visit"), on_delete=models.CASCADE)
# NULL means 'autograded'
# NULL means "autograded"
grader = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True,
verbose_name=_('Grader'), on_delete=models.SET_NULL)
verbose_name=_("Grader"), on_delete=models.SET_NULL)
grade_time = models.DateTimeField(db_index=True, default=now,
verbose_name=_('Grade time'))
verbose_name=_("Grade time"))
graded_at_git_commit_sha = models.CharField(
max_length=200, null=True, blank=True,
verbose_name=_('Graded at git commit SHA'))
verbose_name=_("Graded at git commit SHA"))
grade_data = JSONField(null=True, blank=True,
# Show correct characters in admin for non ascii languages.
dump_kwargs={'ensure_ascii': False},
verbose_name=_('Grade data'))
dump_kwargs={"ensure_ascii": False},
verbose_name=_("Grade data"))
# This data should be recomputable, but we'll cache it here,
# because it might be very expensive (container-launch expensive
......@@ -1022,12 +1159,12 @@ class FlowPageVisitGrade(models.Model):
# Translators: max point in grade
help_text=_("Point value of this question when receiving "
"full credit."),
verbose_name=_('Max points'))
verbose_name=_("Max points"))
correctness = models.FloatField(null=True, blank=True,
# Translators: correctness in grade
help_text=_("Real number between zero and one (inclusively) "
"indicating the degree of correctness of the answer."),
verbose_name=_('Correctness'))
verbose_name=_("Correctness"))
# This JSON object has fields corresponding to
# :class:`course.page.AnswerFeedback`, except for
......@@ -1036,11 +1173,11 @@ class FlowPageVisitGrade(models.Model):
feedback = JSONField(null=True, blank=True,
# Show correct characters in admin for non ascii languages.
dump_kwargs={'ensure_ascii': False},
dump_kwargs={"ensure_ascii": False},
# Translators: "Feedback" stands for the feedback of answers.
verbose_name=_('Feedback'))
verbose_name=_("Feedback"))
def percentage(self):
def percentage(self) -> float | None:
if self.correctness is not None:
return 100*self.correctness
else:
......@@ -1060,42 +1197,95 @@ class FlowPageVisitGrade(models.Model):
ordering = ("visit", "grade_time")
def __unicode__(self):
def __str__(self) -> str:
# information on FlowPageVisitGrade class
# Translators: return the information of the grade of a user
# by percentage.
return _("grade of %(visit)s: %(percentage)s") % {
"visit": self.visit, "percentage": self.percentage()}
if six.PY3:
__str__ = __unicode__
# }}}
# {{{ bulk feedback
class FlowPageBulkFeedback(models.Model):
# We're only storing one of these per page, because
# they're 'bulk' (i.e. big, like plots or program output)
page_data = models.OneToOneField(FlowPageData,
verbose_name=_('Page data'), on_delete=models.CASCADE)
verbose_name=_("Page data"), on_delete=models.CASCADE)
grade = models.ForeignKey(FlowPageVisitGrade,
verbose_name=_('Grade'), on_delete=models.CASCADE)
verbose_name=_("Grade"), on_delete=models.CASCADE)
bulk_feedback = JSONField(null=True, blank=True,
# Show correct characters in admin for non ascii languages.
dump_kwargs={'ensure_ascii': False},
verbose_name=_('Bulk feedback'))
dump_kwargs={"ensure_ascii": False},
verbose_name=_("Bulk feedback"))
def update_bulk_feedback(page_data, grade, bulk_feedback_json):
# type: (FlowPageData, FlowPageVisitGrade, Any) -> None
FlowPageBulkFeedback.objects.update_or_create(
page_data=page_data,
defaults=dict(
grade=grade,
bulk_feedback=bulk_feedback_json))
BULK_FEEDBACK_FILENAME_KEY = "_rl_stor_fn"
def get_feedback_for_grade(grade):
# type: (FlowPageVisitGrade) -> Optional[AnswerFeedback]
def update_bulk_feedback(page_data: FlowPageData, grade: FlowPageVisitGrade,
bulk_feedback_json: Any) -> None:
import json
import zlib
compressed_bulk_json_str = zlib.compress(
json.dumps(bulk_feedback_json).encode("utf-8"))
from django.db import transaction
with transaction.atomic():
try:
fp_bulk_feedback = FlowPageBulkFeedback.objects.get(page_data=page_data)
if (isinstance(fp_bulk_feedback.bulk_feedback, dict)
and (BULK_FEEDBACK_FILENAME_KEY
in fp_bulk_feedback.bulk_feedback)):
storage_fn_to_delete = fp_bulk_feedback.bulk_feedback[
BULK_FEEDBACK_FILENAME_KEY]
def delete_bulk_fb_file():
print(f"DELETING {storage_fn_to_delete}!")
settings.RELATE_BULK_STORAGE.delete(storage_fn_to_delete)
transaction.on_commit(delete_bulk_fb_file)
except ObjectDoesNotExist:
fp_bulk_feedback = FlowPageBulkFeedback(page_data=page_data)
# Half the sector size on Linux
if len(compressed_bulk_json_str) >= 256:
username = "anon"
flow_session = page_data.flow_session
if flow_session.participation is not None:
username = flow_session.participation.user.username
fn_pattern = (
"bulk-feedback/"
f"{flow_session.course.identifier}/"
f"{flow_session.flow_id}/"
f"{page_data.page_id}/"
f"{username}"
f".json_zlib")
from django.core.files.base import ContentFile
saved_name = settings.RELATE_BULK_STORAGE.save(
fn_pattern,
ContentFile(compressed_bulk_json_str))
bulk_feedback_json = {BULK_FEEDBACK_FILENAME_KEY: saved_name}
fp_bulk_feedback.grade = grade
fp_bulk_feedback.bulk_feedback = bulk_feedback_json
fp_bulk_feedback.save()
def get_feedback_for_grade(
grade: FlowPageVisitGrade | None) -> AnswerFeedback | None:
if grade is None:
return None
try:
bulk_feedback_json = FlowPageBulkFeedback.objects.get(
......@@ -1104,24 +1294,35 @@ def get_feedback_for_grade(grade):
except ObjectDoesNotExist:
bulk_feedback_json = None
if grade is not None:
return AnswerFeedback.from_json(
grade.feedback, bulk_feedback_json)
else:
return None
if (bulk_feedback_json is not None
and isinstance(bulk_feedback_json, dict)
and (BULK_FEEDBACK_FILENAME_KEY in bulk_feedback_json)):
import json
import zlib
try:
with settings.RELATE_BULK_STORAGE.open(
bulk_feedback_json[BULK_FEEDBACK_FILENAME_KEY]
) as inf:
bulk_feedback_json = json.loads(
zlib.decompress(inf.read()).decode("utf-8"))
except FileNotFoundError:
bulk_feedback_json = None
from course.page.base import AnswerFeedback
return AnswerFeedback.from_json(grade.feedback, bulk_feedback_json)
# }}}
# {{{ flow access
# {{{ deprecated flow rule exception stuff
def validate_stipulations(stip):
def validate_stipulations(stip): # pragma: no cover (deprecated and not tested)
if stip is None:
return
if not isinstance(stip, dict):
raise ValidationError(_("stipulations must be a dictionary"))
allowed_keys = set(["credit_percent", "allowed_session_count"])
allowed_keys = {"credit_percent", "allowed_session_count"}
if not set(stip.keys()) <= allowed_keys:
raise ValidationError(
string_concat(
......@@ -1130,7 +1331,7 @@ def validate_stipulations(stip):
% ", ".join(set(stip.keys()) - allowed_keys))
if "credit_percent" in stip and not isinstance(
stip["credit_percent"], (int, float)):
stip["credit_percent"], int | float):
raise ValidationError(_("credit_percent must be a float"))
if ("allowed_session_count" in stip
and (
......@@ -1140,17 +1341,15 @@ def validate_stipulations(stip):
_("'allowed_session_count' must be a non-negative integer"))
# {{{ deprecated exception stuff
class FlowAccessException(models.Model):
class FlowAccessException(models.Model): # pragma: no cover (deprecated and not tested) # noqa
# deprecated
participation = models.ForeignKey(Participation, db_index=True,
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
flow_id = models.CharField(max_length=200, blank=False, null=False,
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
expiration = models.DateTimeField(blank=True, null=True,
verbose_name=_('Expiration'))
verbose_name=_("Expiration"))
stipulations = JSONField(blank=True, null=True,
# Translators: help text for stipulations in FlowAccessException
......@@ -1160,13 +1359,13 @@ class FlowAccessException(models.Model):
"credit_percent. If not specified here, values will default "
"to the stipulations in the course content."),
validators=[validate_stipulations],
dump_kwargs={'ensure_ascii': False},
verbose_name=_('Stipulations'))
dump_kwargs={"ensure_ascii": False},
verbose_name=_("Stipulations"))
creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
verbose_name=_('Creator'), on_delete=models.SET_NULL)
verbose_name=_("Creator"), on_delete=models.SET_NULL)
creation_time = models.DateTimeField(default=now, db_index=True,
verbose_name=_('Creation time'))
verbose_name=_("Creation time"))
is_sticky = models.BooleanField(
default=False,
......@@ -1175,12 +1374,12 @@ class FlowAccessException(models.Model):
"exception rule set should stay "
"under this rule set until it is expired."),
# Translators: deprecated
verbose_name=_('Is sticky'))
verbose_name=_("Is sticky"))
comment = models.TextField(blank=True, null=True,
verbose_name=_('Comment'))
verbose_name=_("Comment"))
def __unicode__(self):
def __str__(self) -> str:
return (
# Translators: flow access exception in admin (deprecated)
_("Access exception for '%(user)s' to '%(flow_id)s' "
......@@ -1191,125 +1390,127 @@ class FlowAccessException(models.Model):
"course": self.participation.course
})
if six.PY3:
__str__ = __unicode__
class FlowAccessExceptionEntry(models.Model):
class FlowAccessExceptionEntry(models.Model): # pragma: no cover (deprecated and not tested) # noqa
# deprecated
exception = models.ForeignKey(FlowAccessException,
related_name="entries",
verbose_name=_('Exception'), on_delete=models.CASCADE)
verbose_name=_("Exception"), on_delete=models.CASCADE)
permission = models.CharField(max_length=50,
choices=FLOW_PERMISSION_CHOICES,
verbose_name=_('Permission'))
verbose_name=_("Permission"))
class Meta:
# Translators: FlowAccessExceptionEntry (deprecated)
verbose_name_plural = _("Flow access exception entries")
def __unicode__(self):
def __str__(self) -> str:
return self.permission
if six.PY3:
__str__ = __unicode__
# }}}
# {{{ flow rule exception
class FlowRuleException(models.Model):
flow_id = models.CharField(max_length=200, blank=False, null=False,
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
participation = models.ForeignKey(Participation, db_index=True,
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
expiration = models.DateTimeField(blank=True, null=True,
verbose_name=_('Expiration'))
verbose_name=_("Expiration"))
creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
verbose_name=_('Creator'), on_delete=models.SET_NULL)
verbose_name=_("Creator"), on_delete=models.SET_NULL)
creation_time = models.DateTimeField(default=now, db_index=True,
verbose_name=_('Creation time'))
verbose_name=_("Creation time"))
comment = models.TextField(blank=True, null=True,
verbose_name=_('Comment'))
verbose_name=_("Comment"))
kind = models.CharField(max_length=50, blank=False, null=False,
choices=FLOW_RULE_KIND_CHOICES,
verbose_name=_('Kind'))
verbose_name=_("Kind"))
rule = YAMLField(blank=False, null=False,
verbose_name=_('Rule'))
verbose_name=_("Rule"))
active = models.BooleanField(default=True,
verbose_name=pgettext_lazy(
"Is the flow rule exception activated?", "Active"))
def __unicode__(self):
def __str__(self) -> str:
return (
# Translators: For FlowRuleException
_("%(kind)s exception for '%(user)s' to '%(flow_id)s'"
"in '%(course)s'")
_("%(kind)s exception %(exception_id)s for '%(user)s' to "
"'%(flow_id)s' in '%(course)s'")
% {
"kind": self.kind,
"user": self.participation.user,
"flow_id": self.flow_id,
"course": self.participation.course})
"course": self.participation.course,
"exception_id":
" id %d" % self.id if self.id is not None else ""})
if six.PY3:
__str__ = __unicode__
def clean(self) -> None:
super().clean()
def clean(self):
super(FlowRuleException, self).clean()
if self.kind not in dict(FLOW_RULE_KIND_CHOICES).keys():
raise ValidationError(
# Translators: the rule refers to FlowRuleException rule
string_concat(_("invalid exception rule kind"), ": ", self.kind))
if (self.kind == flow_rule_kind.grading
and self.expiration is not None):
raise ValidationError(_("grading rules may not expire"))
from course.content import (
get_course_commit_sha,
get_course_repo,
get_flow_desc,
)
from course.validation import (
ValidationError as ContentValidationError,
validate_session_start_rule,
validate_session_access_rule,
validate_session_grading_rule,
ValidationContext)
from course.content import (get_course_repo,
get_course_commit_sha,
get_flow_desc)
ValidationContext,
ValidationError as ContentValidationError,
validate_session_access_rule,
validate_session_grading_rule,
validate_session_start_rule,
)
from relate.utils import dict_to_struct
rule = dict_to_struct(self.rule)
repo = get_course_repo(self.participation.course)
commit_sha = get_course_commit_sha(
self.participation.course, self.participation)
ctx = ValidationContext(
repo=repo,
commit_sha=commit_sha)
with get_course_repo(self.participation.course) as repo:
commit_sha = get_course_commit_sha(
self.participation.course, self.participation)
ctx = ValidationContext(
repo=repo,
commit_sha=commit_sha)
flow_desc = get_flow_desc(repo,
self.participation.course,
self.flow_id, commit_sha)
flow_desc = get_flow_desc(repo,
self.participation.course,
self.flow_id, commit_sha)
tags = None
tags: list = []
grade_identifier = None
if hasattr(flow_desc, "rules"):
tags = getattr(flow_desc.rules, "tags", None)
tags = cast(list, getattr(flow_desc.rules, "tags", []))
grade_identifier = flow_desc.rules.grade_identifier
try:
if self.kind == flow_rule_kind.start:
validate_session_start_rule(ctx, six.text_type(self), rule, tags)
validate_session_start_rule(ctx, str(self), rule, tags)
elif self.kind == flow_rule_kind.access:
validate_session_access_rule(ctx, six.text_type(self), rule, tags)
validate_session_access_rule(ctx, str(self), rule, tags)
elif self.kind == flow_rule_kind.grading:
validate_session_grading_rule(
ctx, six.text_type(self), rule, tags,
ctx, str(self), rule, tags,
grade_identifier)
else:
# the rule refers to FlowRuleException rule
raise ValidationError(_("invalid rule kind: ")+self.kind)
else: # pragma: no cover. This won't happen
raise ValueError("invalid exception rule kind")
except ContentValidationError as e:
# the rule refers to FlowRuleException rule
raise ValidationError(_("invalid existing_session_rules: ")+str(e))
raise ValidationError(
string_concat(_("invalid existing_session_rules"), ": ", str(e)))
class Meta:
verbose_name = _("Flow rule exception")
......@@ -1322,42 +1523,42 @@ class FlowRuleException(models.Model):
class GradingOpportunity(models.Model):
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
identifier = models.CharField(max_length=200, blank=False, null=False,
# Translators: format of identifier for GradingOpportunity
help_text=_("A symbolic name for this grade. "
"lower_case_with_underscores, no spaces."),
verbose_name=_('Grading opportunity ID'),
verbose_name=_("Grading opportunity ID"),
validators=[
RegexValidator(
"^"+GRADING_OPP_ID_REGEX+"$",
message=_(
"Identifier may only contain letters, "
"numbers, and hypens ('-').")),
"numbers, and hyphens ('-').")),
])
name = models.CharField(max_length=200, blank=False, null=False,
# Translators: name for GradingOpportunity
help_text=_("A human-readable identifier for the grade."),
verbose_name=_('Grading opportunity name'))
verbose_name=_("Grading opportunity name"))
flow_id = models.CharField(max_length=200, blank=True, null=True,
help_text=_("Flow identifier that this grading opportunity "
"is linked to, if any"),
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
aggregation_strategy = models.CharField(max_length=20,
choices=GRADE_AGGREGATION_STRATEGY_CHOICES,
# Translators: strategy on how the grading of mutiple sessioins
# Translators: strategy on how the grading of multiple sessioins
# are aggregated.
verbose_name=_('Aggregation strategy'))
verbose_name=_("Aggregation strategy"))
due_time = models.DateTimeField(default=None, blank=True, null=True,
verbose_name=_('Due time'))
verbose_name=_("Due time"))
creation_time = models.DateTimeField(default=now,
verbose_name=_('Creation time'))
verbose_name=_("Creation time"))
shown_in_grade_book = models.BooleanField(default=True,
verbose_name=_('Shown in grade book'))
verbose_name=_("Shown in grade book"))
shown_in_participant_grade_book = models.BooleanField(default=True,
verbose_name=_("Shown in student grade book"))
result_shown_in_participant_grade_book = models.BooleanField(default=True,
......@@ -1367,16 +1568,16 @@ class GradingOpportunity(models.Model):
verbose_name=_("Scores for individual pages are shown "
"in the participants' grade book"))
hide_superseded_grade_history_before = models.DateTimeField(
verbose_name=_('Hide superseded grade history before'),
verbose_name=_("Hide superseded grade history before"),
blank=True, null=True,
help_text=_(
'Grade changes dated before this date that are '
'superseded by later grade changes will not be shown to '
'participants. '
'This can help avoid discussions about pre-release grading '
'adjustments. '
'May be blank. In that case, the entire grade history is '
'shown.'))
"Grade changes dated before this date that are "
"superseded by later grade changes will not be shown to "
"participants. "
"This can help avoid discussions about pre-release grading "
"adjustments. "
"May be blank. In that case, the entire grade history is "
"shown."))
class Meta:
verbose_name = _("Grading opportunity")
......@@ -1384,7 +1585,7 @@ class GradingOpportunity(models.Model):
ordering = ("course", "due_time", "identifier")
unique_together = (("course", "identifier"),)
def __unicode__(self):
def __str__(self) -> str:
return (
# Translators: For GradingOpportunity
_("%(opportunity_name)s (%(opportunity_id)s) in %(course)s")
......@@ -1393,9 +1594,6 @@ class GradingOpportunity(models.Model):
"opportunity_id": self.identifier,
"course": self.course})
if six.PY3:
__str__ = __unicode__
def get_aggregation_strategy_descr(self):
return dict(GRADE_AGGREGATION_STRATEGY_CHOICES).get(
self.aggregation_strategy)
......@@ -1410,15 +1608,15 @@ class GradeChange(models.Model):
ones.
"""
opportunity = models.ForeignKey(GradingOpportunity,
verbose_name=_('Grading opportunity'), on_delete=models.CASCADE)
verbose_name=_("Grading opportunity"), on_delete=models.CASCADE)
participation = models.ForeignKey(Participation,
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
state = models.CharField(max_length=50,
choices=GRADE_STATE_CHANGE_CHOICES,
# Translators: something like 'status'.
verbose_name=_('State'))
verbose_name=_("State"))
attempt_id = models.CharField(max_length=50, null=True, blank=True,
default="main",
......@@ -1426,54 +1624,49 @@ class GradeChange(models.Model):
help_text=_("Grade changes are grouped by their 'attempt ID' "
"where later grades with the same attempt ID supersede earlier "
"ones."),
verbose_name=_('Attempt ID'))
verbose_name=_("Attempt ID"))
points = models.DecimalField(max_digits=10, decimal_places=2,
blank=True, null=True,
verbose_name=_('Points'))
verbose_name=_("Points"))
max_points = models.DecimalField(max_digits=10, decimal_places=2,
verbose_name=_('Max points'))
verbose_name=_("Max points"))
comment = models.TextField(blank=True, null=True,
verbose_name=_('Comment'))
verbose_name=_("Comment"))
due_time = models.DateTimeField(default=None, blank=True, null=True,
verbose_name=_('Due time'))
verbose_name=_("Due time"))
creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
verbose_name=_('Creator'), on_delete=models.SET_NULL)
verbose_name=_("Creator"), on_delete=models.SET_NULL)
grade_time = models.DateTimeField(default=now, db_index=True,
verbose_name=_('Grade time'))
verbose_name=_("Grade time"))
flow_session = models.ForeignKey(FlowSession, null=True, blank=True,
related_name="grade_changes",
verbose_name=_('Flow session'), on_delete=models.SET_NULL)
verbose_name=_("Flow session"), on_delete=models.SET_NULL)
class Meta:
verbose_name = _("Grade change")
verbose_name_plural = _("Grade changes")
ordering = ("opportunity", "participation", "grade_time")
def __unicode__(self):
def str(self):
# Translators: information for GradeChange
return _("%(participation)s %(state)s on %(opportunityname)s") % {
'participation': self.participation,
'state': self.state,
'opportunityname': self.opportunity.name}
"participation": self.participation,
"state": self.state,
"opportunityname": self.opportunity.name}
if six.PY3:
__str__ = __unicode__
def clean(self):
super(GradeChange, self).clean()
def clean(self) -> None:
super().clean()
if self.opportunity.course != self.participation.course:
raise ValidationError(_("Participation and opportunity must live "
"in the same course"))
def percentage(self):
# type: () -> Optional[float]
def percentage(self) -> Decimal | None:
if (self.max_points is not None
and self.points is not None
and self.max_points != 0):
......@@ -1485,14 +1678,27 @@ class GradeChange(models.Model):
return dict(GRADE_STATE_CHANGE_CHOICES).get(
self.state)
# may be set by GradeStateMachine
# FIXME: This is kind of a nasty thing to do
is_superseded: bool
# }}}
# {{{ grade state machine
class GradeStateMachine(object):
def __init__(self):
# type: () -> None
class GradeStateMachine:
opportunity: GradingOpportunity | None
state: str | None
due_time: datetime.datetime | None
last_graded_time: datetime.datetime | None
last_report_time: datetime.datetime | None
_last_grade_change_time: datetime.datetime | None
valid_percentages: list[float | Decimal]
attempt_id_to_gchange: dict[str, GradeChange]
def __init__(self) -> None:
self.opportunity = None
self.state = None
......@@ -1504,16 +1710,15 @@ class GradeStateMachine(object):
# applies to *all* grade changes
self._last_grade_change_time = None
def _clear_grades(self):
# type: () -> None
def _clear_grades(self) -> None:
self.state = None
self.last_grade_time = None
self.valid_percentages = [] # type: List[GradeChange]
self.attempt_id_to_gchange = {} # type: Dict[Text, GradeChange]
self.valid_percentages = []
self.attempt_id_to_gchange: dict[str, GradeChange] = {}
def _consume_grade_change(self, gchange, set_is_superseded):
# type: (GradeChange, bool) -> None
def _consume_grade_change(self,
gchange: GradeChange, set_is_superseded: bool) -> None:
if self.opportunity is None:
opp = self.opportunity = gchange.opportunity
......@@ -1526,8 +1731,8 @@ class GradeStateMachine(object):
# check that times are increasing
if self._last_grade_change_time is not None:
assert gchange.grade_time > self._last_grade_change_time
self._last_grade_change_time = gchange.grade_time
assert gchange.grade_time >= self._last_grade_change_time
self._last_grade_change_time = gchange.grade_time
if gchange.state == grade_state_change_types.graded:
if self.state == grade_state_change_types.unavailable:
......@@ -1539,19 +1744,18 @@ class GradeStateMachine(object):
_("cannot accept grade once opportunity has been "
"marked 'exempt'"))
#if self.due_time is not None and gchange.grade_time > self.due_time:
#raise ValueError("cannot accept grade after due date")
# if self.due_time is not None and gchange.grade_time > self.due_time:
# raise ValueError("cannot accept grade after due date")
self.state = gchange.state
if gchange.attempt_id is not None:
if (set_is_superseded and
gchange.attempt_id in self.attempt_id_to_gchange):
if (set_is_superseded
and gchange.attempt_id in self.attempt_id_to_gchange):
self.attempt_id_to_gchange[gchange.attempt_id] \
.is_superseded = True
self.attempt_id_to_gchange[gchange.attempt_id] \
= gchange
self.attempt_id_to_gchange[gchange.attempt_id] = gchange
else:
self.valid_percentages.append(gchange.percentage())
self.valid_percentages.append(not_none(gchange.percentage()))
self.last_graded_time = gchange.grade_time
......@@ -1581,8 +1785,8 @@ class GradeStateMachine(object):
raise RuntimeError(
_("invalid grade change state '%s'") % gchange.state)
def consume(self, iterable, set_is_superseded=False):
# type: (Iterable[GradeChange], bool) -> GradeStateMachine
def consume(self, iterable: Iterable[GradeChange],
set_is_superseded: bool = False) -> GradeStateMachine:
for gchange in iterable:
gchange.is_superseded = False
......@@ -1595,16 +1799,14 @@ class GradeStateMachine(object):
key=lambda gchange: gchange.grade_time)
self.valid_percentages.extend(
cast(GradeChange, gchange.percentage())
not_none(gchange.percentage())
for gchange in valid_grade_changes)
del self.attempt_id_to_gchange
return self
def percentage(self):
# type: () -> Optional[float]
def percentage(self) -> float | Decimal | None:
"""
:return: a percentage of achieved points, or *None*
"""
......@@ -1629,39 +1831,39 @@ class GradeStateMachine(object):
def stringify_state(self):
if self.state is None:
return u"- ∅ -"
return "- ∅ -"
elif self.state == grade_state_change_types.exempt:
return "_((exempt))"
return _("(exempt)")
elif self.state == grade_state_change_types.graded:
if self.valid_percentages:
result = "%.1f%%" % self.percentage()
result = f"{self.percentage():.1f}%"
if len(self.valid_percentages) > 1:
result += " (/%d)" % len(self.valid_percentages)
return result
else:
return u"- ∅ -"
return "- ∅ -"
else:
return "_((other state))"
return _("(other state)")
def stringify_machine_readable_state(self):
if self.state is None:
return u"NONE"
return "NONE"
elif self.state == grade_state_change_types.exempt:
return "EXEMPT"
elif self.state == grade_state_change_types.graded:
if self.valid_percentages:
return "%.3f" % self.percentage()
return f"{self.percentage():.3f}"
else:
return u"NONE"
return "NONE"
else:
return u"OTHER_STATE"
return "OTHER_STATE"
def stringify_percentage(self):
if self.state == grade_state_change_types.graded:
if self.valid_percentages:
return "%.1f" % self.percentage()
return f"{self.percentage():.1f}"
else:
return u""
return ""
else:
return ""
# }}}
......@@ -1669,9 +1871,10 @@ class GradeStateMachine(object):
# {{{ flow <-> grading integration
def get_flow_grading_opportunity(course, flow_id, flow_desc,
grade_identifier, grade_aggregation_strategy):
# type: (Course, Text, FlowDesc, Text, Text) -> GradingOpportunity
def get_flow_grading_opportunity(
course: Course, flow_id: str, flow_desc: FlowDesc,
grade_identifier: str, grade_aggregation_strategy: str
) -> GradingOpportunity:
default_name = (
# Translators: display the name of a flow
......@@ -1681,11 +1884,11 @@ def get_flow_grading_opportunity(course, flow_id, flow_desc,
gopp, created = GradingOpportunity.objects.get_or_create(
course=course,
identifier=grade_identifier,
defaults=dict(
name=default_name,
flow_id=flow_id,
aggregation_strategy=grade_aggregation_strategy,
))
defaults={
"name": default_name,
"flow_id": flow_id,
"aggregation_strategy": grade_aggregation_strategy,
})
# update gopp.name when flow_desc.title changed
if not created:
......@@ -1702,22 +1905,19 @@ def get_flow_grading_opportunity(course, flow_id, flow_desc,
class InstantMessage(models.Model):
participation = models.ForeignKey(Participation,
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
text = models.CharField(max_length=200,
verbose_name=_('Text'))
verbose_name=_("Text"))
time = models.DateTimeField(default=now,
verbose_name=_('Time'))
verbose_name=_("Time"))
class Meta:
verbose_name = _("Instant message")
verbose_name_plural = _("Instant messages")
ordering = ("participation__course", "time")
def __unicode__(self):
return "%s: %s" % (self.participation, self.text)
if six.PY3:
__str__ = __unicode__
def __str__(self) -> str:
return f"{self.participation}: {self.text}"
# }}}
......@@ -1726,56 +1926,80 @@ class InstantMessage(models.Model):
class Exam(models.Model):
course = models.ForeignKey(Course,
verbose_name=_('Course'), on_delete=models.CASCADE)
verbose_name=_("Course"), on_delete=models.CASCADE)
description = models.CharField(max_length=200,
verbose_name=_('Description'))
verbose_name=_("Description"))
flow_id = models.CharField(max_length=200,
verbose_name=_('Flow ID'))
verbose_name=_("Flow ID"))
active = models.BooleanField(
default=True,
verbose_name=_('Currently active'))
verbose_name=_("Active"),
help_text=_(
"Currently active, i.e. may be used to log in "
"via an exam ticket"))
listed = models.BooleanField(
verbose_name=_("Listed"),
default=True,
help_text=_("Shown in the list of current exams"))
no_exams_before = models.DateTimeField(
verbose_name=_('No exams before'))
verbose_name=_("No exams before"))
no_exams_after = models.DateTimeField(
null=True, blank=True,
verbose_name=_('No exams after'))
verbose_name=_("No exams after"))
class Meta:
verbose_name = _("Exam")
verbose_name_plural = _("Exams")
ordering = ("course", "no_exams_before",)
def __unicode__(self):
def __str__(self) -> str:
return _("Exam %(description)s in %(course)s") % {
'description': self.description,
'course': self.course,
"description": self.description,
"course": self.course,
}
if six.PY3:
__str__ = __unicode__
class ExamTicket(models.Model):
exam = models.ForeignKey(Exam,
verbose_name=_('Exam'), on_delete=models.CASCADE)
verbose_name=_("Exam"), on_delete=models.CASCADE)
participation = models.ForeignKey(Participation, db_index=True,
verbose_name=_('Participation'), on_delete=models.CASCADE)
verbose_name=_("Participation"), on_delete=models.CASCADE)
creator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
verbose_name=_('Creator'), on_delete=models.SET_NULL)
verbose_name=_("Creator"), on_delete=models.SET_NULL)
creation_time = models.DateTimeField(default=now,
verbose_name=_('Creation time'))
verbose_name=_("Creation time"))
usage_time = models.DateTimeField(
verbose_name=_('Date and time of first usage of ticket'),
verbose_name=_("Usage time"),
help_text=_("Date and time of first usage of ticket"),
null=True, blank=True)
state = models.CharField(max_length=50,
choices=EXAM_TICKET_STATE_CHOICES,
verbose_name=_('Exam ticket state'))
verbose_name=_("Exam ticket state"))
code = models.CharField(max_length=50)
valid_start_time = models.DateTimeField(
verbose_name=_("End valid period"),
help_text=_("If not blank, date and time at which this exam ticket "
"starts being valid/usable"),
null=True, blank=True)
valid_end_time = models.DateTimeField(
verbose_name=_("End valid period"),
help_text=_("If not blank, date and time at which this exam ticket "
"stops being valid/usable"),
null=True, blank=True)
restrict_to_facility = models.CharField(max_length=200, blank=True, null=True,
verbose_name=_("Restrict to facility"),
help_text=_("If not blank, this exam ticket may only be used in the "
"given facility"))
code = models.CharField(max_length=50, db_index=True, unique=True)
require_login = models.BooleanField(
default=False,
help_text=_("If set, the exam ticket can only be used once logged in"))
class Meta:
verbose_name = _("Exam ticket")
......@@ -1785,17 +2009,14 @@ class ExamTicket(models.Model):
("can_issue_exam_tickets", _("Can issue exam tickets to student")),
)
def __unicode__(self):
def __str__(self) -> str:
return _("Exam ticket for %(participation)s in %(exam)s") % {
'participation': self.participation,
'exam': self.exam,
"participation": self.participation,
"exam": self.exam,
}
if six.PY3:
__str__ = __unicode__
def clean(self):
super(ExamTicket, self).clean()
super().clean()
try:
if self.exam.course != self.participation.course:
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -25,34 +24,55 @@ THE SOFTWARE.
"""
from course.page.base import (
InvalidPageData,
PageBase, AnswerFeedback, PageContext, PageBehavior,
get_auto_feedback,
markup_to_html)
from course.page.static import Page
from course.page.text import (
TextQuestion, SurveyTextQuestion, HumanGradedTextQuestion)
from course.page.inline import InlineMultiQuestion
AnswerFeedback,
InvalidPageData,
PageBase,
PageBehavior,
PageContext,
get_auto_feedback,
markup_to_html,
)
from course.page.choice import (
ChoiceQuestion, MultipleChoiceQuestion, SurveyChoiceQuestion)
ChoiceQuestion,
MultipleChoiceQuestion,
SurveyChoiceQuestion,
)
from course.page.code import (
PythonCodeQuestion, PythonCodeQuestionWithHumanTextFeedback)
PythonCodeQuestion,
PythonCodeQuestionWithHumanTextFeedback,
)
from course.page.inline import InlineMultiQuestion
from course.page.static import Page
from course.page.text import (
HumanGradedRichTextQuestion,
HumanGradedTextQuestion,
SurveyTextQuestion,
TextQuestion,
)
from course.page.upload import FileUploadQuestion
__all__ = (
"InvalidPageData",
"PageBase", "AnswerFeedback", "PageContext", "PageBehavior",
"get_auto_feedback",
"markup_to_html",
"Page",
"TextQuestion", "SurveyTextQuestion", "HumanGradedTextQuestion",
"InlineMultiQuestion",
"ChoiceQuestion", "SurveyChoiceQuestion", "MultipleChoiceQuestion",
"PythonCodeQuestion", "PythonCodeQuestionWithHumanTextFeedback",
"FileUploadQuestion",
)
"AnswerFeedback",
"ChoiceQuestion",
"FileUploadQuestion",
"HumanGradedRichTextQuestion",
"HumanGradedTextQuestion",
"InlineMultiQuestion",
"InvalidPageData",
"MultipleChoiceQuestion",
"Page",
"PageBase",
"PageBehavior",
"PageContext",
"PythonCodeQuestion",
"PythonCodeQuestionWithHumanTextFeedback",
"SurveyChoiceQuestion",
"SurveyTextQuestion",
"TextQuestion",
"get_auto_feedback",
"markup_to_html",
)
__doc__ = """
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,43 +23,81 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Any
import django.forms as forms
from course.validation import validate_struct, ValidationError
from course.constants import MAX_EXTRA_CREDIT_FACTOR
from relate.utils import StyledForm, Struct
import django.http
from django.conf import settings
from django.forms import ValidationError as FormValidationError
from django.utils.safestring import mark_safe
from django.utils.functional import lazy
from django.utils.translation import (
ugettext_lazy as _,
ugettext,
string_concat,
)
from django.utils import translation
from django.conf import settings
from django.utils.translation import gettext, gettext_lazy as _, gettext_noop
from course.constants import MAX_EXTRA_CREDIT_FACTOR
from course.validation import (
AttrSpec,
ValidationContext,
ValidationError,
validate_struct,
)
from relate.utils import Struct, StyledForm, string_concat
# {{{ mypy
from typing import Text, Optional, Any, Tuple # noqa
from django import http # noqa
if False:
from course.models import ( # noqa
Course,
FlowSession
)
from course.content import Repo_ish # noqa
if TYPE_CHECKING:
# FIXME There seem to be some cyclic imports that prevent importing these
# outright.
from course.models import Course, FlowSession
from relate.utils import Repo_ish
# }}}
mark_safe_lazy = lazy(mark_safe, six.text_type)
__doc__ = """
Stub Docs of Internals
======================
.. class:: Repo_ish
See ``relate.utils.Repo_ish``.
.. class:: Course
See ``course.models.Course``.
.. class:: FlowSession
See ``course.models.FlowSession``.
Page Interface
==============
.. autoclass:: PageContext
.. autoclass:: PageBehavior
.. autoclass:: AnswerFeedback
.. exception:: InvalidPageData
Base Classes For Pages
======================
.. autoclass:: PageBase
.. autoclass:: PageBaseWithTitle
.. autoclass:: PageBaseWithHumanTextFeedback
.. autoclass:: PageBaseWithCorrectAnswer
class PageContext(object):
Automatic Feedback
==================
.. autofunction:: get_auto_feedback
"""
class PageContext:
"""
.. attribute:: course
.. attribute:: repo
......@@ -70,6 +107,7 @@ class PageContext(object):
May be None.
.. attribute:: page_uri
.. attribute:: request
Note that this is different from :class:`course.utils.FlowPageContext`,
which is used internally by the flow views.
......@@ -77,14 +115,14 @@ class PageContext(object):
def __init__(
self,
course, # type: Course
repo, # type: Repo_ish
commit_sha, # type: bytes
flow_session, # type: FlowSession
in_sandbox=False, # type: bool
page_uri=None, # type: Optional[str]
):
# type: (...) -> None
course: Course,
repo: Repo_ish,
commit_sha: bytes,
flow_session: FlowSession,
in_sandbox: bool = False,
page_uri: str | None = None,
request: django.http.HttpRequest | None = None,
) -> None:
self.course = course
self.repo = repo
......@@ -92,9 +130,10 @@ class PageContext(object):
self.flow_session = flow_session
self.in_sandbox = in_sandbox
self.page_uri = page_uri
self.request = request
class PageBehavior(object):
class PageBehavior:
"""
.. attribute:: show_correctness
.. attribute:: show_answer
......@@ -103,18 +142,17 @@ class PageBehavior(object):
def __init__(
self,
show_correctness, # type: bool
show_answer, # type: bool
may_change_answer, # type:bool
):
# type: (...) -> None
show_correctness: bool,
show_answer: bool,
may_change_answer: bool,
) -> None:
self.show_correctness = show_correctness
self.show_answer = show_answer
self.may_change_answer = may_change_answer
def __bool__(self):
# This is for compatiblity: page_behavior used to be a bool argument
# This is for compatibility: page_behavior used to be a bool argument
# 'answer_is_final'.
return not self.may_change_answer
......@@ -122,11 +160,11 @@ class PageBehavior(object):
def markup_to_html(
page_context, # type: PageContext
text, # type: Text
use_jinja=True, # type: bool
):
# type: (...) -> Text
page_context: PageContext,
text: str,
use_jinja: bool = True,
reverse_func: Callable | None = None,
) -> str:
from course.content import markup_to_html as mth
return mth(
......@@ -134,40 +172,93 @@ def markup_to_html(
page_context.repo,
page_context.commit_sha,
text,
use_jinja=use_jinja)
use_jinja=use_jinja,
reverse_func=reverse_func)
# {{{ answer feedback type
def get_auto_feedback(correctness):
# type: (Optional[float]) -> Text
class InvalidFeedbackPointsError(ValueError):
pass
def round_point_count_to_quarters(
value: float, atol: float = 1e-5) -> float | int:
"""
If 'value' is close to an int, a half or quarter, return the close value,
otherwise return the original value.
"""
if abs(value - int(value)) < atol:
return int(value)
import math
_atol = atol * 4
v = value * 4
if abs(v - math.floor(v)) < _atol:
v = math.floor(v)
elif abs(v - math.ceil(v)) < _atol:
v = math.ceil(v)
else:
return value
return round(v / 4, 2)
def validate_point_count(
correctness: float | None, atol: float = 1e-5
) -> (float | int | None):
if correctness is None:
return six.text_type(_("No information on correctness of answer."))
elif correctness == 0:
return six.text_type(_("Your answer is not correct."))
return None
if correctness < -atol or correctness > MAX_EXTRA_CREDIT_FACTOR + atol:
raise InvalidFeedbackPointsError(
_("'correctness' is invalid: expecting "
"a value within [0, %(max_extra_credit_factor)s] or None, "
"got %(invalid_value)s.")
% {"max_extra_credit_factor": MAX_EXTRA_CREDIT_FACTOR,
"invalid_value": correctness}
)
return round_point_count_to_quarters(correctness, atol)
def get_auto_feedback(correctness: float | None) -> str:
correctness = validate_point_count(correctness)
if correctness is None:
return str(
gettext_noop("No information on correctness of answer."))
if correctness == 0:
return str(gettext_noop("Your answer is not correct."))
elif correctness == 1:
return six.text_type(_("Your answer is correct."))
return str(gettext_noop("Your answer is correct."))
elif correctness > 1:
return six.text_type(
return str(
string_concat(
_("Your answer is correct and earned bonus points."),
gettext_noop(
"Your answer is correct and earned bonus points."),
" (%.1f %%)")
% (100*correctness))
elif correctness > 0.5:
return six.text_type(
return str(
string_concat(
_("Your answer is mostly correct."),
gettext_noop("Your answer is mostly correct."),
" (%.1f %%)")
% (100*correctness))
else:
return six.text_type(
return str(
string_concat(
_("Your answer is somewhat correct. "),
gettext_noop("Your answer is somewhat correct. "),
"(%.1f%%)")
% (100*correctness))
class AnswerFeedback(object):
class AnswerFeedback:
"""
.. attribute:: correctness
......@@ -187,13 +278,11 @@ class AnswerFeedback(object):
.. attribute:: bulk_feedback
"""
def __init__(self, correctness, feedback=None, bulk_feedback=None):
# type: (Optional[float], Optional[Text], Optional[Text]) -> None
if correctness is not None:
# allow for extra credit
if correctness < 0 or correctness > MAX_EXTRA_CREDIT_FACTOR:
raise ValueError(_("Invalid correctness value"))
def __init__(self,
correctness: float | None,
feedback: str | None = None,
bulk_feedback: str | None = None) -> None:
correctness = validate_point_count(correctness)
if feedback is None:
feedback = get_auto_feedback(correctness)
......@@ -202,8 +291,7 @@ class AnswerFeedback(object):
self.feedback = feedback
self.bulk_feedback = bulk_feedback
def as_json(self):
# type: () -> Tuple[Dict[Text, Any], Dict[Text, Any]]
def as_json(self) -> tuple[dict[str, Any], dict[str, Any]]:
result = {
"correctness": self.correctness,
"feedback": self.feedback,
......@@ -215,8 +303,7 @@ class AnswerFeedback(object):
return result, bulk_result
@staticmethod
def from_json(json, bulk_json):
# type: (Any, Any) -> AnswerFeedback
def from_json(json: Any, bulk_json: Any) -> AnswerFeedback | None:
if json is None:
return json
......@@ -232,8 +319,7 @@ class AnswerFeedback(object):
bulk_feedback=bulk_feedback,
)
def percentage(self):
# type: () -> Optional[float]
def percentage(self) -> float | None:
if self.correctness is not None:
return 100*self.correctness
......@@ -249,7 +335,7 @@ class InvalidPageData(RuntimeError):
pass
class PageBase(object):
class PageBase(ABC):
"""The abstract interface of a flow page.
.. attribute:: location
......@@ -290,11 +376,16 @@ class PageBase(object):
.. automethod:: grade
.. automethod:: correct_answer
.. automethod:: analytic_view_body
.. automethod:: normalized_answer
.. automethod:: normalized_bytes_answer
"""
def __init__(self, vctx, location, page_desc):
def __init__(self,
vctx: ValidationContext | None,
location: str,
page_desc: Any
) -> None:
"""
:arg vctx: a :class:`course.validation.ValidationContext`, or None
if no validation is desired
......@@ -314,7 +405,7 @@ class PageBase(object):
# {{{ validate access_rules
if hasattr(page_desc, "access_rules"):
ar_loc = "%s: access rules" % location
ar_loc = f"{location}: access rules"
validate_struct(
vctx,
ar_loc,
......@@ -331,23 +422,24 @@ class PageBase(object):
for perm in getattr(page_desc.access_rules, attr):
validate_flow_permission(
vctx,
"%s: %s" % (ar_loc, attr),
f"{ar_loc}: {attr}",
perm)
# }}}
self.page_desc = page_desc
self.page_desc: Any = page_desc
self.is_optional_page: bool = getattr(page_desc, "is_optional_page", False)
else:
from warnings import warn
warn(_("Not passing page_desc to PageBase.__init__ is deprecated"),
warn(gettext("Not passing page_desc to PageBase.__init__ is deprecated"),
DeprecationWarning)
id = page_desc
del page_desc
self.id = id
self.id: str = id
def required_attrs(self):
def required_attrs(self) -> AttrSpec:
"""Required attributes, as accepted by
:func:`course.validation.validate_struct`.
Subclasses should only add to, not remove entries from this.
......@@ -358,7 +450,7 @@ class PageBase(object):
("type", str),
)
def allowed_attrs(self):
def allowed_attrs(self) -> AttrSpec:
"""Allowed attributes, as accepted by
:func:`course.validation.validate_struct`.
Subclasses should only add to, not remove entries from this.
......@@ -366,10 +458,11 @@ class PageBase(object):
return (
("access_rules", Struct),
("is_optional_page", bool),
)
def get_modified_permissions_for_page(self, permissions):
# type: (frozenset[Text]) -> frozenset[Text]
def get_modified_permissions_for_page(
self, permissions: frozenset[str]) -> frozenset[str]:
rw_permissions = set(permissions)
if hasattr(self.page_desc, "access_rules"):
......@@ -384,10 +477,10 @@ class PageBase(object):
return frozenset(rw_permissions)
def make_page_data(self):
def make_page_data(self) -> dict:
return {}
def initialize_page_data(self, page_context):
def initialize_page_data(self, page_context: PageContext) -> dict:
"""Return (possibly randomly generated) data that is used to generate
the content on this page. This is passed to methods below as the *page_data*
argument. One possible use for this argument would be a random permutation
......@@ -397,37 +490,44 @@ class PageBase(object):
data = self.make_page_data()
if data:
from warnings import warn
warn(_("%s is using the make_page_data compatiblity hook, which "
warn(_("%s is using the make_page_data compatibility hook, which "
"is deprecated.") % type(self).__name__,
DeprecationWarning)
return data
def title(self, page_context, page_data):
# type: (PageContext, Dict) -> str
@abstractmethod
def title(self, page_context: PageContext, page_data: dict) -> str:
"""Return the (non-HTML) title of this page."""
raise NotImplementedError()
def body(self, page_context, page_data):
# type: (PageContext, Dict) -> str
def analytic_view_body(self, page_context: PageContext, page_data: dict) -> str:
"""
Return the (HTML) body of the page, which is shown in page analytic
view."""
return self.body(page_context, page_data)
@abstractmethod
def body(self, page_context: PageContext, page_data: dict) -> str:
"""Return the (HTML) body of the page."""
raise NotImplementedError()
...
def expects_answer(self):
# type: () -> bool
@abstractmethod
def expects_answer(self) -> bool:
"""
:return: a :class:`bool` indicating whether this page lets the
user provide an answer of some type.
"""
raise NotImplementedError()
...
def is_answer_gradable(self):
# type: () -> bool
def is_answer_gradable(self) -> bool:
"""
:return: a :class:`bool` indicating whether answers on this can
have :meth:`grade` called on them.
......@@ -436,36 +536,37 @@ class PageBase(object):
"""
return True
def max_points(self, page_data):
# type: (Any) -> float
@abstractmethod
def max_points(self, page_data: Any) -> float:
"""
:return: a :class:`int` or :class:`float` indicating how many points
are achievable on this page.
"""
raise NotImplementedError()
...
# {{{ student input
@abstractmethod
def answer_data(
self,
page_context, # type: PageContext
page_data, # type: Any
form, # type: forms.Form
files_data, # type: Any
):
# type: (...) -> Any
page_context: PageContext,
page_data: Any,
form: forms.Form,
files_data: Any,
) -> Any:
"""Return a JSON-persistable object reflecting the user's answer on the
form. This will be passed to methods below as *answer_data*.
"""
raise NotImplementedError()
...
@abstractmethod
def make_form(
self,
page_context, # type: PageContext
page_data, # type: Any
answer_data, # type: Any
page_behavior, # type: Any
):
page_context: PageContext,
page_data: Any,
answer_data: Any,
page_behavior: Any,
) -> StyledForm:
"""
:arg answer_data: value returned by :meth:`answer_data`.
May be *None*.
......@@ -476,27 +577,17 @@ class PageBase(object):
be read-only.
"""
raise NotImplementedError()
def post_form(
self,
page_context, # type: PageContext
page_data, # type: Any
post_data, # type: Any
files_data # type: Any
):
# type: (...) -> forms.Form
raise NotImplementedError()
...
@abstractmethod
def process_form_post(
self,
page_context, # type: PageContext
page_data, # type: Any
post_data, # type: Any
files_data, # type: Any
page_behavior, # type: PageBehavior
):
# type: (...) -> forms.Form
page_context: PageContext,
page_data: Any,
post_data: Any,
files_data: Any,
page_behavior: PageBehavior,
) -> StyledForm:
"""Return a form with the POST response from *post_data* and *files_data*
filled in.
......@@ -506,82 +597,68 @@ class PageBase(object):
If ``page_behavior.may_change_answer`` is *False*, the form should
be read-only.
"""
from warnings import warn
warn(_("%s is using the post_form compatiblity hook, which "
"is deprecated.") % type(self).__name__,
DeprecationWarning)
return self.post_form(page_context, page_data, post_data, files_data)
...
def form_to_html(
self,
request, # type: http.HttpRequest
page_context, # type: PageContext
form, # type: StyledForm
answer_data, # type: Any
request: django.http.HttpRequest,
page_context: PageContext,
form: StyledForm,
answer_data: Any,
):
"""Returns an HTML rendering of *form*."""
from django.template import loader, RequestContext
from django import VERSION as django_version # noqa
from django.template import loader
if django_version >= (1, 9):
return loader.render_to_string(
"course/crispy-form.html",
context={"form": form},
request=request)
else:
context = RequestContext(request)
context.update({"form": form})
return loader.render_to_string(
"course/crispy-form.html",
context_instance=context)
return loader.render_to_string(
"course/crispy-form.html",
context={"form": form},
request=request)
# }}}
# {{{ grader input
@abstractmethod
def make_grading_form(
self,
page_context, # type: PageContext
page_data, # type: Any
grade_data # type: Any
):
# type: (...) -> forms.Form
page_context: PageContext,
page_data: Any,
grade_data: Any,
) -> StyledForm | None:
"""
:arg grade_data: value returned by
:meth:`update_grade_data_from_grading_form_v2`. May be *None*.
:return:
a :class:`django.forms.Form` instance with *grade_data* prepopulated.
"""
return None
...
@abstractmethod
def post_grading_form(
self,
page_context, # type: PageContext
page_data, # type: Any
grade_data, # type: Any
post_data, # type: Any
files_data # type: Any
):
# type: (...) -> forms.Form
page_context: PageContext,
page_data: Any,
grade_data: Any,
post_data: Any,
files_data: Any,
) -> StyledForm:
"""Return a form with the POST response from *post_data* and *files_data*
filled in.
:return: a
:class:`django.forms.Form` instance with *grade_data* prepopulated.
"""
raise NotImplementedError()
...
def update_grade_data_from_grading_form_v2(
self,
request, # type: http.HttpRequest
page_context, # type: PageContext
page_data, # type: Any
grade_data, # type: Any
grading_form, # type: Any
files_data # type: Any
request: django.http.HttpRequest,
page_context: PageContext,
page_data: Any,
grade_data: Any,
grading_form: Any,
files_data: Any
):
"""Return an updated version of *grade_data*, which is a
JSON-persistable object reflecting data on grading of this response.
......@@ -590,7 +667,7 @@ class PageBase(object):
from warnings import warn
warn(_("%s is using the update_grade_data_from_grading_form "
"compatiblity hook, which "
"compatibility hook, which "
"is deprecated.") % type(self).__name__,
DeprecationWarning)
......@@ -599,42 +676,43 @@ class PageBase(object):
def update_grade_data_from_grading_form(
self,
page_context, # type: PageContext
page_data, # type: Any
grade_data, # type: Any
grading_form, # type: Any
files_data # type: Any
page_context: PageContext,
page_data: Any,
grade_data: Any,
grading_form: Any,
files_data: Any
):
return grade_data
def grading_form_to_html(
self,
request, # type: http.HttpRequest
page_context, # type: PageContext
grading_form, # type: Any
grade_data # type: Any
):
# type: (...) -> Text
request: django.http.HttpRequest,
page_context: PageContext,
grading_form: Any,
grade_data: Any
) -> str:
"""Returns an HTML rendering of *grading_form*."""
# https://django-crispy-forms.readthedocs.io/en/latest/crispy_tag_forms.html#render-a-form-within-python-code
from crispy_forms.utils import render_crispy_form
from django.template import RequestContext
context = RequestContext(request, {})
return render_crispy_form(grading_form, context=context)
from django.template.context_processors import csrf
ctx: dict = {}
ctx.update(csrf(request))
return render_crispy_form(grading_form, context=ctx)
# }}}
# {{{ grading/feedback
@abstractmethod
def grade(
self,
page_context, # type: PageContext
page_data, # type: Any
answer_data, # type: Any
grade_data, # type: Any
):
# type: (...) -> Optional[AnswerFeedback]
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> AnswerFeedback | None:
"""Grade the answer contained in *answer_data*.
:arg answer_data: value returned by :meth:`answer_data`,
......@@ -644,34 +722,46 @@ class PageBase(object):
:return: a :class:`AnswerFeedback` instanstance, or *None* if the
grade is not yet available.
"""
...
raise NotImplementedError()
@abstractmethod
def correct_answer(
self,
page_context, # type: PageContext
page_data, # type: Any
answer_data, # type: Any
grade_data, # type: Any
):
# type: (...) -> Optional[Text]
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> str | None:
"""The correct answer to this page's interaction, formatted as HTML,
or *None*.
"""
return None
def normalized_answer(self, page_context, page_data, answer_data):
@abstractmethod
def normalized_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any
) -> str | None:
"""An HTML-formatted answer to be used for summarization and
display in analytics.
display in analytics. Since this may include user input, it is
expected to be sanitized.
"""
return None
def normalized_bytes_answer(self, page_context, page_data, answer_data):
@abstractmethod
def normalized_bytes_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
) -> tuple[str, bytes] | None:
"""An answer to be used for batch download, given as a batch of bytes
to be stuffed in a zip file.
:returns: a tuple of ``(file_ext, data)`` where *file_ext* is a suggested
file extension (inlcuding the leading period, if applicable).
file extension (including the leading period, if applicable).
May also return *None*.
One use case of this function is to work as input for a plagiarism
......@@ -686,10 +776,80 @@ class PageBase(object):
# {{{ utility base classes
class PageBaseWithoutHumanGrading(PageBase):
def make_grading_form(
self,
page_context: PageContext,
page_data: Any,
grade_data: Any,
) -> StyledForm | None:
return None
def post_grading_form(
self,
page_context: PageContext,
page_data: Any,
grade_data: Any,
post_data: Any,
files_data: Any,
) -> StyledForm:
raise NotImplementedError()
class PageBaseUngraded(PageBaseWithoutHumanGrading):
def is_answer_gradable(self) -> bool:
return False
def max_points(self, page_data: Any) -> float:
"""
:return: a :class:`int` or :class:`float` indicating how many points
are achievable on this page.
"""
raise NotImplementedError()
def grade(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> AnswerFeedback | None:
return None
def correct_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> str | None:
return None
def normalized_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any
) -> str | None:
return None
def normalized_bytes_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
) -> tuple[str, bytes] | None:
return None
class PageBaseWithTitle(PageBase):
def __init__(self, vctx, location, page_desc):
super(PageBaseWithTitle, self).__init__(vctx, location, page_desc)
def __init__(
self,
vctx: ValidationContext | None,
location: str,
page_desc: Any
) -> None:
super().__init__(vctx, location, page_desc)
title = None
try:
......@@ -703,7 +863,7 @@ class PageBaseWithTitle(PageBase):
except NotImplementedError:
from warnings import warn
warn(_("PageBaseWithTitle subclass '%s' does not implement "
"markdown_body_for_title()")
"markup_body_for_title()")
% type(self).__name__)
else:
from course.content import extract_title_from_markup
......@@ -716,30 +876,54 @@ class PageBaseWithTitle(PageBase):
_("no title found in body or title attribute"))
% (location))
from django.utils.html import strip_tags
from markdown import markdown
title = strip_tags(markdown(title))
if not title and vctx is not None:
vctx.add_warning(location, gettext("the rendered title is an empty string"))
self._title = title
def allowed_attrs(self):
return super(PageBaseWithTitle, self).allowed_attrs() + (
("title", str),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("title", str))
def markup_body_for_title(self):
raise NotImplementedError()
@abstractmethod
def markup_body_for_title(self) -> str:
...
def title(self, page_context, page_data):
def title(self, page_context: PageContext, page_data: Any):
return self._title
class PageBaseWithValue(PageBase):
def allowed_attrs(self):
return super(PageBaseWithValue, self).allowed_attrs() + (
("value", (int, float)),
)
def __init__(self, vctx, location, page_desc):
super().__init__(vctx, location, page_desc)
if vctx is not None:
if hasattr(page_desc, "value") and self.is_optional_page:
raise ValidationError(
string_concat(
location,
_("Attribute 'value' should be removed when "
"'is_optional_page' is True.")))
if hasattr(page_desc, "value") and page_desc.value < 0:
raise ValidationError(
string_concat(
location,
_("Attribute 'value' expects a non-negative value, "
"got %s instead") % str(page_desc.value)))
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("value", (int, float)))
def expects_answer(self):
def expects_answer(self) -> bool:
return True
def max_points(self, page_data):
def max_points(self, page_data) -> float:
if self.is_optional_page:
return 0
return getattr(self.page_desc, "value", 1)
......@@ -748,7 +932,7 @@ class PageBaseWithValue(PageBase):
# {{{ human text feedback page base
def create_default_point_scale(total_points):
def create_default_point_scale(total_points: float | int) -> Sequence[float]:
"""
Return a scale that has sensible intervals for assigning points.
"""
......@@ -761,7 +945,7 @@ def create_default_point_scale(total_points):
else:
incr = 5
def as_int(x):
def as_int(x: float | int) -> float | int:
return int(x) if int(x) == x else x
points = [as_int(idx*incr) for idx in range(int(total_points/incr))]
......@@ -771,41 +955,50 @@ def create_default_point_scale(total_points):
class TextInputWithButtons(forms.TextInput):
def __init__(self, button_values, *args, **kwargs):
def __init__(self,
button_values: Sequence[float | int],
*args: Any,
**kwargs: Any
) -> None:
self.button_values = button_values
super(TextInputWithButtons, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def render(self, name, value, attrs=None):
html = super(TextInputWithButtons, self).render(name, value, attrs)
from django.utils.html import format_html, mark_safe, escapejs
def render(self, name, value, attrs=None, renderer=None):
html = super().render(name, value, attrs,
renderer)
from django.utils.html import escapejs, format_html, mark_safe
id = attrs["id"]
def make_feedback_func(feedback):
return "'$(\"#{id}\").val(\"{feedback}\")'".format(
id=id, feedback=escapejs(feedback))
return f"'$(\"#{id}\").val(\"{escapejs(feedback)}\")'"
buttons = []
# Add buttons.
for button_value in self.button_values:
buttons.append(format_html(
"<button class='btn btn-xs btn-default' "
"<button class='btn btn-sm btn-outline-secondary me-1' "
"type='button' onclick={func}>{val}</button>",
func=mark_safe(make_feedback_func(button_value)),
val=button_value))
# Add a clear button.
buttons.append(format_html(
"<button class='btn btn-xs btn-default' "
"<button class='btn btn-sm btn-outline-danger' "
"type='button' onclick={func}>Clear</button>",
func=mark_safe(make_feedback_func(""))))
return format_html("{html}<p>{button_row}</p>",
return format_html("{html}<div class='lh-lg'>{button_row}</div>",
html=html, button_row=mark_safe("".join(buttons)))
class HumanTextFeedbackForm(StyledForm):
def __init__(self, point_value, *args, **kwargs):
super(HumanTextFeedbackForm, self).__init__(*args, **kwargs)
def __init__(self,
point_value: float | None,
*args: Any,
editor_interaction_mode: str | None = None,
rubric: str | None = None
) -> None:
super().__init__(*args)
self.point_value = point_value
......@@ -820,7 +1013,7 @@ class HumanTextFeedbackForm(StyledForm):
[0, 10, 20, 25, 30, 40, 50, 60, 70, 75, 80, 90, 100]),
label=_("Grade percent"))
if point_value is not None:
if point_value is not None and point_value != 0:
self.fields["grade_points"] = forms.FloatField(
min_value=0,
max_value=MAX_EXTRA_CREDIT_FACTOR*point_value,
......@@ -834,15 +1027,35 @@ class HumanTextFeedbackForm(StyledForm):
create_default_point_scale(point_value)),
label=_("Grade points"))
from course.utils import JsLiteral, get_codemirror_widget
cm_widget, cm_help_text = get_codemirror_widget(
language_mode="markdown",
interaction_mode=editor_interaction_mode,
additional_keys={
"Ctrl-;":
JsLiteral("rlUtils.goToNextPointsField"),
"Shift-Ctrl-;":
JsLiteral("rlUtils.goToPreviousPointsField"),
})
self.fields["feedback_text"] = forms.CharField(
widget=forms.Textarea(),
widget=cm_widget,
required=False,
help_text=mark_safe_lazy(
initial=rubric,
help_text=mark_safe(
_("Feedback to be shown to student, using "
"<a href='http://documen.tician.de/"
"relate/content.html#relate-markup'>"
"RELATE-flavored Markdown</a>")),
label=_("Feedback text"))
"RELATE-flavored Markdown</a>. "
"See RELATE documentation for automatic computation of point "
"count from <tt>[pts:N/N]</tt> and <tt>[pts:N]</tt>. "
"Use Ctrl-Semicolon/Ctrl-Shift-Semicolon "
"to move between <tt>[pts:]</tt> fields. ")
+ cm_help_text),
label=_("Feedback text (Ctrl+Shift+F)"))
self.fields["rubric_text"] = forms.CharField(
widget=forms.HiddenInput(attrs={"value": rubric}),
initial=rubric,
required=False)
self.fields["notify"] = forms.BooleanField(
initial=False, required=False,
help_text=_("Checking this box and submitting the form "
......@@ -892,68 +1105,96 @@ class HumanTextFeedbackForm(StyledForm):
def cleaned_percent(self):
if self.point_value is None:
return self.cleaned_data["grade_percent"]
elif (self.cleaned_data["grade_percent"] is not None
and self.cleaned_data["grade_points"] is not None):
points_percent = 100*self.cleaned_data["grade_points"]/self.point_value
direct_percent = self.cleaned_data["grade_percent"]
else:
candidate_percentages = []
if abs(points_percent - direct_percent) > 0.1:
raise RuntimeError(_("Grade (percent) and Grade (points) "
"disagree"))
if self.cleaned_data["grade_percent"] is not None:
candidate_percentages.append(self.cleaned_data["grade_percent"])
return max(points_percent, direct_percent)
elif self.cleaned_data["grade_percent"] is not None:
return self.cleaned_data["grade_percent"]
if self.cleaned_data.get("grade_points") is not None:
candidate_percentages.append(
100 * self.cleaned_data["grade_points"] / self.point_value)
elif self.cleaned_data["grade_points"] is not None:
if self.point_value:
return 100*self.cleaned_data["grade_points"]/self.point_value
else:
if not candidate_percentages:
return None
else:
return None
if len(candidate_percentages) == 2:
if abs(candidate_percentages[1] - candidate_percentages[0]) > 0.1:
raise RuntimeError(_("Grade (percent) and Grade (points) "
"disagree"))
return max(candidate_percentages)
class PageBaseWithHumanTextFeedback(PageBase):
"""
.. automethod:: human_feedback_point_value
Supports automatic computation of point values from textual feedback.
See :ref:`points-from-feedback`.
"""
grade_data_attrs = ["released", "grade_percent", "feedback_text", "notes"]
def required_attrs(self):
return super(PageBaseWithHumanTextFeedback, self).required_attrs() + (
("rubric", "markup"),
)
def required_attrs(self) -> AttrSpec:
return (*super().required_attrs(), ("rubric", "markup"))
def human_feedback_point_value(self, page_context, page_data):
"""Subclasses can override this to make the point value of the human feedback known,
which will enable grade entry in points.
def human_feedback_point_value(self,
page_context: PageContext,
page_data: Any
) -> float | None:
"""Subclasses can override this to make the point value of the human
feedback known, which will enable grade entry in points.
"""
return None
def make_grading_form(self, page_context, page_data, grade_data):
def make_grading_form(
self,
page_context: PageContext,
page_data: Any,
grade_data: Any,
) -> StyledForm | None:
human_feedback_point_value = self.human_feedback_point_value(
page_context, page_data)
editor_interaction_mode = get_editor_interaction_mode(page_context)
if grade_data is not None:
form_data = {}
for k in self.grade_data_attrs:
form_data[k] = grade_data[k]
return HumanTextFeedbackForm(human_feedback_point_value, form_data)
return HumanTextFeedbackForm(human_feedback_point_value, form_data,
editor_interaction_mode=editor_interaction_mode,
rubric=self.page_desc.rubric)
else:
return HumanTextFeedbackForm(human_feedback_point_value)
return HumanTextFeedbackForm(human_feedback_point_value,
editor_interaction_mode=editor_interaction_mode,
rubric=self.page_desc.rubric)
def post_grading_form(self, page_context, page_data, grade_data,
post_data, files_data):
def post_grading_form(
self,
page_context: PageContext,
page_data: Any,
grade_data: Any,
post_data: Any,
files_data: Any,
) -> StyledForm:
human_feedback_point_value = self.human_feedback_point_value(
page_context, page_data)
editor_interaction_mode = get_editor_interaction_mode(page_context)
return HumanTextFeedbackForm(
human_feedback_point_value, post_data, files_data)
def update_grade_data_from_grading_form_v2(self, request, page_context,
page_data, grade_data, grading_form, files_data):
human_feedback_point_value, post_data, files_data,
editor_interaction_mode=editor_interaction_mode,
rubric=self.page_desc.rubric)
def update_grade_data_from_grading_form_v2(
self,
request: django.http.HttpRequest,
page_context: PageContext,
page_data: Any,
grade_data: Any,
grading_form: Any,
files_data: Any
):
if grade_data is None:
grade_data = {}
for k in self.grade_data_attrs:
......@@ -963,23 +1204,32 @@ class PageBaseWithHumanTextFeedback(PageBase):
grade_data[k] = grading_form.cleaned_data[k]
if grading_form.cleaned_data["notify"] and page_context.flow_session:
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
message = render_to_string("course/grade-notify.txt", {
from course.utils import LanguageOverride
with LanguageOverride(page_context.course):
from course.utils import will_use_masked_profile_for_email
from relate.utils import render_email_template
assert request.user.is_authenticated
assert page_context.flow_session.participation is not None
staff_email = [page_context.course.notify_email, request.user.email]
message = render_email_template("course/grade-notify.txt", {
"page_title": self.title(page_context, page_data),
"course": page_context.course,
"participation": page_context.flow_session.participation,
"feedback_text": grade_data["feedback_text"],
"flow_session": page_context.flow_session,
"review_uri": page_context.page_uri,
"use_masked_profile":
will_use_masked_profile_for_email(staff_email)
})
from django.core.mail import EmailMessage
msg = EmailMessage(
string_concat("[%(identifier)s:%(flow_id)s] ",
_("New notification"))
% {'identifier': page_context.course.identifier,
'flow_id': page_context.flow_session.flow_id},
% {"identifier": page_context.course.identifier,
"flow_id": page_context.flow_session.flow_id},
message,
getattr(settings, "GRADER_FEEDBACK_EMAIL_FROM",
page_context.course.get_from_email()),
......@@ -995,28 +1245,44 @@ class PageBaseWithHumanTextFeedback(PageBase):
msg.send()
if (grading_form.cleaned_data["notes"]
and grading_form.cleaned_data["notify_instructor"]
and page_context.flow_session):
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
from django.urls import reverse
message = render_to_string("course/grade-internal-notes-notify.txt", {
"page_title": self.title(page_context, page_data),
"course": page_context.course,
"participation": page_context.flow_session.participation,
"notes_text": grade_data["notes"],
"flow_session": page_context.flow_session,
"review_uri": page_context.page_uri,
"sender": request.user
and grading_form.cleaned_data["notify_instructor"]
and page_context.flow_session):
from course.utils import LanguageOverride
with LanguageOverride(page_context.course):
from course.utils import will_use_masked_profile_for_email
from relate.utils import render_email_template
assert request.user.is_authenticated
assert page_context.flow_session.user is not None
staff_email = [page_context.course.notify_email, request.user.email]
use_masked_profile = will_use_masked_profile_for_email(staff_email)
if use_masked_profile:
username = (
page_context.flow_session.user.get_masked_profile())
else:
username = (
page_context.flow_session.user.get_email_appellation())
message = render_email_template(
"course/grade-internal-notes-notify.txt",
{
"page_title": self.title(page_context, page_data),
"username": username,
"course": page_context.course,
"participation": page_context.flow_session.participation,
"notes_text": grade_data["notes"],
"flow_session": page_context.flow_session,
"review_uri": page_context.page_uri,
"sender": request.user,
})
from django.core.mail import EmailMessage
msg = EmailMessage(
string_concat("[%(identifier)s:%(flow_id)s] ",
_("Grading notes from %(ta)s"))
% {'identifier': page_context.course.identifier,
'flow_id': page_context.flow_session.flow_id,
'ta': request.user.get_full_name()
% {"identifier": page_context.course.identifier,
"flow_id": page_context.flow_session.flow_id,
"ta": request.user.get_full_name()
},
message,
getattr(settings, "GRADER_FEEDBACK_EMAIL_FROM",
......@@ -1044,20 +1310,19 @@ class PageBaseWithHumanTextFeedback(PageBase):
def grade(
self,
page_context, # type: PageContext
page_data, # type: Any
answer_data, # type: Any
grade_data, # type: Any
):
# type: (...) -> Optional[AnswerFeedback]
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> AnswerFeedback | None:
"""This method is appropriate if the grade consists *only* of the
feedback provided by humans. If more complicated/combined feedback
is desired, a subclass would likely override this.
"""
if answer_data is None:
if answer_data is None and grade_data is None:
return AnswerFeedback(correctness=0,
feedback=ugettext("No answer provided."))
feedback=gettext_noop("No answer provided."))
if grade_data is None:
return None
......@@ -1069,7 +1334,7 @@ class PageBaseWithHumanTextFeedback(PageBase):
or grade_data["feedback_text"]):
if grade_data["grade_percent"] is not None:
correctness = grade_data["grade_percent"]/100
feedback_text = "<p>%s</p>" % get_auto_feedback(correctness)
feedback_text = f"<p>{get_auto_feedback(correctness)}</p>"
else:
correctness = None
......@@ -1093,12 +1358,16 @@ class PageBaseWithHumanTextFeedback(PageBase):
class PageBaseWithCorrectAnswer(PageBase):
def allowed_attrs(self):
return super(PageBaseWithCorrectAnswer, self).allowed_attrs() + (
("correct_answer", "markup"),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("correct_answer", "markup"))
def correct_answer(self, page_context, page_data, answer_data, grade_data):
def correct_answer(
self,
page_context: PageContext,
page_data: Any,
answer_data: Any,
grade_data: Any,
) -> str | None:
if hasattr(self.page_desc, "correct_answer"):
return markup_to_html(page_context, self.page_desc.correct_answer)
else:
......@@ -1107,8 +1376,11 @@ class PageBaseWithCorrectAnswer(PageBase):
# }}}
def get_editor_interaction_mode(page_context):
if (page_context.flow_session is not None
def get_editor_interaction_mode(page_context: PageContext) -> str:
if (page_context.request is not None
and not page_context.request.user.is_anonymous):
return page_context.request.user.editor_mode
elif (page_context.flow_session is not None
and page_context.flow_session.participation is not None):
return page_context.flow_session.participation.user.editor_mode
else:
......
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,23 +23,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
from six.moves import range
import django.forms as forms
from django.utils.safestring import mark_safe
from django.utils.translation import (
ugettext_lazy as _, ugettext, string_concat)
from django.utils.translation import gettext, gettext_lazy as _
from relate.utils import StyledForm
from course.page.base import (
AnswerFeedback, PageBaseWithTitle, PageBaseWithValue, markup_to_html)
from course.content import remove_prefix
from course.validation import validate_markup, ValidationError
AnswerFeedback,
PageBaseUngraded,
PageBaseWithoutHumanGrading,
PageBaseWithTitle,
PageBaseWithValue,
markup_to_html,
)
from course.validation import AttrSpec, ValidationError, validate_markup
from relate.utils import StyledForm, string_concat
class ChoiceAnswerForm(StyledForm):
def __init__(self, field, *args, **kwargs):
super(ChoiceAnswerForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["choice"] = field
# Translators: "choice" in Choice Answer Form in a single-choice question.
......@@ -49,7 +50,7 @@ class ChoiceAnswerForm(StyledForm):
class MultipleChoiceAnswerForm(StyledForm):
def __init__(self, field, *args, **kwargs):
super(MultipleChoiceAnswerForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.fields["choice"] = field
......@@ -58,109 +59,172 @@ class MultipleChoiceAnswerForm(StyledForm):
self.fields["choice"].label = _("Select all that apply:")
def markup_to_html_plain(page_context, s):
s = markup_to_html(page_context, s)
if s.startswith("<p>") and s.endswith("</p>"):
s = s[3:-4]
return s
# {{{ choice data model
class ChoiceModes:
INCORRECT = "incorrect"
CORRECT = "correct"
DISREGARD = "disregard"
ALWAYS_CORRECT = "always_correct"
# {{{ choice question base
values = [INCORRECT, CORRECT, DISREGARD, ALWAYS_CORRECT]
class ChoiceQuestionBase(PageBaseWithTitle, PageBaseWithValue):
class ChoiceInfo:
CORRECT_TAG = "~CORRECT~"
DISREGARD_TAG = "~DISREGARD~"
ALWAYS_CORRECT_TAG = "~ALWAYS_CORRECT~"
def __init__(self, mode, text):
assert mode in ChoiceModes.values
self.mode = mode
self.text = text
@classmethod
def parse_from_yaml(cls, vctx, location, node):
# could be a number or a bool due to sloppy YAML
try:
node = str(node)
except Exception:
raise ValidationError(
_("%(location)s: unable to convert to string")
% {"location": location})
tag_mode_dict = {
cls.CORRECT_TAG: ChoiceModes.CORRECT,
cls.DISREGARD_TAG: ChoiceModes.DISREGARD,
cls.ALWAYS_CORRECT_TAG: ChoiceModes.ALWAYS_CORRECT
}
s = node
item_mode = [None]
def find_tag_by_mode(mode):
for k, v in tag_mode_dict.items(): # pragma: no branch
if v == mode:
return k
def mode_from_prefix(s):
for prefix in tag_mode_dict.keys():
if s.startswith(prefix):
s = s[len(prefix):].strip()
if item_mode[0] is not None:
raise ValidationError(
_("%(location)s: more than one choice modes "
"set: '%(modes)s'")
% {"location": location,
"modes":
"".join([find_tag_by_mode(item_mode[0]),
prefix])
})
item_mode[0] = tag_mode_dict[prefix]
s = mode_from_prefix(s)
return s
s = mode_from_prefix(s)
if item_mode[0] is None:
item_mode[0] = ChoiceModes.INCORRECT
if vctx is not None:
validate_markup(vctx, location, s)
return ChoiceInfo(item_mode[0], s)
def to_json(self):
return {"mode": self.mode, "text": self.text}
# }}}
# {{{ choice question base
class ChoiceQuestionBase(PageBaseWithTitle, PageBaseWithValue):
@classmethod
def process_choice_string(cls, page_context, s):
if not isinstance(s, str):
s = str(s)
s = remove_prefix(cls.CORRECT_TAG, s)
s = remove_prefix(cls.DISREGARD_TAG, s)
s = markup_to_html_plain(page_context, s)
s = markup_to_html(page_context, s)
# allow HTML in option
s = mark_safe(s)
return s
def __init__(self, vctx, location, page_desc):
super(ChoiceQuestionBase, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
self.correct_choice_count = 0
self.disregard_choice_count = 0
for choice_idx, choice in enumerate(page_desc.choices):
try:
choice = str(choice)
except:
raise ValidationError(
string_concat(
"%(location)s, ",
_("choice %(idx)d: unable to convert to string")
)
% {'location': location, 'idx': choice_idx+1})
self.always_correct_choice_count = 0
if choice.startswith(self.CORRECT_TAG):
self.choices = []
for choice_idx, choice_desc in enumerate(page_desc.choices):
choice = ChoiceInfo.parse_from_yaml(
vctx,
_("%(location)s, choice %(idx)d") %
{"location": location,
"idx": choice_idx+1},
choice_desc)
self.choices.append(choice)
if choice.mode == ChoiceModes.CORRECT:
self.correct_choice_count += 1
if choice.startswith(self.DISREGARD_TAG):
if choice.mode == ChoiceModes.DISREGARD:
self.disregard_choice_count += 1
if vctx is not None:
validate_markup(vctx, location,
remove_prefix(self.DISREGARD_TAG,
remove_prefix(self.CORRECT_TAG,
choice)))
if choice.mode == ChoiceModes.ALWAYS_CORRECT:
self.always_correct_choice_count += 1
def required_attrs(self):
return super(ChoiceQuestionBase, self).required_attrs() + (
("prompt", "markup"),
("choices", list),
)
def required_attrs(self) -> AttrSpec:
return (*super().required_attrs(), ("prompt", "markup"), ("choices", list))
def allowed_attrs(self):
return super(ChoiceQuestionBase, self).allowed_attrs() + (
("shuffle", bool),
)
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("shuffle", bool))
def markup_body_for_title(self):
def markup_body_for_title(self) -> str:
return self.page_desc.prompt
def body(self, page_context, page_data):
def body(self, page_context, page_data) -> str:
return markup_to_html(page_context, self.page_desc.prompt)
def initialize_page_data(self, page_context):
import random
perm = list(range(len(self.page_desc.choices)))
perm = list(range(len(self.choices)))
if getattr(self.page_desc, "shuffle", False):
random.shuffle(perm)
return {"permutation": perm}
def unpermuted_indices_with_tag(self, tag):
result = []
for i, choice_text in enumerate(self.page_desc.choices):
if str(choice_text).startswith(tag):
result.append(i)
return result
def unpermuted_correct_indices(self):
return self.unpermuted_indices_with_tag(self.CORRECT_TAG)
def unpermuted_disregard_indices(self):
return self.unpermuted_indices_with_tag(self.DISREGARD_TAG)
def make_form(self, page_context, page_data,
answer_data, page_behavior):
def check_page_data(self, page_data):
if (
"permutation" not in page_data
or (set(page_data["permutation"])
!= set(range(len(self.page_desc.choices))))):
!= set(range(len(self.choices))))):
from course.page import InvalidPageData
raise InvalidPageData(ugettext(
raise InvalidPageData(gettext(
"existing choice permutation not "
"suitable for number of choices in question"))
def unpermuted_indices_with_mode(self, mode):
return [i for i, choice in enumerate(self.choices)
if choice.mode == mode]
def unpermuted_correct_indices(self):
return self.unpermuted_indices_with_mode(ChoiceModes.CORRECT)
def unpermuted_disregard_indices(self):
return self.unpermuted_indices_with_mode(ChoiceModes.DISREGARD)
def unpermuted_always_correct_indices(self):
return self.unpermuted_indices_with_mode(ChoiceModes.ALWAYS_CORRECT)
def make_form(self, page_context, page_data, answer_data, page_behavior):
self.check_page_data(page_data)
if answer_data is not None:
form_data = {"choice": answer_data["choice"]}
form = self.make_choice_form(
......@@ -181,10 +245,33 @@ class ChoiceQuestionBase(PageBaseWithTitle, PageBaseWithValue):
# {{{ choice question
class ChoiceQuestion(ChoiceQuestionBase):
class ChoiceQuestion(ChoiceQuestionBase, PageBaseWithoutHumanGrading):
"""
A page asking the participant to choose one of multiple answers.
Example:
.. code-block:: yaml
type: ChoiceQuestion
id: fp_accuracy
shuffle: True
prompt: |
# Floating point "machine epsilon"
For a (binary) floating point system of the form
$(s_1.s_2s_3)_2\\cdot 2^{p}$ that has an exponent range from $-128$ to
$127$ and that uses three bits to store the significand $s$, what is the
difference between 1 and the smallest representable number greater than
one?
choices:
- $2^{-3}$
- $2^{-4}$
- $2^{-1}$
- ~CORRECT~ $2^{-2}$
answer_explanation: |
That's just what it is.
.. attribute:: id
|id-page-attr|
......@@ -193,6 +280,10 @@ class ChoiceQuestion(ChoiceQuestionBase):
``ChoiceQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -225,7 +316,7 @@ class ChoiceQuestion(ChoiceQuestionBase):
"""
def __init__(self, vctx, location, page_desc):
super(ChoiceQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
if self.correct_choice_count < 1:
raise ValidationError(
......@@ -234,8 +325,8 @@ class ChoiceQuestion(ChoiceQuestionBase):
_("one or more correct answer(s) "
"expected, %(n_correct)d found"))
% {
'location': location,
'n_correct': self.correct_choice_count})
"location": location,
"n_correct": self.correct_choice_count})
if self.disregard_choice_count:
raise ValidationError(
......@@ -243,12 +334,18 @@ class ChoiceQuestion(ChoiceQuestionBase):
"%(location)s: ",
_("ChoiceQuestion does not allow any choices "
"marked 'disregard'"))
% {'location': location})
% {"location": location})
def allowed_attrs(self):
return super(ChoiceQuestion, self).allowed_attrs() + (
("answer_explanation", "markup"),
)
if self.always_correct_choice_count:
raise ValidationError(
string_concat(
"%(location)s: ",
_("ChoiceQuestion does not allow any choices "
"marked 'always_correct'"))
% {"location": location})
def allowed_attrs(self) -> AttrSpec:
return (*super().allowed_attrs(), ("answer_explanation", "markup"))
def make_choice_form(
self, page_context, page_data, page_behavior, *args, **kwargs):
......@@ -256,7 +353,7 @@ class ChoiceQuestion(ChoiceQuestionBase):
choices = tuple(
(i, self.process_choice_string(
page_context, self.page_desc.choices[src_i]))
page_context, self.choices[src_i].text))
for i, src_i in enumerate(permutation))
form = ChoiceAnswerForm(
......@@ -267,7 +364,7 @@ class ChoiceQuestion(ChoiceQuestionBase):
*args, **kwargs)
if not page_behavior.may_change_answer:
form.fields['choice'].widget.attrs['disabled'] = True
form.fields["choice"].widget.attrs["disabled"] = True
return form
......@@ -277,7 +374,7 @@ class ChoiceQuestion(ChoiceQuestionBase):
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
return AnswerFeedback(correctness=0,
feedback=ugettext("No answer provided."))
feedback=gettext("No answer provided."))
permutation = page_data["permutation"]
choice = answer_data["choice"]
......@@ -291,10 +388,10 @@ class ChoiceQuestion(ChoiceQuestionBase):
def correct_answer(self, page_context, page_data, answer_data, grade_data):
corr_idx = self.unpermuted_correct_indices()[0]
result = (string_concat(_("A correct answer is"), ": '%s'.")
result = (string_concat(_("A correct answer is"), ": %s")
% self.process_choice_string(
page_context,
self.page_desc.choices[corr_idx]).lstrip())
self.choices[corr_idx].text))
if hasattr(self.page_desc, "answer_explanation"):
result += markup_to_html(page_context, self.page_desc.answer_explanation)
......@@ -310,13 +407,31 @@ class ChoiceQuestion(ChoiceQuestionBase):
return self.process_choice_string(
page_context,
self.page_desc.choices[permutation[choice]])
self.choices[permutation[choice]].text)
def normalized_bytes_answer(self, page_context, page_data, answer_data):
self.check_page_data(page_data)
if answer_data is None:
return None
permutation = page_data["permutation"]
unpermuted_choice = permutation[answer_data["choice"]]
import json
return ".json", json.dumps({
"choices": [choice.to_json() for choice in self.choices],
"permutation": permutation,
"unpermuted_choice": unpermuted_choice,
})
# }}}
# {{{ multiple choice question
class MultipleChoiceQuestion(ChoiceQuestionBase):
class MultipleChoiceQuestion(ChoiceQuestionBase, PageBaseWithoutHumanGrading):
"""
A page asking the participant to choose a few of multiple available answers.
......@@ -328,6 +443,10 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
``MultipleChoiceQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
......@@ -350,6 +469,16 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
choices are indicated by the prefix ``~CORRECT~``.
Choices marked with the prefix ``~DISREGARD~`` are
ignored when determining the correctness of an answer.
Choices marked with the prefix ``~ALWAYS_CORRECT~`` are
marked as correct whether they are selected or not. The latter two
exist to ensure fair scoring of a multi-select question in which one
option has turned out to be flawed. The net effect of ``~DISREGARD~``
is to score the question as if that option didn't exist.
But some students may have received points from the broken option,
so ``~DISREGARD~`` would take those points away. Cue lots of
(somewhat justified) complaints from grumpy students.
``~ALWAYS_CORRECT~`` prevents that by grading any answer as
a correct one, therefore never leading to a point decrease.
.. attribute:: shuffle
......@@ -378,7 +507,7 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
"""
def __init__(self, vctx, location, page_desc):
super(MultipleChoiceQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
pd = self.page_desc
......@@ -387,15 +516,14 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
if (
hasattr(pd, "allow_partial_credit")
or
hasattr(pd, "allow_partial_credit_subset_only")):
or hasattr(pd, "allow_partial_credit_subset_only")):
raise ValidationError(
string_concat(
"%(location)s: ",
_("'allow_partial_credit' or "
"'allow_partial_credit_subset_only' may not be specified"
"at the same time as 'credit_mode'"))
% {'location': location})
% {"location": location})
else:
......@@ -408,16 +536,15 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
credit_mode = "proportional"
elif not partial and partial_subset:
credit_mode = "proportional_correct"
elif partial and partial_subset:
else:
assert partial and partial_subset
raise ValidationError(
string_concat(
"%(location)s: ",
_("'allow_partial_credit' and "
"'allow_partial_credit_subset_only' are not allowed to "
"coexist when both attribute are 'True'"))
% {'location': location})
else:
assert False
% {"location": location})
if credit_mode not in [
"exact",
......@@ -427,7 +554,7 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
string_concat(
"%(location)s: ",
_("unrecognized credit_mode '%(credit_mode)s'"))
% {'location': location, "credit_mode": credit_mode})
% {"location": location, "credit_mode": credit_mode})
if vctx is not None and not hasattr(pd, "credit_mode"):
vctx.add_warning(location,
......@@ -439,12 +566,11 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
self.credit_mode = credit_mode
def allowed_attrs(self):
return super(MultipleChoiceQuestion, self).allowed_attrs() + (
("allow_partial_credit", bool),
("allow_partial_credit_subset_only", bool),
("credit_mode", str),
("answer_explanation", "markup"),
)
return (*super().allowed_attrs(),
("allow_partial_credit", bool),
("allow_partial_credit_subset_only", bool),
("credit_mode", str),
("answer_explanation", "markup"))
def make_choice_form(self, page_context, page_data, page_behavior,
*args, **kwargs):
......@@ -452,7 +578,7 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
choices = tuple(
(i, self.process_choice_string(
page_context, self.page_desc.choices[src_i]))
page_context, self.choices[src_i].text))
for i, src_i in enumerate(permutation))
form = MultipleChoiceAnswerForm(
......@@ -464,7 +590,7 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
*args, **kwargs)
if not page_behavior.may_change_answer:
form.fields['choice'].widget.attrs['disabled'] = True
form.fields["choice"].widget.attrs["disabled"] = True
return form
......@@ -474,17 +600,20 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
return AnswerFeedback(correctness=0,
feedback=ugettext("No answer provided."))
feedback=gettext("No answer provided."))
permutation = page_data["permutation"]
choice = answer_data["choice"]
disregard_idx_set = set(self.unpermuted_disregard_indices())
always_correct_idx_set = set(self.unpermuted_always_correct_indices())
unpermed_idx_set = (
set([permutation[idx] for idx in choice]) - disregard_idx_set)
{permutation[idx] for idx in choice} - disregard_idx_set
- always_correct_idx_set)
correct_idx_set = (
set(self.unpermuted_correct_indices()) - disregard_idx_set)
num_choices = len(self.page_desc.choices) - len(disregard_idx_set)
set(self.unpermuted_correct_indices()) - disregard_idx_set
- always_correct_idx_set)
num_choices = len(self.choices) - len(disregard_idx_set)
if self.credit_mode == "exact":
if unpermed_idx_set == correct_idx_set:
......@@ -497,16 +626,21 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
correctness = (
(
num_choices
-
len(unpermed_idx_set
- len(unpermed_idx_set
.symmetric_difference(correct_idx_set)))
/
num_choices)
/ num_choices)
elif self.credit_mode == "proportional_correct":
else:
assert self.credit_mode == "proportional_correct"
correctness = (
len(unpermed_idx_set & correct_idx_set)/len(correct_idx_set))
(
len(unpermed_idx_set & correct_idx_set)
+ len(always_correct_idx_set))
/ (
len(correct_idx_set)
+ len(always_correct_idx_set)))
if not (unpermed_idx_set <= correct_idx_set):
correctness = 0
......@@ -521,8 +655,7 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
"<li>"
+ (self.process_choice_string(
page_context,
self.page_desc.choices[idx])
.lstrip())
self.choices[idx].text))
+ "</li>"
)
answer_html = "<ul>"+"".join(answer_html_list)+"</ul>"
......@@ -530,9 +663,17 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
def correct_answer(self, page_context, page_data, answer_data, grade_data):
corr_idx_list = self.unpermuted_correct_indices()
always_correct_idx_list = self.unpermuted_always_correct_indices()
result = (string_concat(_("The correct answer is"), ": %s")
% self.get_answer_html(page_context, corr_idx_list))
result = (string_concat(_("The correct answer is"), ": %s.")
% self.get_answer_html(page_context, corr_idx_list))
if len(always_correct_idx_list) > 0:
result = (string_concat(result,
string_concat(_("Additional acceptable options are"),
": %s")
% self.get_answer_html(page_context,
always_correct_idx_list)))
if hasattr(self.page_desc, "answer_explanation"):
result += markup_to_html(page_context, self.page_desc.answer_explanation)
......@@ -551,12 +692,29 @@ class MultipleChoiceQuestion(ChoiceQuestionBase):
[permutation[idx] for idx in choice],
unpermute=True)
def normalized_bytes_answer(self, page_context, page_data, answer_data):
self.check_page_data(page_data)
permutation = page_data["permutation"]
if answer_data is None:
return None
else:
unpermuted_choices = [permutation[ch] for ch in answer_data["choice"]]
import json
return ".json", json.dumps({
"choices": [choice.to_json() for choice in self.choices],
"permutation": permutation,
"unpermuted_choices": unpermuted_choices,
})
# }}}
# {{{ survey choice question
class SurveyChoiceQuestion(PageBaseWithTitle):
class SurveyChoiceQuestion(PageBaseWithTitle, PageBaseUngraded):
"""
A page asking the participant to choose one of multiple answers.
......@@ -566,7 +724,11 @@ class SurveyChoiceQuestion(PageBaseWithTitle):
.. attribute:: type
``ChoiceQuestion``
``SurveyChoiceQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
......@@ -596,12 +758,12 @@ class SurveyChoiceQuestion(PageBaseWithTitle):
return s
def __init__(self, vctx, location, page_desc):
super(SurveyChoiceQuestion, self).__init__(vctx, location, page_desc)
super().__init__(vctx, location, page_desc)
for choice_idx, choice in enumerate(page_desc.choices):
try:
choice = str(choice)
except:
except Exception:
raise ValidationError(
string_concat(
"%(location)s, ",
......@@ -613,15 +775,10 @@ class SurveyChoiceQuestion(PageBaseWithTitle):
validate_markup(vctx, location, choice)
def required_attrs(self):
return super(SurveyChoiceQuestion, self).required_attrs() + (
("prompt", "markup"),
("choices", list),
)
return (*super().required_attrs(), ("prompt", "markup"), ("choices", list))
def allowed_attrs(self):
return super(SurveyChoiceQuestion, self).allowed_attrs() + (
("answer_comment", "markup"),
)
return (*super().allowed_attrs(), ("answer_comment", "markup"))
def correct_answer(self, page_context, page_data, answer_data, grade_data):
if hasattr(self.page_desc, "answer_comment"):
......@@ -651,7 +808,7 @@ class SurveyChoiceQuestion(PageBaseWithTitle):
*args, **kwargs)
if not page_behavior.may_change_answer:
form.fields['choice'].widget.attrs['disabled'] = True
form.fields["choice"].widget.attrs["disabled"] = True
return form
......@@ -690,6 +847,17 @@ class SurveyChoiceQuestion(PageBaseWithTitle):
return self.process_choice_string(
page_context,
self.page_desc.choices[choice])
def normalized_bytes_answer(self, page_context, page_data, answer_data):
if answer_data is None:
return None
import json
return ".json", json.dumps({
"choice": self.page_desc.choices,
"0_based_answer": answer_data["choice"],
})
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, print_function
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -24,40 +23,150 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
"""
import six
from course.validation import ValidationError
import django.forms as forms
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.utils.html import escape
from django.utils.translation import ugettext as _, string_concat
from django.utils import translation
from django.conf import settings
from django.utils.translation import gettext as _
from relate.utils import StyledForm
from course.constants import flow_permission
from course.page.base import (
PageBaseWithTitle, markup_to_html, PageBaseWithValue,
PageBaseWithHumanTextFeedback,
AnswerFeedback, get_auto_feedback,
AnswerFeedback,
PageBaseWithHumanTextFeedback,
PageBaseWithoutHumanGrading,
PageBaseWithTitle,
PageBaseWithValue,
get_auto_feedback,
get_editor_interaction_mode,
markup_to_html,
)
from course.validation import AttrSpec, ValidationError
from relate.utils import StyledForm, string_concat
# DEBUGGING SWITCH:
# True for 'spawn containers' (normal operation)
# False for 'just connect to localhost:CODE_QUESTION_CONTAINER_PORT' as runcode'
SPAWN_CONTAINERS = True
# {{{ html sanitization helper
def is_allowed_data_uri(allowed_mimetypes, uri):
import re
m = re.match(r"^data:([-a-z0-9]+/[-a-z0-9]+);base64,", uri)
if not m:
return False
get_editor_interaction_mode)
from course.constants import flow_permission
mimetype = m.group(1)
return mimetype in allowed_mimetypes
# {{{ python code question
def filter_audio_attributes(tag, name, value):
if name in ["controls"]:
return True
else:
return False
def filter_source_attributes(tag, name, value):
if name in ["type"]:
return True
elif name == "src":
if is_allowed_data_uri([
"audio/wav",
], value):
return True
else:
return False
else:
return False
def filter_img_attributes(tag, name, value):
if name in ["alt", "title"]:
return True
elif name == "src":
return is_allowed_data_uri([
"image/png",
"image/jpeg",
], value)
else:
return False
def filter_attributes(tag, name, value):
from bleach.sanitizer import ALLOWED_ATTRIBUTES
allowed_attrs = ALLOWED_ATTRIBUTES.get(tag, [])
result = name in allowed_attrs
if tag == "audio":
result = result or filter_audio_attributes(tag, name, value)
elif tag == "source":
result = result or filter_source_attributes(tag, name, value)
elif tag == "img":
result = result or filter_img_attributes(tag, name, value)
# {{{ prohibit data URLs anywhere not allowed above
# Follows approach suggested in
# https://github.com/mozilla/bleach/issues/348#issuecomment-359484660
from html5lib.filters.sanitizer import attr_val_is_uri
if (None, name) in attr_val_is_uri or (tag, name) in attr_val_is_uri:
from urllib.parse import urlparse
try:
parsed_url = urlparse(value)
except ValueError:
# could not parse URL: tough beans
return False
if parsed_url.scheme == "data" and not result:
return False
# }}}
return result
def sanitize_from_code_html(s):
import bleach
if not isinstance(s, str):
return _("(Non-string in 'HTML' output filtered out)")
return bleach.clean(s,
tags=[*bleach.ALLOWED_TAGS, "audio", "video", "source"],
protocols=[*bleach.ALLOWED_PROTOCOLS, "data"],
attributes=filter_attributes)
# }}}
# {{{ base code question
class PythonCodeForm(StyledForm):
class CodeForm(StyledForm):
# prevents form submission with codemirror's empty textarea
use_required_attribute = False
def __init__(self, read_only, interaction_mode, initial_code, *args, **kwargs):
super(PythonCodeForm, self).__init__(*args, **kwargs)
def __init__(self, read_only, interaction_mode, initial_code,
language_mode, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
from course.utils import get_codemirror_widget
cm_widget, cm_help_text = get_codemirror_widget(
language_mode="python",
language_mode=language_mode,
interaction_mode=interaction_mode,
read_only=read_only)
# Automatically focus the text field once there has
# been some input.
autofocus=(
not read_only
and (data is not None and "answer" in data)))
if read_only:
cm_widget.attrs["readonly"] = None
self.fields["answer"] = forms.CharField(required=True,
initial=initial_code,
......@@ -70,19 +179,20 @@ class PythonCodeForm(StyledForm):
pass
RUNPY_PORT = 9941
CODE_QUESTION_CONTAINER_PORT = 9941
DOCKER_TIMEOUT = 15
class InvalidPingResponse(RuntimeError):
pass
def request_python_run(run_req, run_timeout, image=None):
def request_run(run_req, run_timeout, image=None):
import errno
import http.client as http_client
import json
from six.moves import http_client
import docker
import socket
import errno
from docker.errors import APIError as DockerAPIError
debug = False
......@@ -93,52 +203,60 @@ def request_python_run(run_req, run_timeout, image=None):
def debug_print(s):
pass
docker_timeout = 15
command_path = "/opt/runcode/runcode"
user = "runcode"
# The following is necessary because tests don't arise from a CodeQuestion
# object, so we provide a fallback.
debug_print(f"Image is {image!r}.")
if image is None:
image = settings.RELATE_DOCKER_RUNPY_IMAGE
# DEBUGGING SWITCH: 1 for 'spawn container', 0 for 'static container'
if 1:
if SPAWN_CONTAINERS:
docker_url = getattr(settings, "RELATE_DOCKER_URL",
"unix://var/run/docker.sock")
docker_tls = getattr(settings, "RELATE_DOCKER_TLS_CONFIG",
None)
docker_cnx = docker.Client(
docker_cnx = docker.DockerClient(
base_url=docker_url,
tls=docker_tls,
timeout=docker_timeout,
version="1.19")
timeout=DOCKER_TIMEOUT,
version="1.24")
if image is None:
image = settings.RELATE_DOCKER_RUNPY_IMAGE
dresult = docker_cnx.create_container(
mem_limit = 384*10**6
container = docker_cnx.containers.create(
image=image,
command=[
"/opt/runpy/runpy",
command_path,
"-1"],
host_config={
"Memory": 384*10**6,
"MemorySwap": -1,
"PublishAllPorts": True,
# Do not enable: matplotlib stops working if enabled.
# "ReadonlyRootfs": True,
},
user="runpy")
container_id = dresult["Id"]
mem_limit=mem_limit,
memswap_limit=mem_limit,
publish_all_ports=True,
detach=True,
# Do not enable: matplotlib stops working if enabled.
# read_only=True,
user=user)
else:
container_id = None
container = None
connect_host_ip = 'localhost'
connect_host_ip = "localhost"
try:
# FIXME: Prohibit networking
if container_id is not None:
docker_cnx.start(container_id)
if container is not None:
container.start()
container_props = docker_cnx.api.inspect_container(container.id)
port_infos = (container_props
["NetworkSettings"]["Ports"]
[f"{CODE_QUESTION_CONTAINER_PORT}/tcp"])
if not port_infos:
raise ValueError("got empty list of container ports")
port_info = port_infos[0]
container_props = docker_cnx.inspect_container(container_id)
(port_info,) = (container_props
["NetworkSettings"]["Ports"]["%d/tcp" % RUNPY_PORT])
port_host_ip = port_info.get("HostIp")
if port_host_ip != "0.0.0.0":
......@@ -146,9 +264,9 @@ def request_python_run(run_req, run_timeout, image=None):
port = int(port_info["HostPort"])
else:
port = RUNPY_PORT
port = CODE_QUESTION_CONTAINER_PORT
from time import time, sleep
from time import sleep, time
start_time = time()
# {{{ ping until response received
......@@ -156,22 +274,26 @@ def request_python_run(run_req, run_timeout, image=None):
from traceback import format_exc
def check_timeout():
if time() - start_time < docker_timeout:
sleep(0.1)
# and retry
else:
return {
"result": "uncaught_error",
"message": "Timeout waiting for container.",
"traceback": "".join(format_exc()),
"exec_host": connect_host_ip,
}
if time() - start_time < DOCKER_TIMEOUT:
sleep(0.1)
# and retry
else:
return {
"result": "uncaught_error",
"message": "Timeout waiting for container.",
"traceback": "".join(format_exc()),
"exec_host": connect_host_ip,
}
if not connect_host_ip:
# for compatibility with podman
connect_host_ip = "localhost"
while True:
try:
connection = http_client.HTTPConnection(connect_host_ip, port)
connection.request('GET', '/ping')
connection.request("GET", "/ping")
response = connection.getresponse()
response_data = response.read().decode()
......@@ -186,7 +308,7 @@ def request_python_run(run_req, run_timeout, image=None):
if ct_res is not None:
return ct_res
except socket.error as e:
except OSError as e:
if e.errno in [errno.ECONNRESET, errno.ECONNREFUSED]:
ct_res = check_timeout()
if ct_res is not None:
......@@ -204,7 +326,7 @@ def request_python_run(run_req, run_timeout, image=None):
connection = http_client.HTTPConnection(connect_host_ip, port,
timeout=1 + run_timeout)
headers = {'Content-type': 'application/json'}
headers = {"Content-type": "application/json"}
json_run_req = json.dumps(run_req).encode("utf-8")
......@@ -212,7 +334,7 @@ def request_python_run(run_req, run_timeout, image=None):
start_time = time()
debug_print("BEFPOST")
connection.request('POST', '/run-python', json_run_req, headers)
connection.request("POST", "/run-python", json_run_req, headers)
debug_print("AFTPOST")
http_response = connection.getresponse()
......@@ -224,27 +346,27 @@ def request_python_run(run_req, run_timeout, image=None):
result = json.loads(response_data)
result["feedback"] = (result.get("feedback", [])
+ ["Execution time: %.1f s -- Time limit: %.1f s"
% (end_time - start_time, run_timeout)])
result["feedback"] = ([*result.get("feedback", []),
f"Execution time: {end_time - start_time:.1f} s "
f"-- Time limit: {run_timeout:.1f} s"])
result["exec_host"] = connect_host_ip
return result
except socket.timeout:
except TimeoutError:
return {
"result": "timeout",
"exec_host": connect_host_ip,
}
finally:
if container_id is not None:
debug_print("-----------BEGIN DOCKER LOGS for %s" % container_id)
debug_print(docker_cnx.logs(container_id))
debug_print("-----------END DOCKER LOGS for %s" % container_id)
if container is not None:
debug_print(f"-----------BEGIN DOCKER LOGS for {container.id}")
debug_print(container.logs())
debug_print(f"-----------END DOCKER LOGS for {container.id}")
try:
docker_cnx.remove_container(container_id, force=True)
container.remove(force=True)
except DockerAPIError:
# Oh well. No need to bother the students with this nonsense.
pass
......@@ -254,28 +376,36 @@ def is_nuisance_failure(result):
if result["result"] != "uncaught_error":
return False
if ("traceback" in result
and "BadStatusLine" in result["traceback"]):
if "traceback" in result:
if "BadStatusLine" in result["traceback"]:
# Occasionally, we fail to send a POST to the container, even after
# the inital ping GET succeeded, for (for now) mysterious reasons.
# Just try again.
# Occasionally, we fail to send a POST to the container, even after
# the initial ping GET succeeded, for (for now) mysterious reasons.
# Just try again.
return True
return True
if ("traceback" in result
and "bind: address already in use" in result["traceback"]):
if "bind: address already in use" in result["traceback"]:
# https://github.com/docker/docker/issues/8714
# https://github.com/docker/docker/issues/8714
return True
return True
if ("requests.packages.urllib3.exceptions.NewConnectionError"
in result["traceback"]):
return True
if "http.client.RemoteDisconnected" in result["traceback"]:
return True
if "[Errno 113] No route to host" in result["traceback"]:
return True
return False
def request_python_run_with_retries(run_req, run_timeout, image=None, retry_count=3):
def request_run_with_retries(run_req, run_timeout, image=None, retry_count=3):
while True:
result = request_python_run(run_req, run_timeout, image=image)
result = request_run(run_req, run_timeout, image=image)
if retry_count and is_nuisance_failure(result):
retry_count -= 1
......@@ -284,11 +414,12 @@ def request_python_run_with_retries(run_req, run_timeout, image=None, retry_coun
return result
class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
class CodeQuestion(PageBaseWithTitle, PageBaseWithValue):
"""
An auto-graded question allowing an answer consisting of Python code.
An auto-graded question allowing an answer consisting of code.
All user code as well as all code specified as part of the problem
is in Python 3.
is in the specified language. This class should be treated as an
interface and used only as a superclass.
If you are not including the
:attr:`course.constants.flow_permission.change_answer`
......@@ -310,7 +441,11 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
.. attribute:: type
``PythonCodeQuestion``
``CodeQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
......@@ -337,7 +472,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
.. attribute:: setup_code
Optional.
Python code to prepare the environment for the participants
Language-specific code to prepare the environment for the participant's
answer.
.. attribute:: show_setup_code
......@@ -364,8 +499,10 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
.. attribute:: test_code
Optional.
Symbols that the participant's code is expected to define.
These will be made available to the :attr:`test_code`.
Code that will be run to determine the correctness of a
student-provided solution. Will have access to variables in
:attr:`names_from_user` (which will be *None*) if not provided. Should
never raise an exception.
This may contain the marker "###CORRECT_CODE###", which will
be replaced with the contents of :attr:`correct_code`, with
......@@ -410,39 +547,17 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
based on its :attr:`access_rules` (not the ones of the flow), a warning
is shown. Setting this attribute to True will silence the warning.
The following symbols are available in :attr:`setup_code` and :attr:`test_code`:
* ``GradingComplete``: An exception class that can be raised to indicated
that the grading code has concluded.
* ``feedback``: A class instance with the following interface::
feedback.set_points(0.5) # 0<=points<=1 (usually)
feedback.add_feedback("This was wrong")
# combines the above two and raises GradingComplete
feedback.finish(0, "This was wrong")
feedback.check_numpy_array_sanity(name, num_axes, data)
feedback.check_numpy_array_features(name, ref, data, report_failure=True)
.. attribute:: docker_image
feedback.check_numpy_array_allclose(name, ref, data,
accuracy_critical=True, rtol=1e-5, atol=1e-8,
report_success=True, report_failure=True)
# If report_failure is True, this function will only return
# if *data* passes the tests. It will return *True* in this
# case.
#
# If report_failure is False, this function will always return,
# and the return value will indicate whether *data* passed the
# accuracy/shape/kind checks.
feedback.check_list(name, ref, data, entry_type=None)
feedback.check_scalar(name, ref, data, accuracy_critical=True,
rtol=1e-5, atol=1e-8, report_success=True, report_failure=True)
# returns True if accurate
Optional.
Specific Docker image within which to run code for the participants
answer. This overrides the image set in the `local_settings.py`
configuration. The Docker image should provide two files; these are
supplied in RELATE's standard Python Docker image by `course/page/
code_run_backend_python.py` and `course/page/code_feedback.py`, for
instance. Consult `docker-image-run-py/docker-build.sh` for one
example of a local build. The Docker image should already be loaded
on the system (RELATE does not pull the image automatically).
* ``data_files``: A dictionary mapping file names from :attr:`data_files`
to :class:`bytes` instances with that file's contents.
......@@ -450,8 +565,8 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
* ``user_code``: The user code being tested, as a string.
"""
def __init__(self, vctx, location, page_desc):
super(PythonCodeQuestion, self).__init__(vctx, location, page_desc)
def __init__(self, vctx, location, page_desc, language_mode):
super().__init__(vctx, location, page_desc)
if vctx is not None and hasattr(page_desc, "data_files"):
for data_file in page_desc.data_files:
......@@ -462,8 +577,14 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
from course.content import get_repo_blob
get_repo_blob(vctx.repo, data_file, vctx.commit_sha)
except ObjectDoesNotExist:
raise ValidationError("%s: data file '%s' not found"
% (location, data_file))
raise ValidationError(
string_concat(
"%(location)s: ",
_("data file '%(file)s' not found"))
% {"location": location, "file": data_file})
if hasattr(page_desc, "docker_image"):
self.container_image = page_desc.docker_image
if not getattr(page_desc, "single_submission", False) and vctx is not None:
is_multi_submit = False
......@@ -477,32 +598,33 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
if not is_multi_submit:
vctx.add_warning(location, _("code question does not explicitly "
"allow multiple submission. Either add "
"access_rules/add_permssions/change_answer "
"access_rules/add_permissions/change_answer "
"or add 'single_submission: True' to confirm that you intend "
"for only a single submission to be allowed. "
"While you're at it, consider adding "
"access_rules/add_permssions/see_correctness."))
def required_attrs(self):
return super(PythonCodeQuestion, self).required_attrs() + (
("prompt", "markup"),
("timeout", (int, float)),
)
def allowed_attrs(self):
return super(PythonCodeQuestion, self).allowed_attrs() + (
("setup_code", str),
("show_setup_code", bool),
("names_for_user", list),
("names_from_user", list),
("test_code", str),
("show_test_code", bool),
("correct_code_explanation", "markup"),
("correct_code", str),
("initial_code", str),
("data_files", list),
("single_submission", bool),
)
"access_rules/add_permissions/see_correctness."))
def required_attrs(self) -> AttrSpec:
return (
*super().required_attrs(),
("prompt", "markup"),
("timeout", (int, float)))
def allowed_attrs(self) -> AttrSpec:
return (
*super().allowed_attrs(),
("setup_code", str),
("show_setup_code", bool),
("names_for_user", list),
("names_from_user", list),
("test_code", str),
("show_test_code", bool),
("correct_code_explanation", "markup"),
("correct_code", str),
("initial_code", str),
("docker_image", str),
("data_files", list),
("single_submission", bool))
def _initial_code(self):
result = getattr(self.page_desc, "initial_code", None)
......@@ -534,32 +656,67 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
answer_data, page_behavior):
if answer_data is not None:
answer = {"answer": answer_data["answer"]}
form = PythonCodeForm(
answer = {"answer": self.get_code_from_answer_data(answer_data)}
form = CodeForm(
not page_behavior.may_change_answer,
get_editor_interaction_mode(page_context),
self._initial_code(),
self.language_mode,
answer)
else:
answer = None
form = PythonCodeForm(
form = CodeForm(
not page_behavior.may_change_answer,
get_editor_interaction_mode(page_context),
self._initial_code(),
self.language_mode
)
return form
def process_form_post(
self, page_context, page_data, post_data, files_data, page_behavior):
return PythonCodeForm(
return CodeForm(
not page_behavior.may_change_answer,
get_editor_interaction_mode(page_context),
self._initial_code(),
self.language_mode,
post_data, files_data)
def get_submission_filename_pattern(self, page_context):
username = "anon"
flow_id = "unk_flow"
if page_context.flow_session is not None:
if page_context.flow_session.participation is not None:
username = page_context.flow_session.participation.user.username
if page_context.flow_session.flow_id:
flow_id = page_context.flow_session.flow_id
return (
"submission/"
f"{page_context.course.identifier}/"
"code/"
f"{flow_id}/"
f"{self.page_desc.id}/"
f"{username}"
f"{self.suffix}")
def code_to_answer_data(self, page_context, code):
# Linux sector size is 512. Anything below a half-full
# sector is probably inefficient.
if len(code) <= 256:
return {"answer": code}
from django.core.files.base import ContentFile
saved_name = settings.RELATE_BULK_STORAGE.save(
self.get_submission_filename_pattern(page_context),
ContentFile(code))
return {"storage_filename": saved_name}
def answer_data(self, page_context, page_data, form, files_data):
return {"answer": form.cleaned_data["answer"].strip()}
code = form.cleaned_data["answer"].strip()
return self.code_to_answer_data(page_context, code)
def get_test_code(self):
test_code = getattr(self.page_desc, "test_code", None)
......@@ -570,15 +727,28 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
if correct_code is None:
correct_code = ""
from .code_runpy_backend import substitute_correct_code_into_test_code
from .code_run_backend import substitute_correct_code_into_test_code
return substitute_correct_code_into_test_code(test_code, correct_code)
@staticmethod
def get_code_from_answer_data(answer_data):
if "storage_filename" in answer_data:
bulk_storage = settings.RELATE_BULK_STORAGE
with bulk_storage.open(answer_data["storage_filename"]) as inf:
return inf.read().decode("utf-8")
elif "answer" in answer_data:
return answer_data["answer"]
else:
raise ValueError("could not get submitted data from answer_data JSON")
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
return AnswerFeedback(correctness=0,
feedback=_("No answer provided."))
user_code = answer_data["answer"]
user_code = self.get_code_from_answer_data(answer_data)
# {{{ request run
......@@ -592,8 +762,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
transfer_attr("names_for_user")
transfer_attr("names_from_user")
if hasattr(self.page_desc, "test_code"):
run_req["test_code"] = self.get_test_code()
run_req["test_code"] = self.get_test_code()
if hasattr(self.page_desc, "data_files"):
run_req["data_files"] = {}
......@@ -609,9 +778,10 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
page_context.commit_sha).data).decode()
try:
response_dict = request_python_run_with_retries(run_req,
run_timeout=self.page_desc.timeout)
except:
response_dict = request_run_with_retries(run_req,
run_timeout=self.page_desc.timeout,
image=self.container_image)
except Exception:
from traceback import format_exc
response_dict = {
"result": "uncaught_error",
......@@ -623,6 +793,20 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
feedback_bits = []
correctness = None
if "points" in response_dict:
correctness = response_dict["points"]
try:
feedback_bits.append(
f"<p><b>{_(get_auto_feedback(correctness))}</b></p>")
except Exception as e:
correctness = None
response_dict["result"] = "setup_error"
response_dict["message"] = (
f"{type(e).__name__}: {e!s}"
)
# {{{ send email if the grading code broke
if response_dict["result"] in [
......@@ -631,11 +815,11 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
"setup_error",
"test_compile_error",
"test_error"]:
error_msg_parts = ["RESULT: %s" % response_dict["result"]]
error_msg_parts = ["RESULT: {}".format(response_dict["result"])]
for key, val in sorted(response_dict.items()):
if (key not in ["result", "figures"]
and val
and isinstance(val, six.string_types)):
and isinstance(val, str)):
error_msg_parts.append("-------------------------------------")
error_msg_parts.append(key)
error_msg_parts.append("-------------------------------------")
......@@ -648,18 +832,23 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
error_msg = "\n".join(error_msg_parts)
with translation.override(settings.RELATE_ADMIN_EMAIL_LOCALE):
from django.template.loader import render_to_string
message = render_to_string("course/broken-code-question-email.txt", {
"page_id": self.page_desc.id,
"course": page_context.course,
"error_message": error_msg,
from course.utils import LanguageOverride
from relate.utils import format_datetime_local, local_now
with LanguageOverride(page_context.course):
from relate.utils import render_email_template
message = render_email_template(
"course/broken-code-question-email.txt", {
"site": settings.RELATE_BASE_URL,
"page_id": self.page_desc.id,
"course": page_context.course,
"error_message": error_msg,
"review_uri": page_context.page_uri,
"time": format_datetime_local(local_now())
})
if (
not page_context.in_sandbox
and
not is_nuisance_failure(response_dict)):
and not is_nuisance_failure(response_dict)):
try:
from django.core.mail import EmailMessage
msg = EmailMessage("".join(["[%s:%s] ",
......@@ -680,7 +869,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
except Exception:
from traceback import format_exc
feedback_bits.append(
six.text_type(string_concat(
str(string_concat(
"<p>",
_(
"Both the grading code and the attempt to "
......@@ -702,17 +891,25 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
# }}}
if hasattr(self.page_desc, "correct_code"):
def normalize_code(s):
return (s
.replace(" ", "")
.replace("\r", "")
.replace("\n", "")
.replace("\t", ""))
if (normalize_code(user_code)
== normalize_code(self.page_desc.correct_code)):
feedback_bits.append(
"<p><b>{}</b></p>".format(
_("It looks like you submitted code that is identical to "
"the reference solution. This is not allowed.")))
from relate.utils import dict_to_struct
response = dict_to_struct(response_dict)
bulk_feedback_bits = []
if hasattr(response, "points"):
correctness = response.points
feedback_bits.append(
"<p><b>%s</b></p>"
% get_auto_feedback(correctness))
else:
correctness = None
if response.result == "success":
pass
......@@ -763,16 +960,19 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
correctness = 0
else:
raise RuntimeError("invalid runpy result: %s" % response.result)
raise RuntimeError(f"invalid run result: {response.result}")
if hasattr(response, "feedback") and response.feedback:
def sanitize(s):
import bleach
return bleach.clean(s, tags=["p", "pre"])
feedback_bits.append("".join([
"<p>",
_("Here is some feedback on your code"),
":"
"<ul>%s</ul></p>"]) %
"".join(
"<li>%s</li>" % escape(fb_item)
f"<li>{sanitize(fb_item)}</li>"
for fb_item in response.feedback))
if hasattr(response, "traceback") and response.traceback:
feedback_bits.append("".join([
......@@ -783,9 +983,9 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
if hasattr(response, "exec_host") and response.exec_host != "localhost":
import socket
try:
exec_host_name, dummy, dummy = socket.gethostbyaddr(
exec_host_name, _dummy, _dummy = socket.gethostbyaddr(
response.exec_host)
except socket.error:
except OSError:
exec_host_name = response.exec_host
feedback_bits.append("".join([
......@@ -826,58 +1026,16 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
fig_lines.append("</dl>")
bulk_feedback_bits.extend(fig_lines)
# {{{ html output / santization
# {{{ html output / sanitization
if hasattr(response, "html") and response.html:
def is_allowed_data_uri(allowed_mimetypes, uri):
import re
m = re.match(r"^data:([-a-z0-9]+/[-a-z0-9]+);base64,", uri)
if not m:
return False
mimetype = m.group(1)
return mimetype in allowed_mimetypes
def sanitize(s):
import bleach
def filter_audio_attributes(name, value):
if name in ["controls"]:
return True
else:
return False
def filter_source_attributes(name, value):
if name in ["type"]:
return True
elif name == "src":
return is_allowed_data_uri([
"audio/wav",
], value)
else:
return False
def filter_img_attributes(name, value):
if name in ["alt", "title"]:
return True
elif name == "src":
return is_allowed_data_uri([
"image/png",
"image/jpeg",
], value)
else:
return False
return bleach.clean(s,
tags=bleach.ALLOWED_TAGS + ["audio", "video", "source"],
attributes={
"audio": filter_audio_attributes,
"source": filter_source_attributes,
"img": filter_img_attributes,
})
bulk_feedback_bits.extend(
sanitize(snippet) for snippet in response.html)
if (page_context.course is None
or not page_context.course.trusted_for_markup):
bulk_feedback_bits.extend(
sanitize_from_code_html(snippet)
for snippet in response.html)
else:
bulk_feedback_bits.extend(response.html)
# }}}
......@@ -906,16 +1064,249 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
if answer_data is None:
return None
normalized_answer = answer_data["answer"]
normalized_answer = self.get_code_from_answer_data(answer_data)
from django.utils.html import escape
return "<pre>%s</pre>" % escape(normalized_answer)
return f"<pre>{escape(normalized_answer)}</pre>"
def normalized_bytes_answer(self, page_context, page_data, answer_data):
if answer_data is None:
return None
return (".py", answer_data["answer"].encode("utf-8"))
suffix = self.suffix
return (suffix, self.get_code_from_answer_data(answer_data).encode("utf-8"))
# }}}
# {{{ python code question
class PythonCodeQuestion(CodeQuestion, PageBaseWithoutHumanGrading):
"""
An auto-graded question allowing an answer consisting of Python code.
All user code as well as all code specified as part of the problem
is in Python 3.
Example:
.. code-block:: yaml
type: PythonCodeQuestion
id: addition
access_rules:
add_permissions:
- change_answer
value: 1
timeout: 10
prompt: |
# Adding two numbers in Python
Your code will receive two variables, *a* and *b*. Compute their sum and
assign it to *c*.
setup_code: |
import random
a = random.uniform(-10, 10)
b = random.uniform(-10, 10)
names_for_user: [a, b]
correct_code: |
c = a + b
names_from_user: [c]
test_code: |
if not isinstance(c, float):
feedback.finish(0, "Your computed c is not a float.")
correct_c = a + b
rel_err = abs(correct_c-c)/abs(correct_c)
if rel_err < 1e-7:
feedback.finish(1, "Your computed c was correct.")
else:
feedback.finish(0, "Your computed c was incorrect.")
If you are not including the
:attr:`course.constants.flow_permission.change_answer`
permission for your entire flow, you likely want to
include this snippet in your question definition:
.. code-block:: yaml
access_rules:
add_permissions:
- change_answer
This will allow participants multiple attempts at getting
the right answer.
.. attribute:: id
|id-page-attr|
.. attribute:: type
``PythonCodeQuestion``
.. attribute:: is_optional_page
|is-optional-page-attr|
.. attribute:: access_rules
|access-rules-page-attr|
.. attribute:: title
|title-page-attr|
.. attribute:: value
|value-page-attr|
.. attribute:: prompt
The page's prompt, written in :ref:`markup`.
.. attribute:: timeout
A number, giving the number of seconds for which setup code,
the given answer code, and the test code (combined) will be
allowed to run.
.. attribute:: setup_code
Optional.
Python code to prepare the environment for the participants
answer.
.. attribute:: show_setup_code
Optional. ``True`` or ``False``. If true, the :attr:`setup_code`
will be shown to the participant.
.. attribute:: names_for_user
Optional.
Symbols defined at the end of the :attr:`setup_code` that will be
made available to the participant's code.
A deep copy (using the standard library function :func:`copy.deepcopy`)
of these values is made, to prevent the user from modifying trusted
state of the grading code.
.. attribute:: names_from_user
Optional.
Symbols that the participant's code is expected to define.
These will be made available to the :attr:`test_code`.
.. attribute:: test_code
Optional.
Code that will be run to determine the correctness of a
student-provided solution. Will have access to variables in
:attr:`names_from_user` (which will be *None*) if not provided. Should
never raise an exception.
This may contain the marker "###CORRECT_CODE###", which will
be replaced with the contents of :attr:`correct_code`, with
each line indented to the same depth as where the marker
is found. The line with this marker is only allowed to have
white space and the marker on it.
.. attribute:: show_test_code
Optional. ``True`` or ``False``. If true, the :attr:`test_code`
will be shown to the participant.
.. attribute:: correct_code_explanation
Optional.
Code that is revealed when answers are visible
(see :ref:`flow-permissions`). This is shown before
:attr:`correct_code` as an explanation.
.. attribute:: correct_code
Optional.
Code that is revealed when answers are visible
(see :ref:`flow-permissions`).
.. attribute:: initial_code
Optional.
Code present in the code input field when the participant first starts
working on their solution.
.. attribute:: data_files
Optional.
A list of file names in the :ref:`git-repo` whose contents will be made
available to :attr:`setup_code` and :attr:`test_code` through the
``data_files`` dictionary. (see below)
.. attribute:: single_submission
Optional, a Boolean. If the question does not allow multiple submissions
based on its :attr:`access_rules` (not the ones of the flow), a warning
is shown. Setting this attribute to True will silence the warning.
The following symbols are available in :attr:`setup_code` and :attr:`test_code`:
* ``GradingComplete``: An exception class that can be raised to indicated
that the grading code has concluded.
* ``feedback``: A class instance with the following interface::
feedback.set_points(0.5) # 0<=points<=1 (usually)
feedback.add_feedback("This was wrong")
# combines the above two and raises GradingComplete
feedback.finish(0, "This was wrong")
feedback.check_numpy_array_sanity(name, num_axes, data)
feedback.check_numpy_array_features(name, ref, data, report_failure=True)
feedback.check_numpy_array_allclose(name, ref, data,
accuracy_critical=True, rtol=1e-5, atol=1e-8,
report_success=True, report_failure=True)
# If report_failure is True, this function will only return
# if *data* passes the tests. It will return *True* in this
# case.
#
# If report_failure is False, this function will always return,
# and the return value will indicate whether *data* passed the
# accuracy/shape/kind checks.
feedback.check_list(name, ref, data, entry_type=None)
feedback.check_scalar(name, ref, data, accuracy_critical=True,
rtol=1e-5, atol=1e-8, report_success=True, report_failure=True)
# returns True if accurate
feedback.call_user(f, *args, **kwargs)
# Calls a user-supplied function and prints an appropriate
# feedback message in case of failure.
* ``data_files``: A dictionary mapping file names from :attr:`data_files`
to :class:`bytes` instances with that file's contents.
* ``user_code``: The user code being tested, as a string.
"""
@property
def language_mode(self):
return "python"
@property
def container_image(self):
return settings.RELATE_DOCKER_RUNPY_IMAGE
@property
def suffix(self):
return ".py"
def __init__(self, vctx, location, page_desc, language_mode="python"):
super().__init__(vctx, location, page_desc,
language_mode)
# }}}
......@@ -923,7 +1314,7 @@ class PythonCodeQuestion(PageBaseWithTitle, PageBaseWithValue):
# {{{ python code question with human feedback
class PythonCodeQuestionWithHumanTextFeedback(
PythonCodeQuestion, PageBaseWithHumanTextFeedback):
PageBaseWithHumanTextFeedback, PythonCodeQuestion):
"""
A question allowing an answer consisting of Python code.
This page type allows both automatic grading and grading
......@@ -943,18 +1334,28 @@ class PythonCodeQuestionWithHumanTextFeedback(
This will allow participants multiple attempts at getting
the right answer.
The allowed attributes are the same as those of
:class:`PythonCodeQuestion`, with the following additional,
required attribute:
Besides those defined in :class:`PythonCodeQuestion`, the
following additional, allowed/required attribute are introduced:
Supports automatic computation of point values from textual feedback.
See :ref:`points-from-feedback`.
.. attribute:: human_feedback_value
Required.
Optional (deprecated).
A number. The point value of the feedback component
by the human grader (who will grade on a 0-100 scale,
which is scaled to yield :attr:`human_feedback_value`
at 100).
.. attribute:: human_feedback_percentage
Optional.
A number. The percentage the feedback by the human
grader takes in the overall grade. Noticing that
either this attribute or :attr:`human_feedback_value`
must be included. `
.. attribute:: rubric
Required.
......@@ -963,27 +1364,79 @@ class PythonCodeQuestionWithHumanTextFeedback(
"""
def __init__(self, vctx, location, page_desc):
super(PythonCodeQuestionWithHumanTextFeedback, self).__init__(
super().__init__(
vctx, location, page_desc)
if (vctx is not None
and self.page_desc.human_feedback_value > self.page_desc.value):
raise ValidationError("".join([
"%s: ",
_("human_feedback_value greater than overall "
"value of question")])
% location)
if vctx is not None:
if (
hasattr(self.page_desc, "human_feedback_value")
and hasattr(self.page_desc, "human_feedback_percentage")):
raise ValidationError(
string_concat(
"%(location)s: ",
_("'human_feedback_value' and "
"'human_feedback_percentage' are not "
"allowed to coexist"))
% {"location": location}
)
if not (hasattr(self.page_desc, "human_feedback_value")
or hasattr(self.page_desc, "human_feedback_percentage")):
raise ValidationError(
string_concat(
"%(location)s: ",
_("expecting either 'human_feedback_value' "
"or 'human_feedback_percentage', found neither."))
% {"location": location}
)
if hasattr(self.page_desc, "human_feedback_value"):
vctx.add_warning(
location,
_("Used deprecated 'human_feedback_value' attribute--"
"use 'human_feedback_percentage' instead."))
if self.page_desc.value == 0:
raise ValidationError("".join([
"%s: ",
_("'human_feedback_value' attribute is not allowed "
"if value of question is 0, use "
"'human_feedback_percentage' instead")])
% location)
if self.page_desc.human_feedback_value > self.page_desc.value:
raise ValidationError("".join([
"%s: ",
_("human_feedback_value greater than overall "
"value of question")])
% location)
if hasattr(self.page_desc, "human_feedback_percentage"):
if not (
0 <= self.page_desc.human_feedback_percentage <= 100):
raise ValidationError("".join([
"%s: ",
_("the value of human_feedback_percentage "
"must be between 0 and 100")])
% location)
if hasattr(self.page_desc, "human_feedback_value"):
self.human_feedback_percentage = (
self.page_desc.human_feedback_value * 100 / self.page_desc.value)
else:
self.human_feedback_percentage = (
self.page_desc.human_feedback_percentage)
def required_attrs(self):
return super(
PythonCodeQuestionWithHumanTextFeedback, self).required_attrs() + (
# value is otherwise optional, but we require it here
("value", (int, float)),
("human_feedback_value", (int, float)),
)
return (
*super().required_attrs(),
# value is otherwise optional, but we require it here
("value", (int, float)),
)
def allowed_attrs(self):
return (
*super().allowed_attrs(),
("human_feedback_value", (int, float)),
("human_feedback_percentage", (int, float)))
def human_feedback_point_value(self, page_context, page_data):
return self.page_desc.human_feedback_value
return self.page_desc.value * self.human_feedback_percentage / 100
def grade(self, page_context, page_data, answer_data, grade_data):
if answer_data is None:
......@@ -996,7 +1449,7 @@ class PythonCodeQuestionWithHumanTextFeedback(
code_feedback = PythonCodeQuestion.grade(self, page_context,
page_data, answer_data, grade_data)
human_points = self.page_desc.human_feedback_value
human_points = self.human_feedback_point_value(page_context, page_data)
code_points = self.page_desc.value - human_points
correctness = None
......@@ -1005,31 +1458,36 @@ class PythonCodeQuestionWithHumanTextFeedback(
and code_feedback.correctness is not None
and grade_data is not None
and grade_data["grade_percent"] is not None):
correctness = (
code_feedback.correctness * code_points
code_feedback_percentage = 100 - self.human_feedback_percentage
percentage = (
code_feedback.correctness * code_feedback_percentage
+ grade_data["grade_percent"] / 100
* self.page_desc.human_feedback_value
) / self.page_desc.value
percentage = correctness * 100
elif (self.page_desc.human_feedback_value == self.page_desc.value
* self.human_feedback_percentage
)
correctness = percentage / 100
elif (self.human_feedback_percentage == 100
and grade_data is not None
and grade_data["grade_percent"] is not None):
correctness = grade_data["grade_percent"] / 100
percentage = correctness * 100
elif (self.human_feedback_percentage == 0
and code_feedback.correctness is not None):
correctness = code_feedback.correctness
percentage = correctness * 100
human_feedback_percentage = None
human_feedback_text = None
human_feedback_points = None
if grade_data is not None:
if grade_data["feedback_text"] is not None:
assert grade_data["feedback_text"] is not None
if grade_data["feedback_text"].strip():
human_feedback_text = markup_to_html(
page_context, grade_data["feedback_text"])
human_feedback_percentage = grade_data["grade_percent"]
if human_feedback_percentage is not None:
human_feedback_points = (human_feedback_percentage/100.
human_graded_percentage = grade_data["grade_percent"]
if human_graded_percentage is not None:
human_feedback_points = (human_graded_percentage/100.
* human_points)
code_feedback_points = None
......@@ -1056,3 +1514,5 @@ class PythonCodeQuestionWithHumanTextFeedback(
bulk_feedback=code_feedback.bulk_feedback)
# }}}
# vim: foldmethod=marker
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import division, print_function
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -48,11 +47,11 @@ class Feedback:
def check_numpy_array_sanity(self, name, num_axes, data):
import numpy as np
if not isinstance(data, np.ndarray):
self.finish(0, "'%s' is not a numpy array" % name)
self.finish(0, f"'{name}' is not a numpy array")
if isinstance(data, np.matrix):
self.finish(0, "'%s' is a numpy matrix. Do not use those. "
"bit.ly/array-vs-matrix" % name)
self.finish(0, f"'{name}' is a numpy matrix. Do not use those. "
"bit.ly/array-vs-matrix")
if len(data.shape) != num_axes:
self.finish(
......@@ -62,8 +61,8 @@ class Feedback:
if data.dtype.kind not in "fc":
self.finish(
0, "'%s' does not consist of floating point numbers--"
"got: '%s'" % (name, data.dtype))
0, f"'{name}' does not consist of floating point numbers--"
f"got: '{data.dtype}'")
def check_numpy_array_features(self, name, ref, data, report_failure=True):
import numpy as np
......@@ -76,23 +75,21 @@ class Feedback:
return False
if not isinstance(data, np.ndarray):
return bad("'%s' is not a numpy array" % name)
return bad(f"'{name}' is not a numpy array")
if isinstance(data, np.matrix):
return bad("'%s' is a numpy matrix. Do not use those. "
"bit.ly/array-vs-matrix" % name)
return bad(f"'{name}' is a numpy matrix. Do not use those. "
"bit.ly/array-vs-matrix")
if ref.shape != data.shape:
return bad(
"'%s' does not have correct shape--"
"got: '%s', expected: '%s'" % (
name, data.shape, ref.shape))
f"'{name}' does not have correct shape--"
f"got: '{data.shape}', expected: '{ref.shape}'")
if ref.dtype.kind != data.dtype.kind:
return bad(
"'%s' does not have correct data type--"
"got: '%s', expected: '%s'" % (
name, data.dtype.kind, ref.dtype.kind))
f"'{name}' does not have correct data type--"
f"got: '{data.dtype}', expected: '{ref.dtype}'")
return True
......@@ -107,10 +104,10 @@ class Feedback:
if not good:
if report_failure:
self.add_feedback("'%s' is inaccurate" % name)
self.add_feedback(f"'{name}' is inaccurate")
else:
if report_success:
self.add_feedback("'%s' looks good" % name)
self.add_feedback(f"'{name}' looks good")
if accuracy_critical and not good:
self.set_points(0)
......@@ -121,7 +118,7 @@ class Feedback:
def check_list(self, name, ref, data, entry_type=None):
assert isinstance(ref, list)
if not isinstance(data, list):
self.finish(0, "'%s' is not a list" % name)
self.finish(0, f"'{name}' is not a list")
if len(ref) != len(data):
self.finish(0, "'%s' has the wrong length--expected %d, got %d"
......@@ -136,8 +133,15 @@ class Feedback:
rtol=1e-5, atol=1e-8, report_success=True, report_failure=True):
import numpy as np
if not isinstance(data, (float, int, np.number)):
self.finish(0, "'%s' is not a number" % name)
if not isinstance(data, complex | float | int | np.number):
try:
# Check whether data is a sympy number because sympy
# numbers do not follow the typical interface
# See https://github.com/inducer/relate/pull/284
if not data.is_number:
self.finish(0, f"'{name}' is not a number")
except AttributeError:
self.finish(0, f"'{name}' is not a number")
good = False
......@@ -148,13 +152,45 @@ class Feedback:
if not good:
if report_failure:
self.add_feedback("'%s' is inaccurate" % name)
self.add_feedback(f"'{name}' is inaccurate")
else:
if report_success:
self.add_feedback("'%s' looks good" % name)
self.add_feedback(f"'{name}' looks good")
if accuracy_critical and not good:
self.set_points(0)
raise GradingComplete()
return good
def call_user(self, f, *args, **kwargs):
try:
return f(*args, **kwargs)
except Exception:
if callable(f):
try:
callable_name = f.__name__
except Exception as e_name:
callable_name = (
"<unable to retrieve name; encountered "
f"{type(e_name).__name__}: {e_name!s}>")
from traceback import format_exc
self.add_feedback(
"<p>"
"The callable '{}' supplied in your code failed with "
"an exception while it was being called by the grading "
"code:"
"</p>"
"<pre>{}</pre>".format(
callable_name,
"".join(format_exc())))
else:
self.add_feedback(
"<p>"
"Your code was supposed to supply a function or "
"callable, but the variable you supplied was not "
"callable."
"</p>")
self.set_points(0)
raise GradingComplete()
# -*- coding: utf-8 -*-
from __future__ import annotations
from __future__ import absolute_import
__copyright__ = "Copyright (C) 2014 Andreas Kloeckner"
......@@ -26,11 +25,15 @@ THE SOFTWARE.
import sys
import traceback
from typing import Any
try:
from .code_feedback import Feedback, GradingComplete
except SystemError:
from code_feedback import Feedback, GradingComplete # type: ignore
except ImportError:
from code_feedback import Feedback, GradingComplete # type: ignore
__doc__ = """
......@@ -125,7 +128,7 @@ PROTOCOL
# {{{ tools
class Struct(object):
class Struct:
def __init__(self, entries):
for name, val in entries.items():
self.__dict__[name] = val
......@@ -136,37 +139,40 @@ class Struct(object):
# }}}
def substitute_correct_code_into_test_code(test_code, correct_code):
def substitute_correct_code_into_test_code(test_code: str, correct_code: str) -> str:
import re
CORRECT_CODE_TAG = re.compile(r"^(\s*)###CORRECT_CODE###\s*$") # noqa
new_test_code_lines = []
for l in test_code.split("\n"):
match = CORRECT_CODE_TAG.match(l)
for line in test_code.split("\n"):
match = CORRECT_CODE_TAG.match(line)
if match is not None:
prefix = match.group(1)
for cc_l in correct_code.split("\n"):
new_test_code_lines.append(prefix+cc_l)
else:
new_test_code_lines.append(l)
new_test_code_lines.append(line)
return "\n".join(new_test_code_lines)
def package_exception(result, what):
def package_exception(result: dict[str, Any], what: str) -> None:
tp, val, tb = sys.exc_info()
assert tp is not None
result["result"] = what
result["message"] = "%s: %s" % (tp.__name__, str(val))
result["message"] = f"{tp.__name__}: {val!s}"
result["traceback"] = "".join(
traceback.format_exception(tp, val, tb))
def run_code(result, run_req):
# {{{ silence matplotlib font cache warnings
# {{{ silence matplotlib warnings
import warnings
warnings.filterwarnings(
"ignore", message="Matplotlib is building the font cache.*")
import os
os.environ["MPLCONFIGDIR"] = "/tmp"
# }}}
......@@ -175,8 +181,8 @@ def run_code(result, run_req):
if getattr(run_req, "setup_code", None):
try:
setup_code = compile(
run_req.setup_code, "<setup code>", 'exec')
except:
run_req.setup_code, "[setup code]", "exec")
except Exception:
package_exception(result, "setup_compile_error")
return
else:
......@@ -184,16 +190,16 @@ def run_code(result, run_req):
try:
user_code = compile(
run_req.user_code, "<user code>", 'exec')
except:
run_req.user_code, "[user code]", "exec")
except Exception:
package_exception(result, "user_compile_error")
return
if getattr(run_req, "test_code", None):
try:
test_code = compile(
run_req.test_code, "<test code>", 'exec')
except:
run_req.test_code, "[test code]", "exec")
except Exception:
package_exception(result, "test_compile_error")
return
else:
......@@ -222,7 +228,7 @@ def run_code(result, run_req):
feedback = Feedback()
maint_ctx = {
"feedback": feedback,
"user_code": user_code,
"user_code": run_req.user_code,
"data_files": data_files,
"output_html": output_html,
"GradingComplete": GradingComplete,
......@@ -230,8 +236,9 @@ def run_code(result, run_req):
if setup_code is not None:
try:
maint_ctx["_MODULE_SOURCE_CODE"] = run_req.setup_code
exec(setup_code, maint_ctx)
except:
except BaseException:
package_exception(result, "setup_error")
return
......@@ -240,7 +247,7 @@ def run_code(result, run_req):
for name in run_req.names_for_user:
if name not in maint_ctx:
result["result"] = "setup_error"
result["message"] = "Setup code did not define '%s'." % name
result["message"] = f"Setup code did not define '{name}'."
user_ctx[name] = maint_ctx[name]
......@@ -248,17 +255,19 @@ def run_code(result, run_req):
user_ctx = deepcopy(user_ctx)
try:
user_ctx["_MODULE_SOURCE_CODE"] = run_req.user_code
exec(user_code, user_ctx)
except:
except BaseException:
package_exception(result, "user_error")
return
# {{{ export plots
if "matplotlib" in sys.modules:
import matplotlib.pyplot as pt
from io import BytesIO
from base64 import b64encode
from io import BytesIO
import matplotlib.pyplot as pt
format = "png"
mime = "image/png"
......@@ -269,7 +278,7 @@ def run_code(result, run_req):
bio = BytesIO()
try:
pt.savefig(bio, format=format)
except:
except Exception:
pass
else:
figures.append(
......@@ -283,25 +292,21 @@ def run_code(result, run_req):
for name in run_req.names_from_user:
if name not in user_ctx:
feedback.add_feedback(
"Required answer variable '%s' is not defined."
% name)
f"Required answer variable '{name}' is not defined.")
maint_ctx[name] = None
else:
maint_ctx[name] = user_ctx[name]
if test_code is not None:
try:
maint_ctx["_MODULE_SOURCE_CODE"] = run_req.test_code
exec(test_code, maint_ctx)
except GradingComplete:
pass
except:
except BaseException:
package_exception(result, "test_error")
return
if not (feedback.points is None or 0 <= feedback.points <= 1):
raise ValueError("grade point value is invalid: %s"
% feedback.points)
result["points"] = feedback.points
result["feedback"] = feedback.feedback_items
......