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 @@