diff --git a/course/enrollment.py b/course/enrollment.py index 367d53a3a7e5fb22485abff93369dd903fdce571..ae3c7a0bb8f6494dd3f43de3552946bc4627d60f 100644 --- a/course/enrollment.py +++ b/course/enrollment.py @@ -45,6 +45,7 @@ from course.models import ( get_user_status, user_status, Course, Participation, ParticipationPreapproval, + ParticipationTag, participation_role, participation_status, PARTICIPATION_ROLE_CHOICES) @@ -53,6 +54,8 @@ from course.utils import course_view, render_course_page from relate.utils import StyledForm +from pytools.lex import RE as REBase + # {{{ enrollment @@ -314,7 +317,7 @@ def create_preapprovals(pctx): 'n_exist': exist_count, 'n_requested_approved': pending_approved_count }) - return redirect("relate-home") + return redirect("relate-course_page", pctx.course.identifier) else: form = BulkPreapprovalsForm() @@ -327,4 +330,282 @@ def create_preapprovals(pctx): # }}} +# {{{ participation query parsing + +# {{{ lexer data + +_and = intern("and") +_or = intern("or") +_not = intern("not") +_openpar = intern("openpar") +_closepar = intern("closepar") + +_id = intern("id") +_email = intern("email") +_email_contains = intern("email_contains") +_user = intern("user") +_user_contains = intern("user_contains") +_tagged = intern("tagged") +_whitespace = intern("whitespace") + +# }}} + + +class RE(REBase): + def __init__(self, s): + import re + super(RE, self).__init__(s, re.UNICODE) + + +_LEX_TABLE = [ + (_and, RE(r"and\b")), + (_or, RE(r"or\b")), + (_not, RE(r"not\b")), + (_openpar, RE(r"\(")), + (_closepar, RE(r"\)")), + + # TERMINALS + (_id, RE(r"id:([0-9]+)")), + (_email, RE(r"email:(\S+)")), + (_email_contains, RE(r"email-contains:(\S+)")), + (_user, RE(r"username:(\S+)")), + (_user_contains, RE(r"username-contains:(\S+)")), + (_tagged, RE(r"tagged:([-\w]+)")), + + (_whitespace, RE("[ \t]+")), + ] + + +_TERMINALS = ([ + _id, _email, _email_contains, _user, _user_contains, ]) + +# {{{ operator precedence + +_PREC_OR = 10 +_PREC_AND = 20 +_PREC_NOT = 30 + +# }}} + + +# {{{ parser + +def parse_query(course, expr_str): + from django.db.models import Q + + def parse_terminal(pstate): + next_tag = pstate.next_tag() + if next_tag is _id: + result = Q(user__id=int(pstate.next_match_obj().group(1))) + pstate.advance() + return result + + elif next_tag is _email: + result = Q(user__email__iexact=pstate.next_match_obj().group(1)) + pstate.advance() + return result + + elif next_tag is _email_contains: + result = Q(user__email__icontains=pstate.next_match_obj().group(1)) + pstate.advance() + return result + + elif next_tag is _user: + result = Q(user__username__exact=pstate.next_match_obj().group(1)) + pstate.advance() + return result + + elif next_tag is _user_contains: + result = Q(user__username__contains=pstate.next_match_obj().group(1)) + pstate.advance() + return result + + elif next_tag is _tagged: + ptag = ParticipationTag.objects.get_or_create( + course=course, + name=pstate.next_match_obj().group(1)) + + result = Q(tags__pk=ptag.pk) + + pstate.advance() + return result + + else: + pstate.expected("terminal") + + def inner_parse(pstate, min_precedence=0): + pstate.expect_not_end() + + if pstate.is_next(_not): + pstate.advance() + left_query = ~inner_parse(pstate, _PREC_NOT) + elif pstate.is_next(_openpar): + pstate.advance() + left_query = inner_parse(pstate) + pstate.expect(_closepar) + pstate.advance() + else: + left_query = parse_terminal(pstate) + + did_something = True + while did_something: + did_something = False + if pstate.is_at_end(): + return left_query + + next_tag = pstate.next_tag() + + if next_tag is _and and _PREC_AND > min_precedence: + pstate.advance() + left_query = left_query & inner_parse(pstate, _PREC_AND) + did_something = True + elif next_tag is _or and _PREC_OR > min_precedence: + pstate.advance() + left_query = left_query | inner_parse(pstate, _PREC_OR) + did_something = True + elif (next_tag in _TERMINALS + [_not, _openpar] + and _PREC_AND > min_precedence): + left_query = left_query & inner_parse(pstate, _PREC_AND) + did_something = True + + return left_query + + from pytools.lex import LexIterator, lex + pstate = LexIterator( + [(tag, s, idx, matchobj) + for (tag, s, idx, matchobj) in lex(_LEX_TABLE, expr_str, match_objects=True) + if tag is not _whitespace], expr_str) + + if pstate.is_at_end(): + pstate.raise_parse_error("unexpected end of input") + + result = inner_parse(pstate) + if not pstate.is_at_end(): + pstate.raise_parse_error("leftover input after completed parse") + + return result + +# }}} + +# }}} + + +# {{{ participation query + +class ParticipationQueryForm(StyledForm): + queries = forms.CharField( + required=True, + widget=forms.Textarea, + help_text=_( + "Enter queries, one per line. " + "Allowed: " + "and, " + "or, " + "not, " + "id:1234, " + "email:a@b.com, " + "email-contains:abc, " + "username:abc, " + "username-contains:abc, " + "tagged:abc." + ), + label=_("Queries")) + op = forms.ChoiceField( + choices=( + ("apply_tag", _("Apply tag")), + ("remove_tag", _("Remove tag")), + ("drop", _("Drop")), + ), + label=_("Operation"), + required=True) + tag = forms.CharField(label=_("Tag"), + help_text=_("Tag to apply or remove"), + required=False) + + def __init__(self, *args, **kwargs): + super(ParticipationQueryForm, self).__init__(*args, **kwargs) + + self.helper.add_input( + Submit("list", _("List"))) + self.helper.add_input( + Submit("apply", _("Apply operation"))) + + +@login_required +@transaction.atomic +@course_view +def query_participations(pctx): + if pctx.role != participation_role.instructor: + raise PermissionDenied(_("only instructors may do that")) + + request = pctx.request + + result = None + + if request.method == "POST": + form = ParticipationQueryForm(request.POST) + if form.is_valid(): + parsed_query = None + try: + for lineno, q in enumerate(form.cleaned_data["queries"].split("\n")): + if not q.strip(): + continue + + parsed_subquery = parse_query(pctx.course, q) + if parsed_query is None: + parsed_query = parsed_subquery + else: + parsed_query = parsed_query | parsed_subquery + + except RuntimeError as e: + messages.add_message(request, messages.ERROR, + _("Error in line %(lineno)d: %(error)s") + % { + "lineno": lineno+1, + "error": str(e), + }) + + parsed_query = None + + if parsed_query is not None: + result = list(Participation.objects + .filter(course=pctx.course) + .filter(parsed_query) + .order_by("user__username") + .select_related("user") + .prefetch_related("tags")) + + if "apply" in request.POST: + + if form.cleaned_data["op"] == "apply_tag": + ptag, __ = ParticipationTag.objects.get_or_create( + course=pctx.course, name=form.cleaned_data["tag"]) + for p in result: + p.tags.add(ptag) + elif form.cleaned_data["op"] == "remove_tag": + ptag, __ = ParticipationTag.objects.get_or_create( + course=pctx.course, name=form.cleaned_data["tag"]) + for p in result: + p.tags.remove(ptag) + elif form.cleaned_data["op"] == "drop": + for p in result: + p.status = participation_status.dropped + p.save() + else: + raise RuntimeError("unexpected operation") + + messages.add_message(request, messages.INFO, + "Operation successful on %d participations." + % len(result)) + + else: + form = ParticipationQueryForm() + + return render_course_page(pctx, "course/query-participations.html", { + "form": form, + "result": result, + }) + +# }}} + # vim: foldmethod=marker diff --git a/course/grades.py b/course/grades.py index cc37003b55d0a931404af0dfb17d411e1b6aaeac..5bac939cb24e09d8d8b85e0b353439043bc10b26 100644 --- a/course/grades.py +++ b/course/grades.py @@ -137,6 +137,7 @@ def view_participant_grades(pctx, participation_id=None): "grade_participation": grade_participation, "grading_opportunities": grading_opps, "grade_state_change_types": grade_state_change_types, + "is_student_viewing": is_student_viewing, }) # }}} diff --git a/course/migrations/0079_participationtag_shown_to_participant.py b/course/migrations/0079_participationtag_shown_to_participant.py new file mode 100644 index 0000000000000000000000000000000000000000..4175d1090218cd76a976eccfa8cdb7e945cabaf1 --- /dev/null +++ b/course/migrations/0079_participationtag_shown_to_participant.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2016-01-12 01:31 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0078_help_tweak'), + ] + + operations = [ + migrations.AddField( + model_name='participationtag', + name='shown_to_participant', + field=models.BooleanField(default=False, verbose_name='Shown to pariticpant'), + ), + ] diff --git a/course/models.py b/course/models.py index 69c16dfc8a5c8a1d34fee681b07120e839405dfe..e4a4fcdf90f7c35194b0a5f31d777e43bc7c2cbe 100644 --- a/course/models.py +++ b/course/models.py @@ -325,6 +325,8 @@ class ParticipationTag(models.Model): help_text=_("Format is lower-case-with-hyphens. " "Do not use spaces."), verbose_name=_('Name of participation tag')) + shown_to_participant = models.BooleanField(default=False, + verbose_name=_('Shown to pariticpant')) def clean(self): super(ParticipationTag, self).clean() diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html index c846a0ac5f5463891c8e41e2e5fa4a8f60bb261f..35c40a2c451bc79f2663305c9ba9e1847d58d353 100644 --- a/course/templates/course/course-base.html +++ b/course/templates/course/course-base.html @@ -121,6 +121,7 @@