diff --git a/course/calendar.py b/course/calendar.py index b18169a11..da06a25b9 100644 --- a/course/calendar.py +++ b/course/calendar.py @@ -26,33 +26,80 @@ import six from six.moves import range +import datetime +from bootstrap3_datetime.widgets import DateTimePicker +from crispy_forms.layout import ( + Layout, Div, ButtonHolder, Button, Submit, HTML) from django.utils.translation import ( ugettext_lazy as _, pgettext_lazy) +from django.shortcuts import get_object_or_404 from django.contrib.auth.decorators import login_required -from course.utils import course_view, render_course_page from django.core.exceptions import ( - PermissionDenied, ObjectDoesNotExist, ValidationError) + PermissionDenied, ObjectDoesNotExist, ValidationError, SuspiciousOperation) from django.db import transaction -from django.contrib import messages # noqa +from django.contrib import messages +from django.urls import reverse +from django.http import JsonResponse import django.forms as forms -from crispy_forms.layout import Submit - -import datetime -from bootstrap3_datetime.widgets import DateTimePicker +from relate.utils import ( + StyledForm, as_local_time, format_datetime_local, string_concat, StyledModelForm) -from relate.utils import StyledForm, as_local_time, string_concat, StyledModelForm +from course.views import get_now_or_fake_time from course.constants import ( participation_permission as pperm, ) from course.models import Event -from django.shortcuts import get_object_or_404 +from course.utils import course_view, render_course_page + +# {{{ for mypy + +if False: + from typing import Tuple, Text, Optional, Any, Dict, Iterable, List, Union # noqa + from crispy_forms.helper import FormHelper # noqa + from django import http # noqa + from course.utils import CoursePageContext # noqa + +# }}} + + +class ModalStyledFormMixin(object): + ajax_modal_form_template = "modal-form.html" + + @property + def form_title(self): + raise NotImplementedError() + + @property + def modal_id(self): + raise NotImplementedError() + + def get_ajax_form_helper(self): + # type: (...) -> FormHelper + return self.get_form_helper() # type: ignore + + def render_ajax_modal_form_html(self, request, context=None): + # type: (http.HttpRequest, Optional[Dict]) -> Text + + # remove possbily added buttons by non-AJAX form + self.helper.inputs = [] # type: ignore + + from crispy_forms.utils import render_crispy_form + from django.template.context_processors import csrf + helper = self.get_ajax_form_helper() + helper.template = self.ajax_modal_form_template + if context is None: + context = {} + context.update(csrf(request)) + return render_crispy_form(self, helper, context) class ListTextWidget(forms.TextInput): # Widget which allow free text and choices for CharField def __init__(self, data_list, name, *args, **kwargs): + # type: (List[Tuple[Text, Text]], Text, *Any, **Any) -> None + super(ListTextWidget, self).__init__(*args, **kwargs) self._name = name self._list = data_list @@ -69,9 +116,50 @@ def render(self, name, value, attrs=None, renderer=None): return (text_html + data_list) +def get_local_time_weekday_hour_minute(dt): + # type: (datetime.datetime) -> Tuple[datetime.datetime, int, int, int] + + """Takes a timezone-aware datetime and applies the server timezone, and return + the local_time, week_day, hour and minute""" + + local_time = as_local_time(dt) + + # https://docs.djangoproject.com/en/dev/ref/models/querysets/#week-day + # Sunday = 1 + week_day = local_time.weekday() + 2 + if week_day == 8: + week_day = 1 + hour = local_time.hour + minute = local_time.minute + + return local_time, week_day, hour, minute + + +def get_recurring_event_series_time_desc_from_instance(event): + # type: (Event) -> Text + series_time_str = _( + "started at %s" + % format_datetime_local( + as_local_time(event.time), format="D, H:i")) + + if event.end_time: + series_time_str = string_concat( + series_time_str, ", ", + _("ended at %s" + % format_datetime_local(as_local_time(event.end_time), + format="D, H:i"))) + return series_time_str + + # {{{ creation -class RecurringEventForm(StyledForm): +class RecurringEventForm(ModalStyledFormMixin, StyledForm): + form_title = _("Create recurring events") + modal_id = "create-recurring-events-modal" + + # This is to avoid field name conflict + prefix = "recurring" + kind = forms.CharField(required=True, help_text=_("Should be lower_case_with_underscores, no spaces " "allowed."), @@ -107,6 +195,7 @@ class RecurringEventForm(StyledForm): label=pgettext_lazy("Count of recurring events", "Count")) def __init__(self, course_identifier, *args, **kwargs): + # type: (Text, *Any, **Any) -> None super(RecurringEventForm, self).__init__(*args, **kwargs) self.course_identifier = course_identifier @@ -120,6 +209,22 @@ def __init__(self, course_identifier, *args, **kwargs): self.helper.add_input( Submit("submit", _("Create"))) + def get_ajax_form_helper(self): + helper = self.get_form_helper() + self.helper.form_action = reverse( + "relate-create_recurring_events", args=[self.course_identifier]) + + helper.layout = Layout( + Div(*self.fields, css_class="modal-body"), + ButtonHolder( + Submit("submit", _("Create"), + css_class="btn btn-md btn-success"), + Button("cancel", _("Cancel"), + css_class="btn btn-md btn-default", + data_dismiss="modal"), + css_class="modal-footer")) + return helper + class EventAlreadyExists(Exception): pass @@ -169,9 +274,30 @@ def _create_recurring_events_backend(course, time, kind, starting_ordinal, inter ordinal += 1 +@course_view +def get_recurring_events_modal_form(pctx): + # type: (CoursePageContext) -> http.JsonResponse + + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) + + request = pctx.request + if not (request.is_ajax() and request.method == "GET"): + raise PermissionDenied(_("only AJAX GET is allowed")) + + recurring_events_form = RecurringEventForm(pctx.course.identifier) + + return JsonResponse( + {"modal_id": recurring_events_form.modal_id, + "form_html": + recurring_events_form.render_ajax_modal_form_html(pctx.request)}) + + @login_required @course_view def create_recurring_events(pctx): + # type: (CoursePageContext) -> Union[http.HttpResponse, http.JsonResponse] + if not pctx.has_permission(pperm.edit_events): raise PermissionDenied(_("may not edit events")) @@ -179,6 +305,9 @@ def create_recurring_events(pctx): message = None message_level = None + if request.method == "GET" and request.is_ajax(): + raise PermissionDenied(_("may not GET by AJAX")) + if request.method == "POST": form = RecurringEventForm( pctx.course.identifier, request.POST, request.FILES) @@ -232,6 +361,11 @@ def create_recurring_events(pctx): # RecurringEventForm form.add_error( "__all__", "'%s': %s" % (field, error)) + + if request.is_ajax(): + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, + status=400) else: message = ( string_concat( @@ -242,6 +376,23 @@ def create_recurring_events(pctx): "err_str": str(e)}) message_level = messages.ERROR break + + else: + if request.is_ajax(): + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, status=400) + + if request.is_ajax(): + if message_level == messages.ERROR: + # Rendered as a non-field error in AJAX view + return JsonResponse( + {"errors": {"__all__": [message]}, + "form_prefix": form.prefix}, + status=400) + return JsonResponse( + {"message": message, + "message_level": messages.DEFAULT_TAGS[message_level]}) + else: form = RecurringEventForm(pctx.course.identifier) @@ -253,7 +404,13 @@ def create_recurring_events(pctx): }) -class RenumberEventsForm(StyledForm): +class RenumberEventsForm(ModalStyledFormMixin, StyledForm): + form_title = _("Renumber events") + modal_id = "renumber-events-modal" + + # This is to avoid field name conflict + prefix = "renumber" + kind = forms.ChoiceField(required=True, help_text=_("Should be lower_case_with_underscores, no spaces " "allowed."), @@ -270,6 +427,7 @@ class RenumberEventsForm(StyledForm): label=_("Preserve ordinal order")) def __init__(self, course_identifier, *args, **kwargs): + # type: (Text, *Any, **Any) -> None super(RenumberEventsForm, self).__init__(*args, **kwargs) self.course_identifier = course_identifier @@ -282,16 +440,56 @@ def __init__(self, course_identifier, *args, **kwargs): self.helper.add_input( Submit("submit", _("Renumber"))) + def get_ajax_form_helper(self): + helper = self.get_form_helper() + self.helper.form_action = reverse( + "relate-renumber_events", args=[self.course_identifier]) + + helper.layout = Layout( + Div(*self.fields, css_class="modal-body"), + ButtonHolder( + Submit("submit", _("Renumber"), + css_class="btn btn-md btn-success"), + Button("cancel", _("Cancel"), + css_class="btn btn-md btn-default", + data_dismiss="modal"), + css_class="modal-footer")) + return helper + + +@course_view +def get_renumber_events_modal_form(pctx): + # type: (CoursePageContext) -> http.JsonResponse + + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) + + request = pctx.request + if not (request.is_ajax() and request.method == "GET"): + raise PermissionDenied(_("only AJAX GET is allowed")) + + renumber_events_form = RenumberEventsForm(pctx.course.identifier) + + return JsonResponse( + {"modal_id": renumber_events_form.modal_id, + "form_html": + renumber_events_form.render_ajax_modal_form_html(pctx.request)}) + @transaction.atomic @login_required @course_view def renumber_events(pctx): + # type: (CoursePageContext) -> Union[http.HttpResponse, http.JsonResponse] + if not pctx.has_permission(pperm.edit_events): raise PermissionDenied(_("may not edit events")) request = pctx.request + if request.method == "GET" and request.is_ajax(): + raise PermissionDenied(_("may not GET by AJAX")) + message = None message_level = None @@ -339,6 +537,15 @@ def renumber_events(pctx): message = _("Events renumbered.") message_level = messages.SUCCESS + if request.is_ajax(): + return JsonResponse( + {"message": message, + "message_level": messages.DEFAULT_TAGS[message_level]}) + + else: + if request.is_ajax(): + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, status=400) else: form = RenumberEventsForm(pctx.course.identifier) @@ -356,6 +563,7 @@ def renumber_events(pctx): class EventInfo(object): def __init__(self, id, human_title, start_time, end_time, description): + # type: (int, Text, datetime.datetime, datetime.datetime, Text) -> None self.id = id self.human_title = human_title self.start_time = start_time @@ -364,15 +572,40 @@ def __init__(self, id, human_title, start_time, end_time, description): @course_view -def view_calendar(pctx): +def view_calendar(pctx, mode=None): + # type: (CoursePageContext, Optional[Text]) -> http.HttpResponse + if not pctx.has_permission(pperm.view_calendar): raise PermissionDenied(_("may not view calendar")) - from course.views import get_now_or_fake_time + is_edit_view = bool(mode == "edit") + if is_edit_view and not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit calendar")) + now = get_now_or_fake_time(pctx.request) + default_date = now.date() + if pctx.course.end_date is not None and default_date > pctx.course.end_date: + default_date = pctx.course.end_date + + return render_course_page(pctx, "course/calendar.html", { + "is_edit_view": is_edit_view, + "default_date": default_date.isoformat(), + + # Wrappers used by JavaScript template (tmpl) so as not to + # conflict with Django template's tag wrapper + "JQ_OPEN": '{%', + 'JQ_CLOSE': '%}', + }) + + +def get_events(pctx, is_edit_view=False): + # type: (CoursePageContext, bool) -> Tuple[Text, List] events_json = [] + if is_edit_view: + assert pctx.has_permission(pperm.edit_events) + from course.content import ( get_raw_yaml_from_repo, markup_to_html, parse_date_spec) try: @@ -386,15 +619,20 @@ def view_calendar(pctx): event_info_list = [] + filter_kwargs = {"course": pctx.course} + + if not is_edit_view: + # exclude hidden events when not is_edit_view + filter_kwargs["shown_in_calendar"] = True + events = sorted( - Event.objects - .filter( - course=pctx.course, - shown_in_calendar=True), + Event.objects.filter(**filter_kwargs), key=lambda evt: ( -evt.time.year, -evt.time.month, -evt.time.day, evt.time.hour, evt.time.minute, evt.time.second)) + now = get_now_or_fake_time(pctx.request) + for event in events: kind_desc = event_kinds_desc.get(event.kind) @@ -407,6 +645,9 @@ def view_calendar(pctx): } if event.end_time is not None: event_json["end"] = event.end_time.isoformat() + else: + # Disable duration edit in FullCalendar js for events without end_time + event_json["durationEditable"] = False if kind_desc is not None: if "color" in kind_desc: @@ -417,8 +658,19 @@ def view_calendar(pctx): else: human_title = kind_desc["title"].rstrip("{nr}").strip() + if is_edit_view: + if not event.shown_in_calendar: + event_json["hidden_in_calendar"] = True + event_json["delete_form_url"] = reverse( + "relate-get_delete_event_modal_form", + args=[pctx.course.identifier, event.id]) + event_json["update_form_url"] = reverse( + "relate-get_update_event_modal_form", + args=[pctx.course.identifier, event.id]) + event_json["str"] = str(event) + description = None - show_description = True + show_description = True and event.shown_in_calendar event_desc = event_info_desc.get(six.text_type(event)) if event_desc is not None: if "description" in event_desc: @@ -445,8 +697,12 @@ def view_calendar(pctx): show_description = False event_json["title"] = human_title + if is_edit_view: + event_json['show_description'] = show_description - if show_description and description: + if description and (show_description or is_edit_view): + # Fixme: participation with pperm.edit_events will + # always see the url (both edit view and normal view) event_json["url"] = "#event-%d" % event.id start_time = event.time @@ -468,25 +724,58 @@ def view_calendar(pctx): human_title=human_title, start_time=start_time, end_time=end_time, - description=description + description=description, )) events_json.append(event_json) - default_date = now.date() - if pctx.course.end_date is not None and default_date > pctx.course.end_date: - default_date = pctx.course.end_date + from django.template.loader import render_to_string + events_info_html = render_to_string( + "course/events_info.html", + context={"event_info_list": event_info_list, + "is_edit_view": is_edit_view}, + request=pctx.request) - from json import dumps - return render_course_page(pctx, "course/calendar.html", { - "events_json": dumps(events_json), - "event_info_list": event_info_list, - "default_date": default_date.isoformat(), - "edit_view": False - }) + return events_info_html, events_json + + +@course_view +def fetch_events(pctx, mode=None): + # type: (CoursePageContext, Optional[Text]) -> http.JsonResponse + + if not pctx.has_permission(pperm.view_calendar): + raise PermissionDenied(_("may not fetch events")) + + is_edit_view = bool(mode == "edit") + if is_edit_view and not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not fetch events as edit view")) + + request = pctx.request + if not (request.is_ajax() and request.method == "GET"): + raise PermissionDenied(_("only AJAX GET is allowed")) + events_info_html, events_json = get_events(pctx, is_edit_view=is_edit_view) + + return JsonResponse( + {"events_json": events_json, + "events_info_html": events_info_html}, + safe=False) + +# }}} + + +class CreateEventModalForm(ModalStyledFormMixin, StyledModelForm): + form_title = _("Create a event") + modal_id = "create-event-modal" + prefix = "create" + + kind = forms.CharField( + required=True, + help_text=_( + "Should be lower_case_with_underscores, no spaces " + "allowed."), + label=pgettext_lazy("Kind of event", "Kind of event")) -class EditEventForm(StyledModelForm): class Meta: model = Event fields = ['kind', 'ordinal', 'time', @@ -496,210 +785,690 @@ class Meta: "end_time": DateTimePicker(options={"format": "YYYY-MM-DD HH:mm"}), } + def __init__(self, course_identifier, *args, **kwargs): + # type: (Text, *Any, **Any) -> None + super(CreateEventModalForm, self).__init__(*args, **kwargs) + self.fields["shown_in_calendar"].help_text = ( + _("Shown in students' calendar")) + + self.course_identifier = course_identifier + + exist_event_choices = [(choice, choice) for choice in set( + Event.objects.filter( + course__identifier=course_identifier, + + # only events with ordinals + ordinal__isnull=False) + .values_list("kind", flat=True))] + self.fields['kind'].widget = ListTextWidget(data_list=exist_event_choices, + name="event_create_choices") + + def get_ajax_form_helper(self): + helper = self.get_form_helper() + + self.helper.form_action = reverse( + "relate-create_event", args=[self.course_identifier]) + + helper.layout = Layout( + Div(*self.fields, css_class="modal-body"), + ButtonHolder( + Submit("save", _("Save"), + css_class="btn btn-md btn-success"), + Button("cancel", _("Cancel"), + css_class="btn btn-md btn-default", + data_dismiss="modal"), + css_class="modal-footer" + ) + ) + return helper + + def clean(self): + super(CreateEventModalForm, self).clean() + + kind = self.cleaned_data.get("kind") + ordinal = self.cleaned_data.get('ordinal') + if kind is not None: + filter_kwargs = {"course__identifier": self.course_identifier, + "kind": kind} + if ordinal is not None: + filter_kwargs["ordinal"] = ordinal + else: + filter_kwargs["ordinal__isnull"] = True + + qset = Event.objects.filter(**filter_kwargs) + if qset.count(): + from django.forms import ValidationError + raise ValidationError( + _("'%(exist_event)s' already exists.") + % {'exist_event': qset[0]}) + -@login_required @course_view -def edit_calendar(pctx): +def get_create_event_modal_form(pctx): + # type: (CoursePageContext) -> http.JsonResponse + if not pctx.has_permission(pperm.edit_events): raise PermissionDenied(_("may not edit events")) - from course.content import markup_to_html, parse_date_spec + request = pctx.request + if not (request.is_ajax() and request.method == "GET"): + raise PermissionDenied(_("only AJAX GET is allowed")) + + new_event_form = CreateEventModalForm(pctx.course.identifier) + + return JsonResponse( + {"modal_id": new_event_form.modal_id, + "form_html": new_event_form.render_ajax_modal_form_html(pctx.request)}) + + +@course_view +@transaction.atomic() +def create_event(pctx): + # type: (CoursePageContext) -> http.JsonResponse + + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) - from course.views import get_now_or_fake_time - now = get_now_or_fake_time(pctx.request) request = pctx.request + if not request.is_ajax() or request.method != "POST": + raise PermissionDenied(_("only AJAX POST is allowed")) - edit_existing_event_flag = False - id_to_edit = None - edit_event_form = EditEventForm() - default_date = now.date() + form = CreateEventModalForm( + pctx.course.identifier, request.POST, request.FILES) + + if form.is_valid(): + try: + instance = form.save(commit=False) + instance.course = pctx.course + instance.save() + + message = (_("Event created: '%s'.") % str(instance)) + return JsonResponse({"message": message}) + except Exception as e: + + # return the error as a non-field error + return JsonResponse( + {"__all__": ["%s: %s" % (type(e).__name__, str(e))]}, status=400) + else: + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, status=400) + + +class DeleteEventForm(ModalStyledFormMixin, StyledModelForm): + form_title = _("Delete event") + modal_id = "delete-event-modal" + prefix = "delete" + + class Meta: + model = Event + fields = [] # type: List + + def __init__(self, course_identifier, instance_to_delete, *args, **kwargs): + # type: (Text, Event, *Any, **Any) -> None + super(DeleteEventForm, self).__init__(*args, **kwargs) + + self.course_identifier = course_identifier + + hint = _("Are you sure to delete event '%s'?") % str(instance_to_delete) + + if instance_to_delete.ordinal is not None: + events_of_same_kind = Event.objects.filter( + course__identifier=course_identifier, + kind=instance_to_delete.kind, ordinal__isnull=False) + if events_of_same_kind.count() > 1: + choices = [ + ("delete_single", + _("Delete event '%s'") % str(instance_to_delete)), + ('delete_all', + _("Delete all '%s' events") % instance_to_delete.kind), + ] + + if events_of_same_kind.filter( + time__gt=instance_to_delete.time).count(): + choices.append( + ('delete_this_and_following', + _("Delete this and following '%s' events") + % instance_to_delete.kind), + ) + + local_time, week_day, hour, minute = ( + get_local_time_weekday_hour_minute(instance_to_delete.time)) + + events_of_same_kind_and_weekday_time = ( + Event.objects.filter( + course__identifier=course_identifier, + kind=instance_to_delete.kind, + time__week_day=week_day, + time__hour=hour, + time__minute=minute, + end_time__isnull=instance_to_delete.end_time is None, + shown_in_calendar=instance_to_delete.shown_in_calendar + )) + if instance_to_delete.end_time: + end_local_time, end_week_day, end_hour, end_minute = ( + get_local_time_weekday_hour_minute( + instance_to_delete.end_time)) + events_of_same_kind_and_weekday_time = ( + events_of_same_kind_and_weekday_time + .filter(end_time__isnull=False) + .filter( + end_time__week_day=end_week_day, + end_time__hour=end_hour, + end_time__minute=end_minute)) + + series_time_desc = ( + get_recurring_event_series_time_desc_from_instance( + instance_to_delete)) + + if (events_of_same_kind.count() + > events_of_same_kind_and_weekday_time.count() > 1): + + choices.append( + ("delete_all_in_same_series", + _("Delete all '%(kind)s' events " + "(%(time)s).") + % {"kind": instance_to_delete.kind, + "time": series_time_desc})) + + if events_of_same_kind_and_weekday_time.filter( + time__gt=instance_to_delete.time).count(): + choices.append( + ("delete_this_and_following_of_same_series", + _("Delete this and following '%(kind)s' events " + "(%(time)s).") + % {"kind": instance_to_delete.kind, + "time": series_time_desc})) + + self.fields["operation"] = ( + forms.ChoiceField( + choices=choices, widget=forms.RadioSelect(), required=True, + initial="delete_single", + label=_("Operation"))) + hint = _("Select your operation:") + + self.instance_to_delete = instance_to_delete + self.hint = hint + + def get_ajax_form_helper(self): + helper = super(DeleteEventForm, self).get_ajax_form_helper() + + self.helper.form_action = reverse( + "relate-delete_event", args=[ + self.course_identifier, self.instance_to_delete.pk]) + + helper.layout = Layout( + Div(*self.fields, css_class="modal-body"), + ButtonHolder( + Submit("submit", _("Delete"), + css_class="btn btn-md btn-danger"), + Button("cancel", _("Cancel"), + css_class="btn btn-md btn-default", + data_dismiss="modal"), + css_class="modal-footer")) + helper.layout[0].insert(0, HTML(self.hint)) + return helper + + +@course_view +def get_delete_event_modal_form(pctx, event_id): + # type: (CoursePageContext, int) -> http.JsonResponse + + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) + + request = pctx.request + if not (request.is_ajax() and request.method == "GET"): + raise PermissionDenied(_("only AJAX GET is allowed")) + + event_id = int(event_id) + instance_to_delete = get_object_or_404(Event, course=pctx.course, id=event_id) + + form = DeleteEventForm( + pctx.course.identifier, instance_to_delete, instance=instance_to_delete) + + return JsonResponse( + {"form_html": form.render_ajax_modal_form_html(pctx.request)}) + + +@course_view +@transaction.atomic() +def delete_event(pctx, event_id): + # type: (CoursePageContext, int) -> http.JsonResponse + + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) + + request = pctx.request + if not request.is_ajax() or request.method != "POST": + raise PermissionDenied(_("only AJAX POST is allowed")) + + event_id = int(event_id) + event_qs = Event.objects.filter(course=pctx.course, pk=event_id) + if not event_qs.count(): + from django.http import Http404 + raise Http404() + else: + instance_to_delete, = event_qs + form = DeleteEventForm( + pctx.course.identifier, instance_to_delete, + request.POST, instance=instance_to_delete) + + local_time, week_day, hour, minute = ( + get_local_time_weekday_hour_minute(instance_to_delete.time)) + + events_of_same_kind_and_weekday_time = ( + Event.objects.filter( + course__identifier=pctx.course.identifier, + kind=instance_to_delete.kind, + time__week_day=week_day, + time__hour=hour, + time__minute=minute, + end_time__isnull=instance_to_delete.end_time is None, + shown_in_calendar=instance_to_delete.shown_in_calendar + )) + if instance_to_delete.end_time: + end_local_time, end_week_day, end_hour, end_minute = ( + get_local_time_weekday_hour_minute( + instance_to_delete.end_time)) + events_of_same_kind_and_weekday_time = ( + events_of_same_kind_and_weekday_time + .filter(end_time__isnull=False) + .filter( + end_time__week_day=end_week_day, + end_time__hour=end_hour, + end_time__minute=end_minute)) + + series_time_desc = ( + get_recurring_event_series_time_desc_from_instance( + instance_to_delete)) + + if form.is_valid(): + operation = form.cleaned_data.get("operation") + + if operation == "delete_this_and_following": + qset = Event.objects.filter( + course=pctx.course, kind=instance_to_delete.kind, + time__gte=instance_to_delete.time) + message = _("%(number)d '%(kind)s' events deleted." + ) % {"number": qset.count(), + "kind": instance_to_delete.kind} + elif operation == "delete_all": + qset = Event.objects.filter( + course=pctx.course, kind=instance_to_delete.kind, + ordinal__isnull=False) + message = _("All '%(kind)s' events deleted." + ) % {"kind": instance_to_delete.kind} + elif operation == "delete_all_in_same_series": + qset = events_of_same_kind_and_weekday_time + message = _( + "All '%(kind)s' events (%(time)s) deleted." + % {"time": series_time_desc, + "kind": instance_to_delete.kind}) + elif operation == "delete_this_and_following_of_same_series": + qset = events_of_same_kind_and_weekday_time.filter( + time__gte=instance_to_delete.time) + message = _("%(number)d '%(kind)s' events " + "(%(time)s) deleted." + % {"number": qset.count(), + "kind": instance_to_delete.kind, + "time": series_time_desc}) + else: + # operation is None or operation == "delete_single": + qset = event_qs + message = _("Event '%s' deleted.") % str(instance_to_delete) - if request.method == "POST": - if 'id_to_delete' in request.POST: - event_to_delete = get_object_or_404(Event, - id=request.POST['id_to_delete']) - default_date = event_to_delete.time.date() try: - with transaction.atomic(): - event_to_delete.delete() - messages.add_message(request, messages.SUCCESS, - _("Event deleted.")) + qset.delete() + return JsonResponse({"message": message}) except Exception as e: - messages.add_message(request, messages.ERROR, - string_concat( - _("No event deleted"), - ": %(err_type)s: %(err_str)s") - % { - "err_type": type(e).__name__, - "err_str": str(e)}) - - elif 'id_to_edit' in request.POST: - id_to_edit = request.POST['id_to_edit'] - exsting_event_form = get_object_or_404(Event, - id=id_to_edit) - edit_event_form = EditEventForm(instance=exsting_event_form) - edit_existing_event_flag = True + return JsonResponse( + {"__all__": ["%s: %s" % (type(e).__name__, str(e))]}, + status=400) else: - init_event = Event(course=pctx.course) - is_editing_existing_event = 'existing_event_to_save' in request.POST + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, status=400) - if is_editing_existing_event: - init_event = get_object_or_404(Event, - id=request.POST['existing_event_to_save']) - form_event = EditEventForm(request.POST, instance=init_event) - if form_event.is_valid(): - kind = form_event.cleaned_data['kind'] - ordinal = form_event.cleaned_data['ordinal'] - try: - with transaction.atomic(): - form_event.save() - except Exception as e: - if isinstance(e, IntegrityError): - if ordinal is not None: - ordinal = str(int(ordinal)) - else: - ordinal = _("(no ordinal)") - e = EventAlreadyExists( - _("'%(event_kind)s %(event_ordinal)s' already exists") - % {'event_kind': kind, - 'event_ordinal': ordinal}) - - if is_editing_existing_event: - msg = _("Event not updated.") - else: - msg = _("No event created.") - - messages.add_message(request, messages.ERROR, - string_concat( - "%(err_type)s: %(err_str)s. ", msg) - % { - "err_type": type(e).__name__, - "err_str": str(e)}) - else: - if is_editing_existing_event: - messages.add_message(request, messages.SUCCESS, - _("Event updated.")) - else: - messages.add_message(request, messages.SUCCESS, - _("Event created.")) - default_date = form_event.cleaned_data['time'].date() - events_json = [] +class UpdateEventForm(ModalStyledFormMixin, StyledModelForm): + @property + def form_title(self): + return _("Update event '%s'" % str(Event.objects.get(id=self.event_id))) - from course.content import get_raw_yaml_from_repo - try: - event_descr = get_raw_yaml_from_repo(pctx.repo, - pctx.course.events_file, pctx.course_commit_sha) - except ObjectDoesNotExist: - event_descr = {} + modal_id = "update-event-modal" + prefix = "update" - event_kinds_desc = event_descr.get("event_kinds", {}) - event_info_desc = event_descr.get("events", {}) + class Meta: + model = Event + fields = ['kind', 'ordinal', 'time', + 'end_time', 'all_day', 'shown_in_calendar'] + widgets = { + "time": DateTimePicker(options={"format": "YYYY-MM-DD HH:mm"}), + "end_time": DateTimePicker(options={"format": "YYYY-MM-DD HH:mm"}), + } - event_info_list = [] + def __init__(self, course_identifier, event_id, *args, **kwargs): + # type: (Text, int, *Any, **Any) -> None + super(UpdateEventForm, self).__init__(*args, **kwargs) - events = sorted( - Event.objects - .filter( - course=pctx.course, - ), - key=lambda evt: ( - -evt.time.year, -evt.time.month, -evt.time.day, - evt.time.hour, evt.time.minute, evt.time.second)) + self.course_identifier = course_identifier + self.event_id = event_id + + def get_ajax_form_helper(self): + helper = super(UpdateEventForm, self).get_ajax_form_helper() + self.helper.form_action = reverse( + "relate-update_event", args=[self.course_identifier, self.event_id]) + + update_button = Submit("update", _("Update"), + css_class="btn btn-md btn-success") + + update_all_button = Submit( + "update_all", _("Update all"), + css_class="btn btn-md btn-success") + + update_this_and_following_button = Submit( + "update_this_and_following", _("Update this and following"), + css_class="btn btn-md btn-success") + + update_series = Submit("update_series", + _("Update series"), + css_class="btn btn-md btn-success") + + update_this_and_following_in_series_button = Submit( + "update_this_and_following_in_series", + _("Update this and following in series"), + css_class="btn btn-md btn-success") + + cancel_button = Button("cancel", _("Cancel"), + css_class="btn btn-md btn-default", + data_dismiss="modal") + + instance_to_update = Event.objects.get(id=self.event_id) + may_update_all = False + may_update_following = False + may_update_series = False + may_update_following_in_series = False + + if instance_to_update.ordinal is not None: + events_of_same_kind = Event.objects.filter( + course__identifier=self.course_identifier, + kind=instance_to_update.kind, ordinal__isnull=False) + if events_of_same_kind.count() > 1: + + start_local_time, start_week_day, start_hour, start_minute = ( + get_local_time_weekday_hour_minute(instance_to_update.time)) + + events_of_same_kind_and_weekday_time = ( + Event.objects.filter( + course__identifier=self.course_identifier, + kind=instance_to_update.kind, + time__week_day=start_week_day, + time__hour=start_hour, + time__minute=start_minute, + end_time__isnull=instance_to_update.end_time is None, + shown_in_calendar=instance_to_update.shown_in_calendar + )) + if instance_to_update.end_time: + end_local_time, end_week_day, end_hour, end_minute = ( + get_local_time_weekday_hour_minute( + instance_to_update.end_time)) + events_of_same_kind_and_weekday_time = ( + events_of_same_kind_and_weekday_time + .filter(end_time__isnull=False) + .filter( + end_time__week_day=end_week_day, + end_time__hour=end_hour, + end_time__minute=end_minute)) + + if (events_of_same_kind.count() + > events_of_same_kind_and_weekday_time.count() > 1): + may_update_series = True + if events_of_same_kind_and_weekday_time.filter( + time__gt=instance_to_update.time).count(): + may_update_following_in_series = True + elif (events_of_same_kind.count() + == events_of_same_kind_and_weekday_time.count() > 1): + may_update_all = True + if events_of_same_kind_and_weekday_time.filter( + time__gt=instance_to_update.time).count(): + may_update_following = True + + button_holder = ButtonHolder() + if may_update_all: + button_holder.append(update_all_button) + if may_update_following: + button_holder.append(update_this_and_following_button) + elif may_update_series: + button_holder.append(update_series) + if may_update_following_in_series: + button_holder.append(update_this_and_following_in_series_button) + + button_holder.append(update_button) + button_holder.append(cancel_button) + + button_holder.css_class = "modal-footer" + + helper.layout = Layout( + Div(*self.fields, css_class="modal-body"), + button_holder) + return helper - for event in events: - kind_desc = event_kinds_desc.get(event.kind) - human_title = six.text_type(event) +@course_view +def get_update_event_modal_form(pctx, event_id): + # type: (CoursePageContext, int) -> http.JsonResponse - event_json = { - "id": event.id, - "start": event.time.isoformat(), - "allDay": event.all_day, - } - if event.end_time is not None: - event_json["end"] = event.end_time.isoformat() + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) - if kind_desc is not None: - if "color" in kind_desc: - event_json["color"] = kind_desc["color"] - if "title" in kind_desc: - if event.ordinal is not None: - human_title = kind_desc["title"].format(nr=event.ordinal) - else: - human_title = kind_desc["title"] + request = pctx.request + if not request.is_ajax(): + raise PermissionDenied(_("only AJAX request is allowed")) - description = None - show_description = True - event_desc = event_info_desc.get(six.text_type(event)) - if event_desc is not None: - if "description" in event_desc: - description = markup_to_html( - pctx.course, pctx.repo, pctx.course_commit_sha, - event_desc["description"]) + event_id = int(event_id) + instance_to_update = get_object_or_404(Event, course=pctx.course, id=event_id) - if "title" in event_desc: - human_title = event_desc["title"] + if request.method == "POST": + # when drag-n-drop the event + drop_timedelta_hours = float(request.POST.get("drop_timedelta_hours", 0)) + + # when 'resize', i.e., change end_time of the event + resize_timedelta_hours = float( + request.POST.get("resize_timedelta_hours", 0)) + + assert not (drop_timedelta_hours and resize_timedelta_hours) + + if drop_timedelta_hours: + instance_to_update.time += datetime.timedelta(hours=drop_timedelta_hours) + if instance_to_update.end_time is not None: + instance_to_update.end_time += ( + datetime.timedelta(hours=drop_timedelta_hours)) + + if resize_timedelta_hours: + if not instance_to_update.end_time: + raise SuspiciousOperation( + "may not resize events which has no end_time") + instance_to_update.end_time += ( + datetime.timedelta(hours=resize_timedelta_hours)) + + form = UpdateEventForm( + pctx.course.identifier, event_id, instance=instance_to_update) + for field_name, __ in form.fields.items(): + if field_name not in ["time", "end_time"]: + form.fields[field_name].widget = forms.HiddenInput() + else: + form = UpdateEventForm( + pctx.course.identifier, event_id, instance=instance_to_update) - if "color" in event_desc: - event_json["color"] = event_desc["color"] + return JsonResponse( + {"form_html": form.render_ajax_modal_form_html(pctx.request)}) - if "show_description_from" in event_desc: - ds = parse_date_spec( - pctx.course, event_desc["show_description_from"]) - if now < ds: - show_description = False - if "show_description_until" in event_desc: - ds = parse_date_spec( - pctx.course, event_desc["show_description_until"]) - if now > ds: - show_description = False +@course_view +@transaction.atomic() +def update_event(pctx, event_id): + # type: (CoursePageContext, int) -> http.JsonResponse - event_json["title"] = human_title + if not pctx.has_permission(pperm.edit_events): + raise PermissionDenied(_("may not edit events")) - if show_description and description: - event_json["url"] = "#event-%d" % event.id + request = pctx.request + if not request.is_ajax() or request.method != "POST": + raise PermissionDenied(_("only AJAX POST is allowed")) - start_time = event.time - end_time = event.end_time + event_id = int(event_id) + instance_to_update = get_object_or_404(Event, course=pctx.course, id=event_id) + original_str = str(instance_to_update) + series_time_desc = ( + get_recurring_event_series_time_desc_from_instance(instance_to_update)) - if event.all_day: - start_time = start_time.date() - if end_time is not None: - local_end_time = as_local_time(end_time) - end_midnight = datetime.time(tzinfo=local_end_time.tzinfo) - if local_end_time.time() == end_midnight: - end_time = (end_time - datetime.timedelta(days=1)).date() - else: - end_time = end_time.date() + local_time, week_day, hour, minute = ( + get_local_time_weekday_hour_minute(instance_to_update.time)) - event_info_list.append( - EventInfo( - id=event.id, - human_title=human_title, - start_time=start_time, - end_time=end_time, - description=description - )) + form = UpdateEventForm( + pctx.course.identifier, event_id, request.POST, request.FILES) - events_json.append(event_json) + if form.is_valid(): + try: + temp_instance = form.save(commit=False) + if (temp_instance.time == instance_to_update.time + and temp_instance.end_time == instance_to_update.end_time + and temp_instance.kind == instance_to_update.kind + and temp_instance.ordinal == instance_to_update.ordinal + and temp_instance.all_day == instance_to_update.all_day + and temp_instance.shown_in_calendar + == instance_to_update.shown_in_calendar): + return JsonResponse( + {"message": _("No change was made."), + "message_level": messages.DEFAULT_TAGS[messages.WARNING] + }) + + temp_instance.course = pctx.course + new_event_timedelta = temp_instance.time - instance_to_update.time + new_duration = None + if temp_instance.end_time is not None: + new_duration = temp_instance.end_time - temp_instance.time + + events_of_same_kind_and_weekday_time = ( + Event.objects.filter( + course__identifier=pctx.course.identifier, + kind=instance_to_update.kind, + time__week_day=week_day, + time__hour=hour, + time__minute=minute, + end_time__isnull=instance_to_update.end_time is None, + shown_in_calendar=instance_to_update.shown_in_calendar + )) + + if instance_to_update.end_time is not None: + end_local_time, end_week_day, end_hour, end_minute = ( + get_local_time_weekday_hour_minute( + instance_to_update.end_time)) + events_of_same_kind_and_weekday_time = ( + events_of_same_kind_and_weekday_time + .filter(end_time__isnull=False) + .filter(end_time__week_day=end_week_day, + end_time__hour=end_hour, + end_time__minute=end_minute)) + + if "update" in request.POST: + instance_to_update.time = temp_instance.time + instance_to_update.end_time = temp_instance.end_time + instance_to_update.kind = temp_instance.kind + instance_to_update.ordinal = temp_instance.ordinal + instance_to_update.all_day = temp_instance.all_day + instance_to_update.shown_in_calendar = ( + temp_instance.shown_in_calendar) + + instance_to_update.save() + + if original_str == str(temp_instance): + message = _("Event '%s' updated.") % str(instance_to_update) + else: + message = string_concat( + _("Event updated"), + ": '%(original_event)s' -> '%(new_event)s'" + % {"original_event": original_str, + "new_event": str(temp_instance)}) + else: + if original_str == str(temp_instance): + changes = "." + else: + changes = ( + ": '%s' -> '%s'." + % (instance_to_update.kind, temp_instance.kind)) + + if "update_all" in request.POST: + events_to_update = ( + Event.objects.filter(kind=instance_to_update.kind)) + message = string_concat( + _("All '%(kind)s' events updated" + % {"kind": instance_to_update.kind}), + changes) + + elif "update_this_and_following" in request.POST: + events_to_update = Event.objects.filter( + kind=instance_to_update.kind, + time__gte=instance_to_update.time) + message = string_concat( + _("%(number)d '%(kind)s' events updated" + % {"number": events_to_update.count(), + "kind": instance_to_update.kind}), + changes) + + elif "update_series" in request.POST: + events_to_update = events_of_same_kind_and_weekday_time + message = string_concat( + _("All '%(kind)s' events (%(time)s) updated" + % {"time": series_time_desc, + "kind": instance_to_update.kind}), + changes) + + elif "update_this_and_following_in_series" in request.POST: + events_to_update = events_of_same_kind_and_weekday_time.filter( + time__gte=instance_to_update.time) + message = string_concat( + _("%(number)d '%(kind)s' events " + "(%(time)s) updated" + % {"number": events_to_update.count(), + "kind": instance_to_update.kind, + "time": series_time_desc}), + changes) + else: + raise SuspiciousOperation(_("unknown operation")) - if pctx.course.end_date is not None and default_date > pctx.course.end_date: - default_date = pctx.course.end_date + if temp_instance.ordinal is None and events_to_update.count() > 1: + raise RuntimeError( + _("May not do bulk update when ordinal is None")) - from json import dumps - return render_course_page(pctx, "course/calendar.html", { - "form": edit_event_form, - "events_json": dumps(events_json), - "event_info_list": event_info_list, - "default_date": default_date.isoformat(), - "edit_existing_event_flag": edit_existing_event_flag, - "id_to_edit": id_to_edit, - "edit_view": True - }) + new_event_ordinal_delta = ( + temp_instance.ordinal - instance_to_update.ordinal) -# }}} + for event in events_to_update: + event.kind = temp_instance.kind + + # This might result in IntegrityError + event.ordinal += new_event_ordinal_delta + + event.time = ( + event.time + new_event_timedelta) + event.all_day = temp_instance.all_day + event.shown_in_calendar = temp_instance.shown_in_calendar + if new_duration is not None: + event.end_time = event.time + new_duration + else: + event.end_time = None + event.save() + + return JsonResponse( + {"message": message, + "message_level": messages.DEFAULT_TAGS[messages.SUCCESS]}) + + except Exception as e: + return JsonResponse( + {"__all__": ["%s: %s" % (type(e).__name__, str(e))]}, status=400) + else: + return JsonResponse( + {"errors": form.errors, "form_prefix": form.prefix}, status=400) # vim: foldmethod=marker diff --git a/course/models.py b/course/models.py index 576ef2f22..3b647ce59 100644 --- a/course/models.py +++ b/course/models.py @@ -307,7 +307,6 @@ class Event(models.Model): verbose_name=_('All day')) shown_in_calendar = models.BooleanField(default=True, - help_text=_("Shown in students' calendar"), verbose_name=_('Shown in calendar')) class Meta: @@ -332,6 +331,7 @@ def clean(self): _("End time must not be ahead of start time.")}) def save(self, *args, **kwargs): + # type: (*Any, **Any) -> None self.full_clean() if self.ordinal is None: diff --git a/course/templates/course/calendar.html b/course/templates/course/calendar.html index c79151e20..ecaeaa809 100644 --- a/course/templates/course/calendar.html +++ b/course/templates/course/calendar.html @@ -1,185 +1,526 @@ {% extends "course/course-base.html" %} {% load i18n %} +{% load crispy_forms_tags %} {% load static %} {% block title %} - {{ course.number}} + {{ course.number }} {% trans "Calendar" %} - {{ relate_site_name }} {% endblock %} -{%block head_assets_extra %} - +{% block head_assets_extra %} + - + {# load calendar with local language #} {% get_current_language as LANGUAGE_CODE %} {% if LANGUAGE_CODE != "en" %} - + {% endif %} + {% if pperm.edit_events %} + + {% endif %} + + {% if pperm.edit_events %} + + {% endif %} + {% endblock %} {% block content %} -

{{ course.number}} {% trans "Calendar" %}

+

{{ course.number }} {% trans "Calendar" %}

{% if pperm.edit_events %}
- - {% if edit_view %} - {% trans "Switch to Student View" %} - {% else %} - {% trans "Switch to Edit View" %} - {% endif %} - - {% if edit_view %} - - {% endif %} + + + + + + +
{% endif %} -
+
- {% trans "Note" %}: {% if edit_view %}{% trans "Different from the students' calendar, this calender shows all events. " %}{% endif %}{% trans "Some calendar entries are clickable and link to entries below." %} + {% trans "Note" %}: + {% trans "Some calendar entries are clickable and link to entries below." %} + {% if pperm.edit_events %} + + ({% trans "Different from normal view, this calender shows all events. " %}) + {% endif %} +
{{ events_info_html|safe }}
-
- {% for event_info in event_info_list %} -
-
- {{ event_info.human_title }} - ({{ event_info.start_time }}{% if event_info.end_time %} - {{ event_info.end_time }}{% endif %}) -
-
- {{ event_info.description|safe }} -
-
- {% endfor%} -
- - {% if edit_view %} - {% endif %} -{% for message in messages %} -
+ {% for message in messages %} +
- {% if message.tags == "error" %} + {% if message.tags == "error" %} - {% elif message.tags == "success" %} + {% elif message.tags == "success" %} - {% elif message.tags == "info" %} + {% elif message.tags == "info" %} - {% elif message.tags == "warning" %} + {% elif message.tags == "warning" %} - {% elif message.tags == "danger warning" %} + {% elif message.tags == "danger warning" %} - {% endif %} - {{ message|safe }} -
-{% endfor %} + {% endif %} + {{ message|safe }} +
+ {% endfor %} + {% if fake_time %}
diff --git a/relate/templates/modal-form.html b/relate/templates/modal-form.html new file mode 100644 index 000000000..6c1017c27 --- /dev/null +++ b/relate/templates/modal-form.html @@ -0,0 +1,19 @@ +{% load i18n %} + +{% load crispy_forms_tags %} + + \ No newline at end of file diff --git a/relate/urls.py b/relate/urls.py index 16b1c5266..747381485 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -348,9 +348,69 @@ url(r"^course" "/" + COURSE_ID_REGEX + - "/calendar-edit/$", - course.calendar.edit_calendar, - name="relate-edit_calendar"), + "/calendar/(?Pedit)/$", + course.calendar.view_calendar, + name="relate-view_calendar"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/fetch/$", + course.calendar.fetch_events, + name="relate-fetch_events"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/fetch/(?Pedit)/$", + course.calendar.fetch_events, + name="relate-fetch_events"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/new-event-modal-form/$", + course.calendar.get_create_event_modal_form, + name="relate-get_create_event_modal_form"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/new/$", + course.calendar.create_event, + name="relate-create_event"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/delete/(?P[0-9]+)$", + course.calendar.delete_event, + name="relate-delete_event"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/renumber-events-ajax-form/$", + course.calendar.get_renumber_events_modal_form, + name="relate-get_renumber_events_modal_form"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/recurring-events-modal-form/$", + course.calendar.get_recurring_events_modal_form, + name="relate-get_recurring_events_modal_form"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/delete-form/(?P[0-9]+)$", + course.calendar.get_delete_event_modal_form, + name="relate-get_delete_event_modal_form"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/update-form/(?P[0-9]+)$", + course.calendar.get_update_event_modal_form, + name="relate-get_update_event_modal_form"), + + url(r"^course" + "/" + COURSE_ID_REGEX + + "/calendar/update/(?P[0-9]+)$", + course.calendar.update_event, + name="relate-update_event"), # }}} diff --git a/relate/utils.py b/relate/utils.py index 3dc240e5e..8d8ce6527 100644 --- a/relate/utils.py +++ b/relate/utils.py @@ -27,6 +27,7 @@ import six import datetime +from crispy_forms.helper import FormHelper import django.forms as forms from django.utils.translation import ugettext_lazy as _ @@ -45,41 +46,37 @@ def string_concat(*strings): return format_lazy("{}" * len(strings), *strings) -class StyledForm(forms.Form): - def __init__(self, *args, **kwargs): - # type: (...) -> None - from crispy_forms.helper import FormHelper - self.helper = FormHelper() - self.helper.form_class = "form-horizontal" - self.helper.label_class = "col-lg-2" - self.helper.field_class = "col-lg-8" - - super(StyledForm, self).__init__(*args, **kwargs) - +class StyledFormMixin(object): + styled_form_class = "form-horizontal" + styled_label_class = "col-lg-2" + styled_field_class = "col-lg-8" -class StyledInlineForm(forms.Form): def __init__(self, *args, **kwargs): - # type: (...) -> None + # type: (*Any, **Any) -> None + super(StyledFormMixin, self).__init__(*args, **kwargs) # type: ignore + self.helper = self.get_form_helper() - from crispy_forms.helper import FormHelper - self.helper = FormHelper() - self.helper.form_class = "form-inline" - self.helper.label_class = "sr-only" + def get_form_helper(self): + # type: (...) -> FormHelper + helper = FormHelper() + helper.form_class = self.styled_form_class + helper.label_class = self.styled_label_class + helper.field_class = self.styled_field_class + return helper - super(StyledInlineForm, self).__init__(*args, **kwargs) +class StyledForm(StyledFormMixin, forms.Form): + pass -class StyledModelForm(forms.ModelForm): - def __init__(self, *args, **kwargs): - # type: (...) -> None - from crispy_forms.helper import FormHelper - self.helper = FormHelper() - self.helper.form_class = "form-horizontal" - self.helper.label_class = "col-lg-2" - self.helper.field_class = "col-lg-8" +class StyledInlineForm(StyledFormMixin, forms.Form): + styled_field_class = '' + styled_label_class = "sr-only" + styled_form_class = "form-inline" + - super(StyledModelForm, self).__init__(*args, **kwargs) +class StyledModelForm(StyledFormMixin, forms.ModelForm): + pass # {{{ repo-ish types diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index 169f6a1e2..2cc5f06c9 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -828,11 +828,6 @@ def get_course_view_url(cls, view_name, course_identifier=None): course_identifier or cls.get_default_course_identifier()) return reverse(view_name, args=[course_identifier]) - @classmethod - def get_course_calender_url(cls, course_identifier=None): - return cls.get_course_view_url( - "relate-view_calendar", course_identifier) - @classmethod def get_set_up_new_course_url(cls): return reverse("relate-set_up_new_course") diff --git a/tests/test_calendar.py b/tests/test_calendar.py index 70a127914..75dec703d 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -22,11 +22,13 @@ THE SOFTWARE. """ +import six import pytz import json import datetime +import unittest -from django.test import TestCase +from django.test import TestCase, override_settings, RequestFactory from django.urls import reverse from django.utils.timezone import now, timedelta from django.core.exceptions import ValidationError @@ -43,11 +45,92 @@ from tests.constants import DATE_TIME_PICKER_TIME_FORMAT -class CreateRecurringEventsTest(SingleCourseTestMixin, - MockAddMessageMixing, TestCase): - """test course.calendar.create_recurring_events""" +def get_prefixed_form_data(form_klass, form_data): + prefixed_form_data = {} + for k, v in form_data.items(): + prefixed_form_data[ + "%s-%s" % (form_klass.prefix, k)] = v + return prefixed_form_data + + +class CalendarTestMixin(SingleCourseTestMixin, HackRepoMixin): + + default_faked_now = datetime.datetime(2019, 1, 1, tzinfo=pytz.UTC) + default_event_time = default_faked_now - timedelta(hours=12) default_event_kind = "lecture" + def setUp(self): + super(CalendarTestMixin, self).setUp() + fake_get_now_or_fake_time = mock.patch( + "course.calendar.get_now_or_fake_time") + self.mock_get_now_or_fake_time = fake_get_now_or_fake_time.start() + self.mock_get_now_or_fake_time.return_value = now() + self.addCleanup(fake_get_now_or_fake_time.stop) + self.addCleanup(factories.EventFactory.reset_sequence) + + @classmethod + def get_course_calender_url(cls, is_edit_view=False, course_identifier=None): + course_identifier = course_identifier or cls.get_default_course_identifier() + kwargs = {"course_identifier": course_identifier} + if is_edit_view: + kwargs["mode"] = "edit" + return reverse("relate-view_calendar", kwargs=kwargs) + + def get_course_calender_view(self, is_edit_view=False, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + return self.c.get( + self.get_course_calender_url(is_edit_view, course_identifier)) + + def switch_to_fake_commit_sha(self): + self.course.active_git_commit_sha = "my_fake_commit_sha_for_events" + self.course.events_file = "events.yml" + self.course.save() + + def create_recurring_events( + self, n=5, staring_ordinal=1, + staring_time_offset_days=0, + staring_time_offset_hours=0, + staring_time_offset_minutes=0, + end_time_minute_duration=None): + + exist_events_pks = list(Event.objects.all().values_list("pk", flat=True)) + now_time = self.default_faked_now + timedelta( + days=staring_time_offset_days, + hours=staring_time_offset_hours, + minutes=staring_time_offset_minutes) + for i in range(n): + now_time += timedelta(weeks=1) + kwargs = {"kind": self.default_event_kind, + "ordinal": i + staring_ordinal, + "time": now_time} + if end_time_minute_duration is not None: + kwargs["end_time"] = ( + now_time + timedelta(minutes=end_time_minute_duration)) + factories.EventFactory(**kwargs) + + return list(Event.objects.exclude(pk__in=exist_events_pks)) + + +class ModalStyledFormMixinTest(CalendarTestMixin, TestCase): + """test course.calendar.ModalStyledFormMixin""" + def test_render_ajax_modal_form_html_with_context(self): + rf = RequestFactory() + request = rf.get(self.get_course_page_url()) + request.user = self.instructor_participation.user + + form = calendar.CreateEventModalForm(self.course.identifier) + + with mock.patch("crispy_forms.utils.render_crispy_form" + ) as mock_render_crispy_form: + form.render_ajax_modal_form_html(request, context={"foo": "bar"}) + self.assertEqual(mock_render_crispy_form.call_count, 1) + self.assertEqual(mock_render_crispy_form.call_args[0][2]["foo"], "bar") + self.assertTemplateUsed(form.ajax_modal_form_template) + + +class CreateRecurringEventsTest(CalendarTestMixin, MockAddMessageMixing, TestCase): + """test course.calendar.create_recurring_events""" + def get_create_recurring_events_url(self, course_identifier=None): course_identifier = course_identifier or self.get_default_course_identifier() return self.get_course_view_url( @@ -66,16 +149,22 @@ def get_create_recurring_events_view(self, course_identifier=None, self.get_create_recurring_events_url(course_identifier)) def post_create_recurring_events_view(self, data, course_identifier=None, - force_login_instructor=True): + force_login_instructor=True, + using_ajax=False): course_identifier = course_identifier or self.get_default_course_identifier() if not force_login_instructor: user = self.get_logged_in_user() else: user = self.instructor_participation.user + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + with self.temporarily_switch_to_user(user): return self.c.post( - self.get_create_recurring_events_url(course_identifier), data) + self.get_create_recurring_events_url(course_identifier), data, + **kwargs) def get_post_create_recur_evt_data( self, op="submit", starting_ordinal=None, **kwargs): @@ -92,7 +181,7 @@ def get_post_create_recur_evt_data( data["starting_ordinal"] = starting_ordinal data.update(kwargs) - return data + return get_prefixed_form_data(calendar.RecurringEventForm, data) def test_not_authenticated(self): with self.temporarily_switch_to_user(None): @@ -210,6 +299,23 @@ def test_event_save_unknown_error(self): self.assertAddMessageCalledWith( "RuntimeError: %s. No events created." % error_msg) + def test_event_save_field_error(self): + error_msg = "my unknown validation error for start_time" + with mock.patch("course.models.Event.save") as mock_event_save: + # mock error raised by event.end_time + mock_event_save.side_effect = ( + ValidationError({"time": error_msg})) + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data(starting_ordinal=4)) + self.assertEqual(resp.status_code, 200) + + # form error was raised instead of add_message + self.assertFormErrorLoose(resp, error_msg) + self.assertAddMessageCallCount(0) + + # not created + self.assertEqual(Event.objects.count(), 0) + def test_event_save_other_validation_error(self): error_msg = "my unknown validation error for end_time" with mock.patch("course.models.Event.save") as mock_event_save: @@ -266,6 +372,147 @@ def test_interval_biweekly(self): self.assertEqual(evt.time - t, datetime.timedelta(weeks=2)) t = evt.time + # {{{ Ajax part + + def test_get_by_ajax_failure(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.c.get(self.get_create_recurring_events_url(), + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 403) + + def test_post_form_not_valid_ajax(self): + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data(time="invalid_time"), + using_ajax=True + ) + self.assertEqual(resp.status_code, 400) + json_resp = json.loads(resp.content.decode()) + self.assertEqual( + json_resp["errors"]["time"], ['Enter a valid date/time.']) + + # not created + self.assertEqual(Event.objects.count(), 0) + + def test_event_save_field_error_ajax(self): + error_msg = "my unknown validation error for start_time" + with mock.patch("course.models.Event.save") as mock_event_save: + # mock error raised by event.end_time + mock_event_save.side_effect = ( + ValidationError({"time": error_msg})) + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data(starting_ordinal=4), + using_ajax=True) + self.assertEqual(resp.status_code, 400) + + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["errors"]["time"], [error_msg]) + + # not created + self.assertEqual(Event.objects.count(), 0) + + @unittest.skipIf(six.PY2, "Python 2 string repr has 'u' prefix") + def test_event_save_other_validation_error_ajax(self): + error_msg = "my unknown validation error for end_time" + with mock.patch("course.models.Event.save") as mock_event_save: + # mock error raised by event.end_time + mock_event_save.side_effect = ( + ValidationError({"end_time": error_msg})) + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data(starting_ordinal=4), + using_ajax=True) + self.assertEqual(resp.status_code, 400) + + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["errors"]["__all__"], + ["'end_time': [ValidationError(['%s'])]" % error_msg]) + + # not created + self.assertEqual(Event.objects.count(), 0) + + def test_post_success_ajax(self): + # only tested starting_ordinal specified + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data(starting_ordinal=4), + using_ajax=True) + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 5) + self.assertListEqual( + list(Event.objects.values_list("ordinal", flat=True)), + [4, 5, 6, 7, 8]) + + t = None + for evt in Event.objects.all(): + if t is None: + t = evt.time + continue + else: + self.assertEqual(evt.time - t, datetime.timedelta(weeks=1)) + t = evt.time + + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["message"], "Events created.") + + def test_event_save_unknown_error_ajax(self): + error_msg = "my unknown error" + with mock.patch("course.models.Event.save") as mock_event_save: + mock_event_save.side_effect = RuntimeError(error_msg) + resp = self.post_create_recurring_events_view( + data=self.get_post_create_recur_evt_data( + starting_ordinal=4), using_ajax=True) + self.assertEqual(resp.status_code, 400) + + # not created + self.assertEqual(Event.objects.count(), 0) + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["errors"]["__all__"], + ["RuntimeError: %s. No events created." % error_msg]) + + # }}} + + +class GetRecurringEventsModalFormTest(CalendarTestMixin, TestCase): + """test calendar.get_recurring_events_modal_form""" + force_login_student_for_each_test = False + + def setUp(self): + super(GetRecurringEventsModalFormTest, self).setUp() + self.c.force_login(self.instructor_participation.user) + + def get_recurring_events_modal_form_url(self, course_identifier=None): + return self.get_course_view_url( + "relate-get_recurring_events_modal_form", + course_identifier) + + def get_recurring_events_modal_form_view(self, course_identifier=None, + using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.get_recurring_events_modal_form_url(course_identifier), **kwargs) + + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_recurring_events_modal_form_view() + self.assertEqual(resp.status_code, 403) + + def test_post(self): + resp = self.c.post(self.get_recurring_events_modal_form_url(), data={}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 403) + + def test_post_non_ajax(self): + resp = self.c.post(self.get_recurring_events_modal_form_url(), data={}) + self.assertEqual(resp.status_code, 403) + + def test_get_non_ajax(self): + resp = self.get_recurring_events_modal_form_view(using_ajax=False) + self.assertEqual(resp.status_code, 403) + + def test_get_success(self): + resp = self.get_recurring_events_modal_form_view() + self.assertEqual(resp.status_code, 200) + class RecurringEventFormTest(SingleCourseTestMixin, TestCase): """test course.calendar.RecurringEventForm""" @@ -279,7 +526,8 @@ def test_valid(self): } form = calendar.RecurringEventForm( self.course.identifier, - data=form_data) + data=get_prefixed_form_data( + calendar.RecurringEventForm, form_data)) self.assertTrue(form.is_valid()) def test_negative_duration_in_minutes(self): @@ -291,7 +539,11 @@ def test_negative_duration_in_minutes(self): "interval": "weekly", "count": 5 } - form = calendar.RecurringEventForm(self.course.identifier, data=form_data) + + form = calendar.RecurringEventForm( + self.course.identifier, + data=get_prefixed_form_data( + calendar.RecurringEventForm, form_data)) self.assertFalse(form.is_valid()) self.assertIn( "Ensure this value is greater than or equal to 0.", @@ -305,7 +557,11 @@ def test_negative_event_count(self): "interval": "weekly", "count": -1 } - form = calendar.RecurringEventForm(self.course.identifier, data=form_data) + form = calendar.RecurringEventForm( + self.course.identifier, + data=get_prefixed_form_data( + calendar.RecurringEventForm, form_data)) + self.assertFalse(form.is_valid()) self.assertIn( "Ensure this value is greater than or equal to 0.", @@ -333,8 +589,7 @@ def test_available_kind_choices(self): '', form.as_p()) -class RenumberEventsTest(SingleCourseTestMixin, - MockAddMessageMixing, TestCase): +class RenumberEventsTest(CalendarTestMixin, MockAddMessageMixing, TestCase): """test course.calendar.create_recurring_events""" default_event_kind = "lecture" @@ -356,16 +611,21 @@ def get_renumber_events_view(self, course_identifier=None, self.get_renumber_events_events_url(course_identifier)) def post_renumber_events_view(self, data, course_identifier=None, - force_login_instructor=True): + force_login_instructor=True, using_ajax=False): course_identifier = course_identifier or self.get_default_course_identifier() if not force_login_instructor: user = self.get_logged_in_user() else: user = self.instructor_participation.user + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + with self.temporarily_switch_to_user(user): return self.c.post( - self.get_renumber_events_events_url(course_identifier), data) + self.get_renumber_events_events_url(course_identifier), + data, **kwargs) def get_post_renumber_evt_data( self, starting_ordinal, kind=None, op="submit", **kwargs): @@ -376,15 +636,13 @@ def get_post_renumber_evt_data( "preserve_ordinal_order": False} data.update(kwargs) - return data + return get_prefixed_form_data(calendar.RenumberEventsForm, data) @classmethod def setUpTestData(cls): # noqa super(RenumberEventsTest, cls).setUpTestData() - times = [] now_time = now() for i in range(5): - times.append(now_time) now_time += timedelta(weeks=1) factories.EventFactory( kind=cls.default_event_kind, ordinal=i * 2 + 1, time=now_time) @@ -568,32 +826,115 @@ def test_no_ordinal_event_not_renumbered(self): self.assertAddMessageCallCount(1) self.assertAddMessageCalledWith("Events renumbered.") + # {{{ Ajax part -class ViewCalendarTest(SingleCourseTestMixin, HackRepoMixin, TestCase): - """test course.calendar.view_calendar""" + def test_get_by_ajax_failure(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.c.get(self.get_renumber_events_events_url(), + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 403) - default_faked_now = datetime.datetime(2019, 1, 1, tzinfo=pytz.UTC) - default_event_time = default_faked_now - timedelta(hours=12) - default_event_kind = "lecture" + def test_post_form_not_valid_ajax(self): + resp = self.post_renumber_events_view( + data=self.get_post_renumber_evt_data( + kind="foo_kind", starting_ordinal=3), + using_ajax=True + ) + self.assertEqual(resp.status_code, 400) + expected_errors = ["Select a valid choice. foo_kind is " + "not one of the available choices."] + + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["errors"]["kind"], expected_errors) + + def test_post_success_ajax(self): + resp = self.post_renumber_events_view( + data=self.get_post_renumber_evt_data(starting_ordinal=3), + using_ajax=True) + self.assertEqual(resp.status_code, 200) + all_pks = list(Event.objects.values_list("pk", flat=True)) + + # originally 1, 3, 5, 7, 9, now 3, 4, 5, 6, 7 + + all_default_evts = Event.objects.filter(kind=self.default_event_kind) + self.assertEqual(all_default_evts.count(), 5) + self.assertListEqual( + list(all_default_evts.values_list("ordinal", flat=True)), + [3, 4, 5, 6, 7]) + + t = None + for evt in all_default_evts: + if t is None: + t = evt.time + continue + else: + self.assertEqual(evt.time - t, datetime.timedelta(weeks=1)) + t = evt.time + + # other events not affected + self.evt_another_kind1.refresh_from_db() + self.evt_another_kind2.refresh_from_db() + self.assertEqual( + self.evt_another_kind1.ordinal, self.evt_another_kind1_ordinal) + self.assertEqual( + self.evt_another_kind2.ordinal, self.evt_another_kind2_ordinal) + + # no new objects created + self.assertListEqual( + list(Event.objects.values_list("pk", flat=True)), all_pks) + + json_resp = json.loads(resp.content.decode()) + self.assertEqual(json_resp["message"], "Events renumbered.") + + # }}} + + +class GetRenumberEventsModalFormTest(CalendarTestMixin, TestCase): + """test calendar.get_renumber_events_modal_form""" + force_login_student_for_each_test = False def setUp(self): - super(ViewCalendarTest, self).setUp() - fake_get_now_or_fake_time = mock.patch( - "course.views.get_now_or_fake_time") - self.mock_get_now_or_fake_time = fake_get_now_or_fake_time.start() - self.mock_get_now_or_fake_time.return_value = now() - self.addCleanup(fake_get_now_or_fake_time.stop) + super(GetRenumberEventsModalFormTest, self).setUp() + self.c.force_login(self.instructor_participation.user) - self.addCleanup(factories.EventFactory.reset_sequence) + def get_renumber_events_modal_form_url(self, course_identifier=None): + return self.get_course_view_url("relate-get_renumber_events_modal_form", + course_identifier) - def get_course_calender_view(self, course_identifier=None): - course_identifier = course_identifier or self.get_default_course_identifier() - return self.c.get(self.get_course_calender_url(course_identifier)) + def get_renumber_events_modal_form_view(self, course_identifier=None, + using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.get_renumber_events_modal_form_url(course_identifier), **kwargs) - def switch_to_fake_commit_sha(self): - self.course.active_git_commit_sha = "my_fake_commit_sha_for_events" - self.course.events_file = "events.yml" - self.course.save() + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_renumber_events_modal_form_view() + self.assertEqual(resp.status_code, 403) + + def test_post(self): + resp = self.c.post(self.get_renumber_events_modal_form_url(), data={}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 403) + + def test_post_non_ajax(self): + resp = self.c.post(self.get_renumber_events_modal_form_url(), data={}) + self.assertEqual(resp.status_code, 403) + + def test_get_non_ajax(self): + resp = self.get_renumber_events_modal_form_view(using_ajax=False) + self.assertEqual(resp.status_code, 403) + + def test_get_success(self): + resp = self.get_renumber_events_modal_form_view() + self.assertEqual(resp.status_code, 200) + + +class ViewCalendarTest(CalendarTestMixin, TestCase): + """ test calendar.view_calendar """ + force_login_student_for_each_test = True def test_no_pperm(self): with mock.patch( @@ -603,122 +944,315 @@ def test_no_pperm(self): resp = self.get_course_calender_view() self.assertEqual(resp.status_code, 403) - def test_neither_events_nor_event_file(self): - self.mock_get_now_or_fake_time.return_value = self.default_faked_now + def test_student_non_edit_view_success(self): resp = self.get_course_calender_view() self.assertEqual(resp.status_code, 200) - self.assertResponseContextEqual(resp, "events_json", '[]') - self.assertResponseContextEqual(resp, "event_info_list", []) - self.assertResponseContextEqual( - resp, "default_date", self.default_faked_now.date().isoformat()) - def test_no_event_file(self): - factories.EventFactory( - kind=self.default_event_kind, course=self.course, - time=self.default_event_time) - factories.EventFactory( - kind=self.default_event_kind, course=self.course, - time=self.default_event_time + timedelta(hours=1)) + def test_student_edit_view_failure(self): + resp = self.get_course_calender_view(is_edit_view=True) + self.assertEqual(resp.status_code, 403) - resp = self.get_course_calender_view() - self.assertEqual(resp.status_code, 200) + def test_instructor_non_edit_view_success(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.get_course_calender_view() + self.assertEqual(resp.status_code, 200) - events_json = json.loads(resp.context["events_json"]) - self.assertEqual(len(events_json), 2) - self.assertDictEqual( - events_json[0], - {'id': 1, 'start': self.default_event_time.isoformat(), - 'allDay': False, - 'title': 'lecture 0'}) - self.assertDictEqual( - events_json[1], - {'id': 2, - 'start': (self.default_event_time + timedelta(hours=1)).isoformat(), - 'allDay': False, - 'title': 'lecture 1'}) + def test_instructor_edit_view_success(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.get_course_calender_view(is_edit_view=True) + self.assertEqual(resp.status_code, 200) - self.assertResponseContextEqual(resp, "event_info_list", []) + def test_default_time(self): + if self.course.end_date is not None: + self.course.end_date = ( + self.default_faked_now.date() + timedelta(days=100)) + self.course.save() - def test_hidden_event_not_shown(self): - factories.EventFactory( - kind=self.default_event_kind, course=self.course, - time=self.default_event_time) - factories.EventFactory( - kind=self.default_event_kind, course=self.course, - shown_in_calendar=False, - time=self.default_event_time + timedelta(hours=1)) + self.mock_get_now_or_fake_time.return_value = self.default_faked_now resp = self.get_course_calender_view() self.assertEqual(resp.status_code, 200) + self.assertResponseContextEqual( + resp, "default_date", self.default_faked_now.date().isoformat()) - events_json = json.loads(resp.context["events_json"]) - self.assertEqual(len(events_json), 1) - self.assertDictEqual( - events_json[0], - {'id': 1, 'start': self.default_event_time.isoformat(), - 'allDay': False, - 'title': 'lecture 0'}) - self.assertResponseContextEqual(resp, "event_info_list", []) + def test_default_time_after_course_ended(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now + + self.course.end_date = self.default_faked_now.date() - timedelta(days=100) + self.course.save() + self.assertTrue(self.course.end_date < self.default_faked_now.date()) - def test_event_has_end_time(self): - factories.EventFactory( - kind=self.default_event_kind, course=self.course, - time=self.default_event_time, - end_time=self.default_event_time + timedelta(hours=1)) resp = self.get_course_calender_view() self.assertEqual(resp.status_code, 200) - events_json = json.loads(resp.context["events_json"]) - self.assertEqual(len(events_json), 1) - event_json = events_json[0] - self.assertDictEqual( - event_json, - {'id': 1, 'start': self.default_event_time.isoformat(), - 'allDay': False, - 'title': 'lecture 0', - 'end': (self.default_event_time + timedelta(hours=1)).isoformat(), - }) + # Calendar's default_date will be course.end_date + self.assertResponseContextEqual( + resp, "default_date", self.course.end_date.isoformat()) - self.assertResponseContextEqual(resp, "event_info_list", []) - def test_event_course_finished(self): +class FetchEventsTest(CalendarTestMixin, TestCase): + """test course.calendar.fetch_events""" + force_login_student_for_each_test = False + + def setUp(self): + super(FetchEventsTest, self).setUp() + self.c.force_login(self.instructor_participation.user) + + def fetch_events_url(self, is_edit_view=False, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + kwargs = {"course_identifier": course_identifier} + if is_edit_view: + kwargs["mode"] = "edit" + return reverse("relate-fetch_events", kwargs=kwargs) + + def get_fetch_events(self, is_edit_view=False, course_identifier=None, + using_ajax=True): + course_identifier = course_identifier or self.get_default_course_identifier() + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.fetch_events_url(is_edit_view, course_identifier), **kwargs) + + def test_view_no_pperm(self): + with mock.patch( + "course.utils.CoursePageContext.has_permission" + ) as mock_has_pperm: + mock_has_pperm.return_value = False + resp = self.get_fetch_events() + self.assertEqual(resp.status_code, 403) + + def test_student_view_success(self): self.mock_get_now_or_fake_time.return_value = self.default_faked_now - self.course.end_date = (self.default_faked_now - timedelta(weeks=1)).date() - self.course.save() + with mock.patch("course.calendar.get_events") as mock_get_events: + mock_get_events.return_value = ([], []) + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_fetch_events() + self.assertEqual(resp.status_code, 200) + self.assertEqual(mock_get_events.call_count, 1) + self.assertEqual( + mock_get_events.call_args[1]["is_edit_view"], False) + + def test_edit_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_fetch_events(is_edit_view=True) + self.assertEqual(resp.status_code, 403) - resp = self.get_course_calender_view() + def test_not_ajax(self): + resp = self.get_fetch_events(using_ajax=False) + self.assertEqual(resp.status_code, 403) + + def test_ajax_post(self): + resp = self.c.post(self.fetch_events_url(), data={}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 403) + + def test_post(self): + resp = self.c.post(self.fetch_events_url(), data={}) + self.assertEqual(resp.status_code, 403) + + def test_instructor_fetch_success(self): + resp = self.get_fetch_events() self.assertEqual(resp.status_code, 200) - self.assertResponseContextEqual(resp, "events_json", '[]') - self.assertResponseContextEqual(resp, "event_info_list", []) - self.assertResponseContextEqual( - resp, "default_date", self.course.end_date.isoformat()) + def test_instructor_edit_fetch_success(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now + with mock.patch("course.calendar.get_events") as mock_get_events: + mock_get_events.return_value = ([], []) + resp = self.get_fetch_events(is_edit_view=True) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + mock_get_events.call_args[1]["is_edit_view"], True) + + +class GetEventsTest(CalendarTestMixin, TestCase): + """test course.calendar.get_events""" + force_login_student_for_each_test = False - def test_event_course_not_finished(self): + def setUp(self): + super(GetEventsTest, self).setUp() + fake_render_to_string = mock.patch("django.template.loader.render_to_string") + self.mock_render_to_string = fake_render_to_string.start() + + # Note: in this way, the events_info_html in the fetch_event response + # will always be empty, we test the events_info_html by check the kwargs + # when calling render_to_string + + # Todo: This only test the behavior of get_events when called + # in fetch_events. get_events also need to be tested when called in + # view_calendar. + self.mock_render_to_string.return_value = "" + self.addCleanup(fake_render_to_string.stop) + self.default_pctx = self.get_instructor_pctx() + # self.c.force_login(self.instructor_participation.user) + + def get_event_delete_form_url(self, event_id): + return reverse( + "relate-get_delete_event_modal_form", + args=[self.course.identifier, event_id]) + + def get_event_update_form_url(self, event_id): + return reverse("relate-get_update_event_modal_form", + args=[self.course.identifier, event_id]) + + def get_pctx(self, user): + rf = RequestFactory() + request = rf.get(self.get_course_page_url()) + request.user = user + + from course.utils import CoursePageContext + return CoursePageContext(request, self.course.identifier) + + def get_student_pctx(self): + return self.get_pctx(self.student_participation.user) + + def get_instructor_pctx(self): + return self.get_pctx(self.instructor_participation.user) + + def get_event_info_list_rendered(self): + """get the event_info_list rendered from mocked render_to_string call""" + self.assertTrue(self.mock_render_to_string.call_count > 0) + return self.mock_render_to_string.call_args[1]["context"]["event_info_list"] + + def test_neither_events_nor_event_file(self): self.mock_get_now_or_fake_time.return_value = self.default_faked_now - self.course.end_date = (self.default_faked_now + timedelta(weeks=1)).date() - self.course.save() + __, events_json = calendar.get_events(self.default_pctx) - resp = self.get_course_calender_view() - self.assertEqual(resp.status_code, 200) + self.assertEqual(events_json, []) + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) - self.assertResponseContextEqual(resp, "events_json", '[]') - self.assertResponseContextEqual(resp, "event_info_list", []) - self.assertResponseContextEqual( - resp, "default_date", self.default_faked_now.date().isoformat()) + def test_no_event_file_not_editing(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now - def test_events_file_no_events(self): - # make sure it works - self.switch_to_fake_commit_sha() + event1 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time) + event2 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time + timedelta(hours=1), + end_time=self.default_event_time + timedelta(hours=2)) - resp = self.get_course_calender_view() + __, events_json = calendar.get_events(self.default_pctx) + self.assertEqual(len(events_json), 2) + self.assertDictEqual( + events_json[0], + {'id': event1.id, 'start': event1.time.isoformat(), + 'allDay': False, + 'durationEditable': False, + 'title': 'lecture 0'}) + self.assertDictEqual( + events_json[1], + {'id': event2.id, + 'start': event2.time.isoformat(), + 'end': event2.end_time.isoformat(), + 'allDay': False, + 'title': 'lecture 1'}) + + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) - self.assertResponseContextEqual(resp, "events_json", '[]') - self.assertResponseContextEqual(resp, "event_info_list", []) + def test_no_event_file_editing(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now + + event1 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time, + end_time=self.default_event_time+timedelta(minutes=1)) + event2 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time + timedelta(hours=1)) + + __, events_json = calendar.get_events(self.default_pctx, is_edit_view=True) + self.assertEqual(len(events_json), 2) + self.assertDictEqual( + events_json[0], + {'id': event1.id, + 'start': event1.time.isoformat(), + 'end': event1.end_time.isoformat(), + 'allDay': False, + 'title': 'lecture 0', + 'show_description': True, + 'str': str(event1), + 'delete_form_url': self.get_event_delete_form_url(event1.id), + 'update_form_url': self.get_event_update_form_url(event1.id)}) + self.assertDictEqual( + events_json[1], + {'id': event2.id, + 'start': event2.time.isoformat(), + 'allDay': False, + 'title': 'lecture 1', + 'show_description': True, + 'str': str(event2), + 'delete_form_url': self.get_event_delete_form_url(event2.id), + 'update_form_url': self.get_event_update_form_url(event2.id), + 'durationEditable': False, + }) + + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) + + def test_hidden_event_not_shown_not_editing(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now + event1 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time) + + factories.EventFactory( + kind=self.default_event_kind, course=self.course, + shown_in_calendar=False, + time=self.default_event_time + timedelta(hours=1)) + + __, events_json = calendar.get_events(self.default_pctx) + + self.assertEqual(len(events_json), 1) + self.assertDictEqual( + events_json[0], + {'id': event1.id, + 'start': event1.time.isoformat(), + 'durationEditable': False, + 'allDay': False, + 'title': 'lecture 0'}) + + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) + + def test_hidden_event_shown_editing(self): + self.mock_get_now_or_fake_time.return_value = self.default_faked_now + factories.EventFactory( + kind=self.default_event_kind, course=self.course, + time=self.default_event_time) + event2 = factories.EventFactory( + kind=self.default_event_kind, course=self.course, + shown_in_calendar=False, + time=self.default_event_time + timedelta(hours=1)) + + __, events_json = calendar.get_events(self.default_pctx, is_edit_view=True) + + self.assertEqual(len(events_json), 2) + self.assertDictEqual( + events_json[1], + {'id': event2.id, + 'start': event2.time.isoformat(), + 'allDay': False, + 'title': 'lecture 1', + 'show_description': False, + 'str': str(event2), + 'delete_form_url': self.get_event_delete_form_url(event2.id), + 'update_form_url': self.get_event_update_form_url(event2.id), + 'durationEditable': False, + 'hidden_in_calendar': True, + }) + + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) def test_events_file_with_events_test1(self): self.switch_to_fake_commit_sha() + # pctx.course has been update, regenerate the default_pctx + default_pctx = self.get_instructor_pctx() + # lecture 1 lecture1_start_time = self.default_event_time - timedelta(weeks=1) factories.EventFactory( @@ -730,19 +1264,20 @@ def test_events_file_with_events_test1(self): kind=self.default_event_kind, course=self.course, time=self.default_event_time, ordinal=2) - resp = self.get_course_calender_view() + __, events_json = calendar.get_events(default_pctx) - events_json = json.loads(resp.context["events_json"]) self.assertEqual(len(events_json), 2) self.assertDictEqual( events_json[0], {'id': 2, 'start': self.default_event_time.isoformat(), + 'durationEditable': False, 'allDay': False, 'title': 'Lecture 2'}) self.assertDictEqual( events_json[1], {'id': 1, + 'durationEditable': False, 'color': "red", 'start': lecture1_start_time.isoformat(), 'allDay': False, @@ -750,7 +1285,7 @@ def test_events_file_with_events_test1(self): 'url': '#event-1' }) - event_info_list = resp.context["event_info_list"] + event_info_list = self.get_event_info_list_rendered() # lecture 2 doesn't create an EventInfo object self.assertEqual(len(event_info_list), 1) @@ -773,6 +1308,9 @@ def test_events_file_with_events_test1(self): def test_events_file_with_events_test2(self): self.switch_to_fake_commit_sha() + # pctx.course has been update, regenerate the default_pctx + default_pctx = self.get_instructor_pctx() + self.mock_get_now_or_fake_time.return_value = ( self.default_event_time + timedelta(minutes=5)) @@ -794,21 +1332,22 @@ def test_events_file_with_events_test2(self): time=test_start_time, ordinal=None) - resp = self.get_course_calender_view() + __, events_json = calendar.get_events(default_pctx) - events_json = json.loads(resp.context["events_json"]) self.assertEqual(len(events_json), 3) self.assertDictEqual( events_json[0], {'id': 2, 'start': (lecture3_start_time).isoformat(), 'allDay': False, + 'durationEditable': False, 'title': 'Lecture 3'}) self.assertDictEqual( events_json[1], {'id': 1, 'start': self.default_event_time.isoformat(), 'allDay': False, + 'durationEditable': False, 'title': 'Lecture 2', 'url': '#event-1'}) @@ -816,9 +1355,10 @@ def test_events_file_with_events_test2(self): events_json[2], {'id': 3, 'start': test_start_time.isoformat(), 'allDay': True, + 'durationEditable': False, 'title': 'test'}) - event_info_list = resp.context["event_info_list"] + event_info_list = self.get_event_info_list_rendered() # only lecture 2 create an EventInfo object self.assertEqual(len(event_info_list), 1) @@ -833,20 +1373,20 @@ def test_events_file_with_events_test2(self): "start_time": self.default_event_time, "end_time": None}) - self.assertIn( - 'Can you see this?', - evt_description) + self.assertIn('Can you see this?', evt_description) # lecture 2's description exceeded show_description_until self.mock_get_now_or_fake_time.return_value = ( lecture3_start_time + timedelta(minutes=5)) # no EventInfo object - resp = self.get_course_calender_view() - self.assertResponseContextEqual(resp, "event_info_list", []) + __, events_json = calendar.get_events(default_pctx) + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) def test_events_file_with_events_test3(self): self.switch_to_fake_commit_sha() + default_pctx = self.get_instructor_pctx() exam_end_time = self.default_event_time + timedelta(hours=2) factories.EventFactory( @@ -855,9 +1395,8 @@ def test_events_file_with_events_test3(self): time=self.default_event_time, end_time=exam_end_time) - resp = self.get_course_calender_view() + __, events_json = calendar.get_events(default_pctx) - events_json = json.loads(resp.context["events_json"]) self.assertEqual(len(events_json), 1) self.assertDictEqual( @@ -869,10 +1408,12 @@ def test_events_file_with_events_test3(self): 'color': 'red', 'end': exam_end_time.isoformat()}) - self.assertResponseContextEqual(resp, "event_info_list", []) + event_info_list = self.get_event_info_list_rendered() + self.assertEqual(event_info_list, []) def test_all_day_event(self): self.switch_to_fake_commit_sha() + default_pctx = self.get_instructor_pctx() # lecture 2, no end_time lecture2_start_time = datetime.datetime(2019, 1, 1, tzinfo=pytz.UTC) @@ -891,9 +1432,7 @@ def test_all_day_event(self): kind=self.default_event_kind, course=self.course, time=lecture3_start_time, ordinal=3) - resp = self.get_course_calender_view() - - events_json = json.loads(resp.context["events_json"]) + __, events_json = calendar.get_events(default_pctx) self.assertEqual(len(events_json), 2) self.assertDictEqual( @@ -901,6 +1440,7 @@ def test_all_day_event(self): {'id': 1, 'start': lecture2_start_time.isoformat(), 'allDay': True, 'title': 'Lecture 2', + 'durationEditable': False, 'url': '#event-1'}) # now we add end_time of lecture 2 evt to a time which is not midnight @@ -908,9 +1448,8 @@ def test_all_day_event(self): lecture2_evt.end_time = lecture2_end_time lecture2_evt.save() - resp = self.get_course_calender_view() + __, events_json = calendar.get_events(default_pctx) - events_json = json.loads(resp.context["events_json"]) self.assertEqual(len(events_json), 2) self.assertDictEqual( @@ -933,9 +1472,7 @@ def test_all_day_event(self): lecture2_end_time += timedelta(hours=1) - resp = self.get_course_calender_view() - - events_json = json.loads(resp.context["events_json"]) + __, events_json = calendar.get_events(default_pctx) self.assertEqual(len(events_json), 2) self.assertDictEqual( @@ -948,500 +1485,1232 @@ def test_all_day_event(self): }) -SHOWN_EVENT_KIND = "test_open_event" -HIDDEN_EVENT_KIND = "test_secret_event" -OPEN_EVENT_NO_ORDINAL_KIND = "test_open_no_ordinal" -HIDDEN_EVENT_NO_ORDINAL_KIND = "test_secret_no_ordinal" -FAILURE_EVENT_KIND = "never_created_event_kind" - -TEST_EVENTS = ( - {"kind": SHOWN_EVENT_KIND, "ordinal": "1", - "shown_in_calendar": True, "time": now()}, - {"kind": SHOWN_EVENT_KIND, "ordinal": "2", - "shown_in_calendar": True, "time": now()}, - {"kind": SHOWN_EVENT_KIND, "ordinal": "3", - "shown_in_calendar": True, "time": now()}, - {"kind": HIDDEN_EVENT_KIND, "ordinal": "1", - "shown_in_calendar": False, "time": now()}, - {"kind": HIDDEN_EVENT_KIND, "ordinal": "2", - "shown_in_calendar": False, "time": now()}, - {"kind": OPEN_EVENT_NO_ORDINAL_KIND, - "shown_in_calendar": True, "time": now()}, - {"kind": HIDDEN_EVENT_NO_ORDINAL_KIND, - "shown_in_calendar": False, "time": now()}, -) - -TEST_NOT_EXIST_EVENT = { - "pk": 1000, - "kind": "DOES_NOT_EXIST_KIND", "ordinal": "1", - "shown_in_calendar": True, "time": now()} - -N_TEST_EVENTS = len(TEST_EVENTS) # 7 events -N_HIDDEN_EVENTS = len([event - for event in TEST_EVENTS - if not event["shown_in_calendar"]]) # 3 events -N_SHOWN_EVENTS = N_TEST_EVENTS - N_HIDDEN_EVENTS # 4 events - -# html literals (from template) -MENU_EDIT_EVENTS_ADMIN = "Edit events (admin)" -MENU_VIEW_EVENTS_CALENDAR = "Edit events (calendar)" -MENU_CREATE_RECURRING_EVENTS = "Create recurring events" -MENU_RENUMBER_EVENTS = "Renumber events" - -HTML_SWITCH_TO_STUDENT_VIEW = "Switch to Student View" -HTML_SWITCH_TO_EDIT_VIEW = "Switch to Edit View" -HTML_CREATE_NEW_EVENT_BUTTON_TITLE = "create a new event" - -MESSAGE_EVENT_CREATED_TEXT = "Event created." -MESSAGE_EVENT_NOT_CREATED_TEXT = "No event created." -MESSAGE_PREFIX_EVENT_ALREADY_EXIST_FAILURE_TEXT = "EventAlreadyExists:" -MESSAGE_PREFIX_EVENT_NOT_DELETED_FAILURE_TEXT = "No event deleted:" -MESSAGE_EVENT_DELETED_TEXT = "Event deleted." -MESSAGE_EVENT_UPDATED_TEXT = "Event updated." -MESSAGE_EVENT_NOT_UPDATED_TEXT = "Event not updated." - - -def get_object_or_404_side_effect(klass, *args, **kwargs): - """ - Delete an existing object from db after get - """ - from django.shortcuts import get_object_or_404 - obj = get_object_or_404(klass, *args, **kwargs) - obj.delete() - return obj - - -class CalendarTestMixin(object): - @classmethod - def setUpTestData(cls): # noqa - super(CalendarTestMixin, cls).setUpTestData() - - # superuser was previously removed from participation, now we add him back - from course.constants import participation_status - - cls.create_participation( - cls.course, - cls.superuser, - role_identifier="instructor", - status=participation_status.active) - - for event in TEST_EVENTS: - event.update({ - "course": cls.course, - }) - Event.objects.create(**event) - assert Event.objects.count() == N_TEST_EVENTS - - def set_course_end_date(self): - from datetime import timedelta - self.course.end_date = now() + timedelta(days=120) - self.course.save() +class GetLocalTimeWeekdayHourMinuteTest(unittest.TestCase): + def test(self): + dt = datetime.datetime(2019, 1, 1, 11, 35, tzinfo=pytz.utc) + with override_settings(TIME_ZONE="Hongkong"): + local_time, week_day, hour, minute = ( + calendar.get_local_time_weekday_hour_minute(dt)) - def assertShownEventsCountEqual(self, resp, expected_shown_events_count): # noqa - self.assertEqual( - len(json.loads(resp.context["events_json"])), - expected_shown_events_count) + self.assertEqual(as_local_time(dt), local_time) + self.assertEqual(week_day, 3) + self.assertEqual(hour, 19) + self.assertEqual(minute, 35) - def assertTotalEventsCountEqual(self, expected_total_events_count): # noqa - self.assertEqual(Event.objects.count(), expected_total_events_count) + dt = datetime.datetime(2019, 1, 6, 11, 35, tzinfo=pytz.utc) + with override_settings(TIME_ZONE="Hongkong"): + local_time, week_day, hour, minute = ( + calendar.get_local_time_weekday_hour_minute(dt)) + self.assertEqual(as_local_time(dt), local_time) + self.assertEqual(week_day, 1) + self.assertEqual(hour, 19) + self.assertEqual(minute, 35) -class CalendarTest(CalendarTestMixin, SingleCourseTestMixin, - MockAddMessageMixing, TestCase): - def test_superuser_instructor_calendar_get(self): - self.c.force_login(self.superuser) - resp = self.c.get( - reverse("relate-view_calendar", args=[self.course.identifier])) - self.assertEqual(resp.status_code, 200) +class CreateEventTest(CalendarTestMixin, TestCase): + """test calendar.create_event""" - # menu items - self.assertContains(resp, MENU_EDIT_EVENTS_ADMIN) - self.assertContains(resp, MENU_VIEW_EVENTS_CALENDAR) - self.assertContains(resp, MENU_CREATE_RECURRING_EVENTS) - self.assertContains(resp, MENU_RENUMBER_EVENTS) + force_login_student_for_each_test = False - # rendered page html - self.assertNotContains(resp, HTML_SWITCH_TO_STUDENT_VIEW) - self.assertContains(resp, HTML_SWITCH_TO_EDIT_VIEW) - self.assertNotContains(resp, HTML_CREATE_NEW_EVENT_BUTTON_TITLE) + def setUp(self): + super(CreateEventTest, self).setUp() + self.c.force_login(self.instructor_participation.user) - # see only shown events - self.assertShownEventsCountEqual(resp, N_SHOWN_EVENTS) + def get_create_event_url(self, course_identifier=None): + return self.get_course_view_url("relate-create_event", course_identifier) - def test_non_superuser_instructor_calendar_get(self): - self.c.force_login(self.instructor_participation.user) - resp = self.c.get( - reverse("relate-view_calendar", args=[self.course.identifier])) - self.assertEqual(resp.status_code, 200) + def post_create_event(self, data, course_identifier=None, using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.post( + self.get_create_event_url(course_identifier), data, **kwargs) - # menu items - self.assertNotContains(resp, MENU_EDIT_EVENTS_ADMIN) - self.assertContains(resp, MENU_VIEW_EVENTS_CALENDAR) - self.assertContains(resp, MENU_CREATE_RECURRING_EVENTS) - self.assertContains(resp, MENU_RENUMBER_EVENTS) + def get_default_post_data(self, time=None, end_time=None, **kwargs): + data = { + 'kind': "some_kind", + 'time': self.default_faked_now.strftime(DATE_TIME_PICKER_TIME_FORMAT), + 'shown_in_calendar': True, + 'all_day': False} - # rendered page html - self.assertNotContains(resp, HTML_SWITCH_TO_STUDENT_VIEW) - self.assertContains(resp, HTML_SWITCH_TO_EDIT_VIEW) - self.assertNotContains(resp, HTML_CREATE_NEW_EVENT_BUTTON_TITLE) + if time is None: + time = kwargs.pop("time", None) - # see only shown events - self.assertShownEventsCountEqual(resp, N_SHOWN_EVENTS) + if time is not None: + data["time"] = time.strftime(DATE_TIME_PICKER_TIME_FORMAT) - def test_student_calendar_get(self): - self.c.force_login(self.student_participation.user) - resp = self.c.get( - reverse("relate-view_calendar", args=[self.course.identifier])) - self.assertEqual(resp.status_code, 200) + if end_time is None: + end_time = kwargs.pop("end_time", None) - # menu items - self.assertNotContains(resp, MENU_EDIT_EVENTS_ADMIN) - self.assertNotContains(resp, MENU_VIEW_EVENTS_CALENDAR) - self.assertNotContains(resp, MENU_CREATE_RECURRING_EVENTS) - self.assertNotContains(resp, MENU_RENUMBER_EVENTS) - self.assertRegexpMatches - - # rendered page html - self.assertNotContains(resp, HTML_SWITCH_TO_STUDENT_VIEW) - self.assertNotContains(resp, HTML_SWITCH_TO_EDIT_VIEW) - self.assertNotContains(resp, HTML_CREATE_NEW_EVENT_BUTTON_TITLE) - - # see only shown events - self.assertShownEventsCountEqual(resp, N_SHOWN_EVENTS) - - def test_superuser_instructor_calendar_edit_get(self): - self.c.force_login(self.superuser) - resp = self.c.get( - reverse("relate-edit_calendar", args=[self.course.identifier])) + if end_time is not None: + data["end_time"] = end_time.strftime(DATE_TIME_PICKER_TIME_FORMAT) + + data.update(kwargs) + return get_prefixed_form_data(calendar.CreateEventModalForm, data) + + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.post_create_event(data=self.get_default_post_data()) + self.assertEqual(resp.status_code, 403) + + def test_get(self): + resp = self.c.get(self.get_create_event_url(), + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 403) + + def test_get_non_ajax(self): + resp = self.c.get(self.get_create_event_url()) + self.assertEqual(resp.status_code, 403) + + def test_post_non_ajax(self): + resp = self.post_create_event( + data=self.get_default_post_data(), using_ajax=False) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 0) + + def test_post_success(self): + resp = self.post_create_event(data=self.get_default_post_data()) self.assertEqual(resp.status_code, 200) - # menu items - self.assertContains(resp, MENU_EDIT_EVENTS_ADMIN) - self.assertContains(resp, MENU_VIEW_EVENTS_CALENDAR) - self.assertContains(resp, MENU_CREATE_RECURRING_EVENTS) - self.assertContains(resp, MENU_RENUMBER_EVENTS) + events_qs = Event.objects.all() + self.assertEqual(events_qs.count(), 1) - # rendered page html - self.assertNotContains(resp, HTML_SWITCH_TO_EDIT_VIEW) - self.assertContains(resp, HTML_SWITCH_TO_STUDENT_VIEW) - self.assertContains(resp, HTML_CREATE_NEW_EVENT_BUTTON_TITLE) + event = events_qs[0] - # see all events (including hidden ones) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS) + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event created: '%s'." % str(event)) - def test_non_superuser_instructor_calendar_edit_get(self): + def test_post_form_non_field_error(self): + event = factories.EventFactory(course=self.course) + + # post create already exist event + resp = self.post_create_event( + data=self.get_default_post_data(**event.__dict__)) + self.assertEqual(resp.status_code, 400) + + events_qs = Event.objects.all() + self.assertEqual(events_qs.count(), 1) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["errors"]['__all__'], + ["'%s' already exists." % str(event)]) + + def test_post_form_field_error(self): + resp = self.post_create_event(data={}) + self.assertEqual(resp.status_code, 400) + + events_qs = Event.objects.all() + self.assertEqual(events_qs.count(), 0) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["errors"]['kind'], + ['This field is required.']) + + def test_post_form_save_errored(self): + with mock.patch("course.models.Event.save") as mock_event_save: + mock_event_save.side_effect = RuntimeError("my custom event save error.") + resp = self.post_create_event(data=self.get_default_post_data()) + + self.assertEqual(resp.status_code, 400) + + events_qs = Event.objects.all() + self.assertEqual(events_qs.count(), 0) + + json_response = json.loads(resp.content.decode()) + + self.assertEqual(json_response['__all__'], + ['RuntimeError: my custom event save error.']) + + +class GetCreateEventModalFormTest(CalendarTestMixin, TestCase): + """test calendar.get_create_event_modal_form""" + force_login_student_for_each_test = False + + def setUp(self): + super(GetCreateEventModalFormTest, self).setUp() self.c.force_login(self.instructor_participation.user) - resp = self.c.get( - reverse("relate-edit_calendar", args=[self.course.identifier])) - self.assertEqual(resp.status_code, 200) - # menu items - self.assertNotContains(resp, MENU_EDIT_EVENTS_ADMIN) - self.assertContains(resp, MENU_VIEW_EVENTS_CALENDAR) - self.assertContains(resp, MENU_CREATE_RECURRING_EVENTS) - self.assertContains(resp, MENU_RENUMBER_EVENTS) + def get_create_event_modal_form_url(self, course_identifier=None): + return self.get_course_view_url("relate-get_create_event_modal_form", + course_identifier) + + def get_create_event_modal_form_view(self, course_identifier=None, + using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.get_create_event_modal_form_url(course_identifier), **kwargs) + + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_create_event_modal_form_view() + self.assertEqual(resp.status_code, 403) - # rendered page html - self.assertNotContains(resp, HTML_SWITCH_TO_EDIT_VIEW) - self.assertContains(resp, HTML_SWITCH_TO_STUDENT_VIEW) - self.assertContains(resp, HTML_CREATE_NEW_EVENT_BUTTON_TITLE) + def test_post(self): + resp = self.c.post(self.get_create_event_modal_form_url(), data={}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 403) - # see all events (including hidden ones) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS) + def test_post_non_ajax(self): + resp = self.c.post(self.get_create_event_modal_form_url(), data={}) + self.assertEqual(resp.status_code, 403) - def test_student_calendar_edit_get(self): - self.c.force_login(self.student_participation.user) - resp = self.c.get( - reverse("relate-edit_calendar", args=[self.course.identifier])) + def test_get_non_ajax(self): + resp = self.get_create_event_modal_form_view(using_ajax=False) self.assertEqual(resp.status_code, 403) - def test_instructor_calendar_edit_create_exist_failure(self): + def test_get_success(self): + resp = self.get_create_event_modal_form_view() + self.assertEqual(resp.status_code, 200) + + +class DeleteEventFormTest(CalendarTestMixin, TestCase): + """test course.calendar.DeleteEventForm""" + + def test_delete_event_not_in_recurring_series_ui(self): + # create 5 recurring events + self.create_recurring_events(5) + instance_to_delete = factories.EventFactory( + course=self.course, kind="some_kind") + + form = calendar.DeleteEventForm(self.course.identifier, instance_to_delete) + + self.assertNotIn("operation", form.fields) + + def test_delete_event_with_no_ordinal_ui(self): + # create 5 recurring events + self.create_recurring_events(5) + instance_to_delete = factories.EventFactory( + course=self.course, kind="some_kind", ordinal=None) + + form = calendar.DeleteEventForm(self.course.identifier, instance_to_delete) + + self.assertNotIn("operation", form.fields) + + @unittest.skipIf(six.PY2, "PY2 doesn't support subTest") + def test_delete_event_in_recurring_single_series_ui(self): + # create 5 recurring events + evt1, __, __, __, evt5 = self.create_recurring_events(5) + + with self.subTest(delete_option="delete single, all and following"): + form = calendar.DeleteEventForm(self.course.identifier, evt1) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, + ["delete_single", "delete_all", "delete_this_and_following"]) + + with self.subTest(delete_option="delete single, all"): + form = calendar.DeleteEventForm(self.course.identifier, evt5) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, ["delete_single", "delete_all"]) + + @unittest.skipIf(six.PY2, "PY2 doesn't support subTest") + def test_delete_event_in_recurring_multiple_series_ui(self): + # create 3 recurring events, first series + self.create_recurring_events(3) + + # create 5 recurring events, with the same kind, another series + evt1, __, __, __, evt5 = self.create_recurring_events( + 5, staring_ordinal=4, staring_time_offset_days=2) + + with self.subTest( + delete_option="delete single, all, following, series, " + "and following_in_series"): + form = calendar.DeleteEventForm(self.course.identifier, evt1) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, + ["delete_single", + "delete_all", + "delete_this_and_following", + "delete_all_in_same_series", + "delete_this_and_following_of_same_series"]) + + with self.subTest( + delete_option="delete single, all, series"): + form = calendar.DeleteEventForm(self.course.identifier, evt5) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, + ["delete_single", + "delete_all", + "delete_all_in_same_series"]) + + # Only evt5 has end_time, it no longer belong to a series, + # thus no series operation + evt5.end_time = evt5.time + timedelta(minutes=1) + evt5.save() + with self.subTest( + delete_option="delete single, all"): + form = calendar.DeleteEventForm(self.course.identifier, evt5) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, + ["delete_single", + "delete_all"]) + + # Same with above, but can delete following + evt1.end_time = evt1.time + timedelta(minutes=2) + evt1.save() + with self.subTest( + delete_option="delete single, all, this and following"): + form = calendar.DeleteEventForm(self.course.identifier, evt1) + + choices = [choice for choice, __ in form.fields["operation"].choices] + self.assertListEqual( + choices, + ["delete_single", + "delete_all", + "delete_this_and_following"]) + + +class GetDeleteEventModalFormTest(CalendarTestMixin, TestCase): + """test calendar.get_delete_event_modal_form""" + force_login_student_for_each_test = False + + def setUp(self): + super(GetDeleteEventModalFormTest, self).setUp() + self.event = factories.EventFactory(course=self.course) self.c.force_login(self.instructor_participation.user) - # Failing to create event already exist - post_data = { - "kind": SHOWN_EVENT_KIND, - "ordinal": "3", - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } + def get_delete_event_modal_form_url(self, event_id, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + return reverse("relate-get_delete_event_modal_form", + args=[course_identifier, event_id]) + + def get_delete_event_modal_form_view(self, event_id, course_identifier=None, + using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.get_delete_event_modal_form_url(event_id, course_identifier), + **kwargs) + + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_delete_event_modal_form_view(self.event.id) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_post(self): resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + self.get_delete_event_modal_form_url(self.event.id), data={}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_post_non_ajax(self): + resp = self.c.post( + self.get_delete_event_modal_form_url(self.event.id), data={}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_get_non_ajax(self): + resp = self.get_delete_event_modal_form_view(self.event.id, using_ajax=False) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_get_success(self): + resp = self.get_delete_event_modal_form_view(self.event.id) self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith( - [MESSAGE_PREFIX_EVENT_ALREADY_EXIST_FAILURE_TEXT, - MESSAGE_EVENT_NOT_CREATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS) + self.assertEqual(Event.objects.count(), 1) - def test_instructor_calendar_edit_create_exist_no_ordinal_event_faliure(self): + +class DeleteEventTest(CalendarTestMixin, TestCase): + """test calendar.delete_event""" + force_login_student_for_each_test = False + + def setUp(self): + super(DeleteEventTest, self).setUp() self.c.force_login(self.instructor_participation.user) - # Failing to create event (no ordinal) already exist - post_data = { - "kind": OPEN_EVENT_NO_ORDINAL_KIND, - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + # an event in another course, which should not be deleted + factories.EventFactory( + course=factories.CourseFactory(identifier="another-course")) + + # an event with another kind, which should not be deleted + factories.EventFactory(course=self.course, kind="another_kind") + + def get_delete_event_url(self, event_id, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + return reverse("relate-delete_event", args=[course_identifier, event_id]) + + def post_delete_event_view( + self, event_id, operation=None, data=None, course_identifier=None, + using_ajax=True): + + if data is None: + data = {} + + if operation: + data["operation"] = operation + + data = get_prefixed_form_data(calendar.DeleteEventForm, data) + + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.post( + self.get_delete_event_url(event_id, course_identifier), data=data, + **kwargs) + + def test_no_pperm(self): + event = factories.EventFactory(course=self.course) + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.post_delete_event_view(event.id) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_get_non_ajax(self): + event = factories.EventFactory(course=self.course) + resp = self.c.get(self.get_delete_event_url(event.id)) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_get(self): + event = factories.EventFactory(course=self.course) + resp = self.c.get(self.get_delete_event_url(event.id), + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_post_no_ajax(self): + event = factories.EventFactory(course=self.course) + resp = self.post_delete_event_view(event.id, using_ajax=False) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_delete_non_existing_event(self): + factories.EventFactory(course=self.course) + resp = self.post_delete_event_view(event_id=1000) + self.assertEqual(resp.status_code, 404) + self.assertEqual(Event.objects.count(), 3) + + def test_delete_single_with_ordinal_success(self): + event = factories.EventFactory(course=self.course) + resp = self.post_delete_event_view(event.id) self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith( - [MESSAGE_PREFIX_EVENT_ALREADY_EXIST_FAILURE_TEXT, - MESSAGE_EVENT_NOT_CREATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS) + self.assertEqual(Event.objects.count(), 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' deleted." % str(event)) + + def test_delete_single_with_no_oridnal_success(self): + event = factories.EventFactory(course=self.course, ordinal=None) + resp = self.post_delete_event_view(event.id) + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' deleted." % str(event)) + + def test_delete_single_within_single_series_success(self): + events = self.create_recurring_events(2) + instance_to_delete = events[0] + resp = self.post_delete_event_view( + instance_to_delete.id, operation="delete_single") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 3) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' deleted." % str(instance_to_delete)) - def test_instructor_calendar_edit_post_create_success(self): - # Successfully create new event + def test_delete_all_within_single_series_success(self): + events = self.create_recurring_events(2) + instance_to_delete = events[0] + resp = self.post_delete_event_view( + instance_to_delete.id, operation="delete_all") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All 'lecture' events deleted.") + + def test_delete_this_and_following_within_single_series_success(self): + events = self.create_recurring_events(3) + instance_to_delete = events[1] + resp = self.post_delete_event_view( + instance_to_delete.id, operation="delete_this_and_following") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 3) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "2 'lecture' events deleted.") + + def test_delete_all_across_multiple_series_success(self): + events = self.create_recurring_events(3) + self.create_recurring_events(5, staring_ordinal=4, + staring_time_offset_days=1) + instance_to_delete = events[1] + resp = self.post_delete_event_view( + instance_to_delete.id, operation="delete_all") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All 'lecture' events deleted.") + + def test_delete_all_within_same_series_success(self): + events = self.create_recurring_events(3) + self.create_recurring_events(5, staring_ordinal=4, + staring_time_offset_days=1) + instance_to_delete = events[1] + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_delete_event_view( + instance_to_delete.id, operation="delete_all_in_same_series") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 7) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All 'lecture' events (started at Tue, 08:00) deleted.") + + def test_delete_this_and_following_within_same_series_success(self): + events = self.create_recurring_events(3) + self.create_recurring_events(5, staring_ordinal=4, + staring_time_offset_days=1) + instance_to_delete = events[1] + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_delete_event_view( + instance_to_delete.id, + operation="delete_this_and_following_of_same_series") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 8) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "2 'lecture' events (started at Tue, 08:00) deleted.") + + def test_delete_event_in_one_recurring_series_end_time_not_match(self): + # create 3 recurring events, with the same kind, another series + evt1, __, __, __, evt5 = self.create_recurring_events( + 5, end_time_minute_duration=15) + + # evt1 and evt5's end_time is updated, so there becomes 2 series + evt1.end_time += timedelta(hours=1) + evt1.save() + + evt5.end_time += timedelta(hours=1) + evt5.save() + + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_delete_event_view( + evt1.id, + operation="delete_all_in_same_series") + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 5) + + json_response = json.loads(resp.content.decode()) + self.assertEqual( + json_response["message"], + "All 'lecture' events (started at Tue, 08:00, ended at " + "Tue, 09:15) deleted.") + + def test_unknown_delete_errored(self): + events = self.create_recurring_events(3) + instance_to_delete = events[1] + with mock.patch("django.db.models.query.QuerySet.delete") as mock_delete: + mock_delete.side_effect = RuntimeError("unknown delete error.") + resp = self.post_delete_event_view( + instance_to_delete.id, + operation="delete_all") + self.assertEqual(resp.status_code, 400) + self.assertEqual(Event.objects.count(), 5) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["__all__"], + ["RuntimeError: unknown delete error."]) + + def test_unknown_delete_operation(self): + events = self.create_recurring_events(3) + instance_to_delete = events[1] + resp = self.post_delete_event_view( + instance_to_delete.id, + operation="unknown") + self.assertEqual(resp.status_code, 400) + self.assertEqual(Event.objects.count(), 5) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["errors"]["operation"], + ["Select a valid choice. unknown is not one of " + "the available choices."]) + + +class UpdateEventFormTest(CalendarTestMixin, TestCase): + """test calendar.UpdateEventForm""" + # force_login_student_for_each_test = False + + def setUp(self): + super(UpdateEventFormTest, self).setUp() + # self.c.force_login(self.instructor_participation.user) + rf = RequestFactory() + self.request = rf.get(self.get_course_page_url()) + self.request.user = self.instructor_participation.user + + # {{{ test get_ajax_form_helper + + def get_ajax_helper_input_button_names(self, helper): + return [field.name for field in helper.layout[-1].fields] + + def assertFormAJAXHelperButtonNameEqual(self, form, button_names): # noqa + self.assertListEqual( + self.get_ajax_helper_input_button_names( + form.get_ajax_form_helper()), button_names) + + def test_get_ajax_form_helper_individual_event_no_ordinal(self): + event = factories.EventFactory(course=self.course) + form = calendar.UpdateEventForm(self.course.identifier, event.id) + self.assertFormAJAXHelperButtonNameEqual(form, ['update', 'cancel']) + + def test_get_ajax_form_helper_individual_event_with_ordinal(self): + event = factories.EventFactory(course=self.course, ordinal=None) + form = calendar.UpdateEventForm(self.course.identifier, event.id) + self.assertFormAJAXHelperButtonNameEqual(form, ['update', 'cancel']) + + def test_get_ajax_form_helper_single_series(self): + events = self.create_recurring_events(5) + form = calendar.UpdateEventForm(self.course.identifier, events[0].id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update_all', 'update_this_and_following', 'update', 'cancel']) + + def test_get_ajax_form_helper_single_series_not_may_update_this_and_following(self): # noqa + events = self.create_recurring_events(5) + form = calendar.UpdateEventForm(self.course.identifier, events[4].id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update_all', 'update', 'cancel']) + + def test_get_ajax_form_helper_multiple_series(self): + events = self.create_recurring_events(5) + self.create_recurring_events(3, staring_ordinal=6, + staring_time_offset_days=1) + form = calendar.UpdateEventForm(self.course.identifier, events[0].id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update_series', 'update_this_and_following_in_series', + 'update', 'cancel']) + + def test_get_ajax_form_helper_multiple_series_has_end_time(self): + events = self.create_recurring_events(5) + self.create_recurring_events(3, staring_ordinal=6, + staring_time_offset_days=1, + end_time_minute_duration=15) + form = calendar.UpdateEventForm(self.course.identifier, events[0].id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update_series', 'update_this_and_following_in_series', + 'update', 'cancel']) + + def test_events_have_no_correspond_end_time(self): + events = self.create_recurring_events(2) + event1 = events[0] + event1.end_time = event1.time + timedelta(minutes=15) + event1.save() + + form = calendar.UpdateEventForm(self.course.identifier, event1.id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update', 'cancel']) + + def test_get_ajax_form_helper_multiple_series_not_may_update_this_and_following(self): # noqa + events = self.create_recurring_events(5) + self.create_recurring_events(3, staring_ordinal=6, + staring_time_offset_days=1) + form = calendar.UpdateEventForm(self.course.identifier, events[4].id) + self.assertFormAJAXHelperButtonNameEqual( + form, + ['update_series', 'update', 'cancel']) + + # }}} + + +class GetUpdateEventModalFormTest(CalendarTestMixin, TestCase): + """test calendar.get_update_event_modal_form""" + force_login_student_for_each_test = False + + def setUp(self): + super(GetUpdateEventModalFormTest, self).setUp() + self.event = factories.EventFactory(course=self.course) self.c.force_login(self.instructor_participation.user) - post_data = { - "kind": SHOWN_EVENT_KIND, - "ordinal": "4", - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } + + def get_update_event_modal_form_url(self, event_id, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + return reverse("relate-get_update_event_modal_form", + args=[course_identifier, event_id]) + + def get_update_event_modal_form_view(self, event_id, course_identifier=None, + using_ajax=True): + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.get( + self.get_update_event_modal_form_url(event_id, course_identifier), + **kwargs) + + def test_no_pperm(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_update_event_modal_form_view(self.event.id) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_post_non_ajax(self): resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + self.get_update_event_modal_form_url(self.event.id), data={}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_get_non_ajax(self): + resp = self.get_update_event_modal_form_view(self.event.id, using_ajax=False) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 1) + + def test_get_success(self): + resp = self.get_update_event_modal_form_view(self.event.id) self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_CREATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS + 1) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS + 1) + self.assertEqual(Event.objects.count(), 1) - def test_instructor_calendar_edit_post_create_for_course_has_end_date(self): - # Successfully create new event - self.set_course_end_date() - self.c.force_login(self.instructor_participation.user) - post_data = { - "kind": SHOWN_EVENT_KIND, - "ordinal": "4", - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } + def test_post_success(self): resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + self.get_update_event_modal_form_url(self.event.id), data={}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_CREATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS + 1) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS + 1) + self.assertEqual(Event.objects.count(), 1) - def test_instructor_calendar_edit_delete_success(self): - # Successfully remove an existing event - self.c.force_login(self.instructor_participation.user) - id_to_delete = Event.objects.filter(kind=SHOWN_EVENT_KIND).first().id - post_data = { - "id_to_delete": id_to_delete, - 'submit': [''] - } + def test_post_drop_timedelta_hours_success(self): resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + self.get_update_event_modal_form_url(self.event.id), + data={"drop_timedelta_hours": 1}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_DELETED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS - 1) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS - 1) + self.assertEqual(Event.objects.count(), 1) - def test_instructor_calendar_edit_delete_for_course_has_end_date(self): - # Successfully remove an existing event - self.set_course_end_date() - self.c.force_login(self.instructor_participation.user) - id_to_delete = Event.objects.filter(kind=SHOWN_EVENT_KIND).first().id - post_data = { - "id_to_delete": id_to_delete, - 'submit': [''] - } + def test_post_drop_timedelta_hours_event_has_end_time_success(self): + self.event.end_time = self.event.time + timedelta(hours=1) + self.event.save() resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) + self.get_update_event_modal_form_url(self.event.id), + data={"drop_timedelta_hours": 1}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_DELETED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS - 1) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS - 1) + self.assertEqual(Event.objects.count(), 1) - def test_instructor_calendar_edit_delete_non_exist(self): - # Successfully remove an existing event - self.c.force_login(self.instructor_participation.user) - post_data = { - "id_to_delete": 1000, # forgive me - 'submit': [''] - } + def test_post_resize_timedelta_hours_failed(self): resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) - self.assertEqual(resp.status_code, 404) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) + self.get_update_event_modal_form_url(self.event.id), + data={"resize_timedelta_hours": 1}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(resp.status_code, 400) + self.assertEqual(Event.objects.count(), 1) - @mock.patch("course.calendar.get_object_or_404", - side_effect=get_object_or_404_side_effect) - def test_instructor_calendar_edit_delete_deleted_event_before_transaction( - self, mocked_get_object_or_404): - # Deleting event which exist when get and was deleted before transaction - self.c.force_login(self.instructor_participation.user) - id_to_delete = Event.objects.filter(kind=SHOWN_EVENT_KIND).first().id - post_data = { - "id_to_delete": id_to_delete, - 'submit': [''] - } + def test_post_resize_timedelta_hours_success(self): + self.event.end_time = self.event.time + timedelta(hours=1) + self.event.save() resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data - ) - self.assertAddMessageCalledWith( - MESSAGE_PREFIX_EVENT_NOT_DELETED_FAILURE_TEXT) + self.get_update_event_modal_form_url(self.event.id), + data={"resize_timedelta_hours": 1}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest") self.assertEqual(resp.status_code, 200) - self.assertTotalEventsCountEqual(N_TEST_EVENTS - 1) + self.assertEqual(Event.objects.count(), 1) + + +class UpdateEventTest(CalendarTestMixin, TestCase): + """test calendar.update_event""" + force_login_student_for_each_test = False + + def setUp(self): + super(UpdateEventTest, self).setUp() + + # an event in another course, which should not be edited + self.another_course_event = factories.EventFactory( + course=factories.CourseFactory( + identifier="another-course"), kind=self.default_event_kind) + + # an event with another kind, which should not be edited + self.another_kind_event = factories.EventFactory( + course=self.course, kind="another_kind") + + # this is to make sure other events are not affected during update + self.another_course_event_dict = self.another_course_event.__dict__ + self.another_kind_event_dict = self.another_kind_event.__dict__ - def test_instructor_calendar_edit_update_success(self): - # Successfully update an existing event self.c.force_login(self.instructor_participation.user) - all_hidden_events = Event.objects.filter(kind=HIDDEN_EVENT_KIND) - hidden_count_before_update = all_hidden_events.count() - shown_count_before_update = ( - Event.objects.filter(kind=SHOWN_EVENT_KIND).count()) - event_to_edit = all_hidden_events.first() - id_to_edit = event_to_edit.id - post_data = { - "existing_event_to_save": id_to_edit, - "kind": SHOWN_EVENT_KIND, - "ordinal": 10, - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] + + def assertOtherEventNotAffected(self): # noqa + self.another_kind_event.refresh_from_db() + self.another_kind_event.refresh_from_db() + self.assertDictEqual( + self.another_course_event_dict, self.another_course_event.__dict__) + self.assertDictEqual( + self.another_kind_event_dict, self.another_kind_event.__dict__) + + def create_event(self, **kwargs): + data = { + "course": self.course, + "kind": self.default_event_kind, + 'time': self.default_faked_now, + 'shown_in_calendar': True, + 'all_day': False } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + data.update(kwargs) + return factories.EventFactory(**data) + + def get_update_event_url(self, event_id, course_identifier=None): + course_identifier = course_identifier or self.get_default_course_identifier() + return reverse("relate-update_event", args=[course_identifier, event_id]) + + def get_default_post_data(self, event, time=None, end_time=None, + operation='update', **kwargs): + #Note: to remove ordinal, explicitly set ordinal=None + + data = { + 'kind': event.kind, + 'shown_in_calendar': event.shown_in_calendar, + 'all_day': event.all_day, + } + + if time is None: + time = kwargs.pop("time", None) + + if time is None: + time = event.time + + data["time"] = as_local_time(time).strftime(DATE_TIME_PICKER_TIME_FORMAT) + + if end_time is None: + end_time = kwargs.pop("end_time", None) + + if end_time is None: + end_time = event.end_time + + if end_time is not None: + data["end_time"] = ( + as_local_time(end_time).strftime(DATE_TIME_PICKER_TIME_FORMAT)) + + try: + ordinal = kwargs.pop("ordinal") + except KeyError: + ordinal = event.ordinal + + if ordinal is not None: + data["ordinal"] = ordinal + + data.update(kwargs) + data = get_prefixed_form_data(calendar.UpdateEventForm, data) + + if operation: + data[operation] = '' + + return data + + def post_update_event_view( + self, event_id, data, course_identifier=None, + using_ajax=True): + + kwargs = {} + if using_ajax: + kwargs["HTTP_X_REQUESTED_WITH"] = 'XMLHttpRequest' + return self.c.post( + self.get_update_event_url(event_id, course_identifier), data=data, + **kwargs) + + def test_no_pperm(self): + event = self.create_event() + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.post_update_event_view(event.id, data={}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_get_non_ajax(self): + event = self.create_event() + resp = self.c.get(self.get_update_event_url(event.id)) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_get(self): + event = self.create_event() + resp = self.c.get(self.get_update_event_url(event.id), + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_post_no_ajax(self): + event = self.create_event() + resp = self.post_update_event_view(event.id, using_ajax=False, data={}) + self.assertEqual(resp.status_code, 403) + self.assertEqual(Event.objects.count(), 3) + + def test_update_non_existing_event(self): + self.create_event() + resp = self.post_update_event_view(event_id=1000, data={}) + self.assertEqual(resp.status_code, 404) + self.assertEqual(Event.objects.count(), 3) + + def test_update_form_invalid(self): + event = self.create_event() + end_time = as_local_time(event.time - timedelta(hours=2)) + + resp = self.post_update_event_view( + event.id, data=self.get_default_post_data(event, end_time=end_time)) + self.assertEqual(resp.status_code, 400) + + event.refresh_from_db() + self.assertIsNone(event.end_time) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["errors"]["end_time"], + ["End time must not be ahead of start time."]) + + def test_update_single_with_ordinal_success(self): + event = self.create_event() + end_time = as_local_time(event.time + timedelta(hours=2)) + + resp = self.post_update_event_view( + event.id, data=self.get_default_post_data(event, end_time=end_time)) + self.assertEqual(resp.status_code, 200) + + event.refresh_from_db() + self.assertEqual(event.end_time, end_time) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' updated." % str(event)) + + def test_update_not_changed(self): + event = self.create_event() + + resp = self.post_update_event_view( + event.id, + data=self.get_default_post_data(event)) + self.assertEqual(resp.status_code, 200) + + event.refresh_from_db() + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], "No change was made.") + + self.assertOtherEventNotAffected() + + def test_update_single_with_ordinal_success_kind_changed(self): + event = self.create_event() + event_str = str(event) + + resp = self.post_update_event_view( + event.id, + data=self.get_default_post_data(event, kind="some_kind")) + self.assertEqual(resp.status_code, 200) + + event.refresh_from_db() + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event updated: '%s' -> '%s'" % (event_str, str(event))) + self.assertOtherEventNotAffected() + + def test_update_single_with_no_ordinal_success(self): + event = self.create_event(ordinal=None) + end_time = as_local_time(event.time + timedelta(hours=2)) + + resp = self.post_update_event_view( + event.id, data=self.get_default_post_data(event, end_time=end_time)) + self.assertEqual(resp.status_code, 200) + + event.refresh_from_db() + self.assertEqual(event.end_time, end_time) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' updated." % str(event)) + self.assertOtherEventNotAffected() + + def test_update_single_within_single_series_success(self): + instance_to_update, another_event = self.create_recurring_events(2) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data(instance_to_update, end_time=end_time) ) + self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_UPDATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - self.assertShownEventsCountEqual(resp, N_TEST_EVENTS) + self.assertEqual(Event.objects.count(), 4) + + instance_to_update.refresh_from_db() + self.assertIsNotNone(instance_to_update.end_time) + + another_event.refresh_from_db() + self.assertIsNone(another_event.end_time) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "Event '%s' updated." % str(instance_to_update)) + self.assertOtherEventNotAffected() + + def test_update_all_success(self): + instance_to_update, another_event = self.create_recurring_events(2) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, end_time=end_time, operation="update_all") + ) + self.assertEqual(resp.status_code, 200) + + instance_to_update.refresh_from_db() self.assertEqual( - Event.objects.filter(kind=HIDDEN_EVENT_KIND).count(), - hidden_count_before_update - 1) + instance_to_update.end_time - instance_to_update.time, + timedelta(hours=2) + ) + + another_event.refresh_from_db() self.assertEqual( - Event.objects.filter(kind=SHOWN_EVENT_KIND).count(), - shown_count_before_update + 1) + another_event.end_time - another_event.time, + timedelta(hours=2) + ) - def test_instructor_calendar_edit_update_no_ordinal_event_success(self): - # Failure to update an existing event to overwrite and existing event - self.c.force_login(self.instructor_participation.user) - event_to_edit = Event.objects.filter(kind=HIDDEN_EVENT_KIND).first() - id_to_edit = event_to_edit.id # forgive me - post_data = { - "existing_event_to_save": id_to_edit, - "kind": HIDDEN_EVENT_NO_ORDINAL_KIND, - "ordinal": "", - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": False, - 'submit': [''] - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All '%s' events updated." + % instance_to_update.kind) + + self.assertOtherEventNotAffected() + + def test_update_this_and_following_success(self): + evt1, instance_to_update, evt3 = self.create_recurring_events(3) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, + end_time=end_time, operation="update_this_and_following") ) self.assertEqual(resp.status_code, 200) - self.assertAddMessageCalledWith([MESSAGE_EVENT_UPDATED_TEXT]) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - def test_instructor_calendar_edit_update_non_exist_id_to_edit_failure(self): - self.c.force_login(self.instructor_participation.user) - id_to_edit = 1000 # forgive me - post_data = { - "id_to_edit": id_to_edit, - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + evt1.refresh_from_db() + self.assertIsNone(evt1.end_time) + + instance_to_update.refresh_from_db() + self.assertEqual( + instance_to_update.end_time - instance_to_update.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 404) - post_data = { - "existing_event_to_save": id_to_edit, - "kind": SHOWN_EVENT_KIND, - "ordinal": 1000, - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + evt3.refresh_from_db() + self.assertEqual( + evt3.end_time - evt3.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 404) - def test_no_pperm_edit_event_post_create_fail(self): - self.c.force_login(self.student_participation.user) - post_data = { - "kind": FAILURE_EVENT_KIND, - "ordinal": "1", - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "2 '%s' events updated." + % instance_to_update.kind) - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + self.assertOtherEventNotAffected() + + def test_update_all_in_a_series_success(self): + instance_to_update, another_event = self.create_recurring_events(2) + + self.create_recurring_events( + 5, staring_time_offset_days=1, staring_ordinal=3) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, end_time=end_time, operation="update_series") + ) + + self.assertEqual(resp.status_code, 200) + + instance_to_update.refresh_from_db() + self.assertEqual( + instance_to_update.end_time - instance_to_update.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 403) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - self.assertEqual(Event.objects.filter(kind=FAILURE_EVENT_KIND).count(), 0) - - def test_no_pperm_edit_event_post_delete_fail(self): - self.c.force_login(self.student_participation.user) - id_to_delete = Event.objects.filter(kind=SHOWN_EVENT_KIND).first().id - post_data = { - "id_to_delete": id_to_delete, - 'submit': [''] - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + + another_event.refresh_from_db() + self.assertEqual( + another_event.end_time - another_event.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 403) - self.assertTotalEventsCountEqual(N_TEST_EVENTS) - - def test_no_pperm_edit_event_post_edit(self): - self.c.force_login(self.student_participation.user) - id_to_edit = 1 - self.assertIsNotNone(Event.objects.get(id=id_to_edit)) - post_data = { - "id_to_edit": id_to_edit, - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All '%s' events (started at Tue, 08:00) updated." + % (instance_to_update.kind)) + + self.assertEqual( + Event.objects.filter( + course=self.course, + kind=self.default_event_kind, end_time__isnull=True).count(), + 5) + self.assertOtherEventNotAffected() + + def test_update_this_and_following_in_a_series_success(self): + evt1, instance_to_update, evt3 = self.create_recurring_events(3) + + self.create_recurring_events( + 5, staring_time_offset_days=1, staring_ordinal=4) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, + end_time=end_time, + operation="update_this_and_following_in_series") + ) + self.assertEqual(resp.status_code, 200) + + evt1.refresh_from_db() + self.assertIsNone(evt1.end_time) + + instance_to_update.refresh_from_db() + self.assertEqual( + instance_to_update.end_time - instance_to_update.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 403) - post_data = { - "existing_event_to_save": id_to_edit, - "kind": FAILURE_EVENT_KIND, - "ordinal": 1000, - "time": now().strftime(DATE_TIME_PICKER_TIME_FORMAT), - "shown_in_calendar": True, - 'submit': [''] - } - resp = self.c.post( - reverse("relate-edit_calendar", args=[self.course.identifier]), - post_data + evt3.refresh_from_db() + self.assertEqual( + evt3.end_time - evt3.time, + timedelta(hours=2) ) - self.assertEqual(resp.status_code, 403) - self.assertEqual(Event.objects.filter(kind=FAILURE_EVENT_KIND).count(), 0) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "2 '%s' events (started at Tue, 08:00) updated." + % (instance_to_update.kind)) + + self.assertEqual( + Event.objects.filter( + course=self.course, + kind=self.default_event_kind, end_time__isnull=True).count(), + 6) + self.assertOtherEventNotAffected() + + def test_update_unknown_operation(self): + instance_to_update, another_event = self.create_recurring_events(2) + + end_time = as_local_time(instance_to_update.time + timedelta(hours=2)) + + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, end_time=end_time, operation="unknown_operation") + ) + self.assertEqual(resp.status_code, 400) + + self.assertEqual(Event.objects.filter( + course=self.course, kind=self.default_event_kind, + end_time__isnull=True).count(), + 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["__all__"], + ['SuspiciousOperation: unknown operation']) + + self.assertOtherEventNotAffected() + + def test_update_remove_recurring_event_ordinal(self): + instance_to_update, another_event = self.create_recurring_events(2) + + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, ordinal=None, operation="update_all") + ) + self.assertEqual(resp.status_code, 400) + + self.assertEqual(Event.objects.filter( + course=self.course, kind=self.default_event_kind, + end_time__isnull=True).count(), + 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["__all__"], + ['RuntimeError: May not do bulk update when ' + 'ordinal is None']) + + self.assertOtherEventNotAffected() + + def test_update_bulk_change_kind(self): + __, instance_to_update = self.create_recurring_events(2) + + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, kind="new_kind", operation="update_all") + ) + self.assertEqual(resp.status_code, 200) + + self.assertEqual(Event.objects.filter( + course=self.course, kind="new_kind").count(), + 2) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All 'lecture' events updated: " + "'lecture' -> 'new_kind'.") + + self.assertOtherEventNotAffected() + + def test_update_series_consider_end_time(self): + # Events with recurring start time but with non-recurring end_time + # won't be considered as in a same series. + instance_to_update, another_event = ( + self.create_recurring_events(2, end_time_minute_duration=60)) + + self.create_recurring_events( + 5, staring_time_offset_days=1, staring_ordinal=4) + + instance_to_update.end_time += timedelta(hours=1) + instance_to_update.save() + + with override_settings(TIME_ZONE="Hongkong"): + resp = self.post_update_event_view( + instance_to_update.id, + self.get_default_post_data( + instance_to_update, kind="new_kind", operation="update_series") + ) + self.assertEqual(resp.status_code, 200) + + # only one is updated + self.assertEqual(Event.objects.filter( + course=self.course, kind="new_kind").count(), + 1) + + json_response = json.loads(resp.content.decode()) + self.assertEqual(json_response["message"], + "All 'lecture' events (started at Tue, 08:00, " + "ended at Tue, 10:00) updated: " + "'lecture' -> 'new_kind'.") + + self.assertOtherEventNotAffected() + +# vim: fdm=marker diff --git a/yarn.lock b/yarn.lock index 25aeb4fd0..97d5521ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -79,12 +79,12 @@ for-each@^0.3.2: dependencies: is-function "~1.0.0" -fullcalendar@^2.9.1: - version "2.9.1" - resolved "https://registry.yarnpkg.com/fullcalendar/-/fullcalendar-2.9.1.tgz#dd2a84469b627749e47c5dd9fd71f56146e0651b" +fullcalendar@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/fullcalendar/-/fullcalendar-3.9.0.tgz#b608a9989f3416f0b1d526c6bdfeeaf2ac79eda5" dependencies: - jquery ">=1.7.1" - moment ">=2.5.0" + jquery "2 - 3" + moment "^2.20.1" global@4.3.0: version "4.3.0" @@ -116,7 +116,7 @@ jquery-ui-dist@^1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/jquery-ui-dist/-/jquery-ui-dist-1.12.1.tgz#5c0815d3cc6f90ff5faaf5b268a6e23b4ca904fa" -jquery@>=1.7, jquery@>=1.7.1, jquery@>=1.9.1, jquery@^3.3.1: +"jquery@2 - 3", jquery@>=1.7, jquery@>=1.9.1, jquery@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" @@ -136,9 +136,9 @@ min-document@^2.19.0, min-document@^2.6.1: dependencies: dom-walk "^0.1.0" -moment@>=2.5.0: - version "2.21.0" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.21.0.tgz#2a114b51d2a6ec9e6d83cf803f838a878d8a023a" +moment@^2.20.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.1.tgz#529a2e9bf973f259c9643d237fda84de3a26e8ad" parse-headers@^2.0.0: version "2.0.1"