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 %}
{% 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 %}
-
-
-
-
+ {% if pperm.edit_events %}
+
+
+
+
+
{% endif %}
+
{% endblock %}
{% block page_bottom_javascript_extra %}
+ {% if pperm.edit_events %}
+
+
+
+
+
+ {% endif %}
+
+
{{ block.super }}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
+
diff --git a/course/templates/course/course-base.html b/course/templates/course/course-base.html
index e1046d4a3..4846ab9ef 100644
--- a/course/templates/course/course-base.html
+++ b/course/templates/course/course-base.html
@@ -131,7 +131,7 @@
{% if perms.course.add_event and perms.course.change_event and perms.course.delete_event %}
{% trans "Edit events (admin)" %}
{% endif %}
-
{% trans "Edit events (calendar)" %}
+
{% trans "Edit events (calendar)" %}
{% trans "Create recurring events" %}
{% trans "Renumber events" %}
{% endif %}
diff --git a/course/templates/course/events_info.html b/course/templates/course/events_info.html
new file mode 100644
index 000000000..c53b8ff1e
--- /dev/null
+++ b/course/templates/course/events_info.html
@@ -0,0 +1,12 @@
+{% 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 %}
diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po
index d9887b68c..ea0e7af8f 100644
--- a/locale/zh_Hans/LC_MESSAGES/django.po
+++ b/locale/zh_Hans/LC_MESSAGES/django.po
@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2018-04-20 16:43+0800\n"
+"POT-Creation-Date: 2018-05-29 00:09+0800\n"
"Last-Translator: Dong Zhuang
\n"
"Language-Team: Dong Zhuang \n"
"Language: \n"
@@ -236,8 +236,14 @@ msgstr "已经模拟了某个用户. "
msgid "Impersonate user"
msgstr "用户模拟"
-msgid "Stop impersonating"
-msgstr "停止模拟用户"
+msgid "only AJAX POST is allowed"
+msgstr "只允许AJAX POST操作"
+
+msgid "odd POST parameters"
+msgstr "怪异的POST参数设置"
+
+msgid "may not stop impersonating"
+msgstr "不能停止模拟用户"
msgid "Not currently impersonating anyone."
msgstr "当前未模拟任何用户. "
@@ -245,9 +251,6 @@ msgstr "当前未模拟任何用户. "
msgid "No longer impersonating anyone."
msgstr "未模拟任何用户. "
-msgid "Stop impersonating user"
-msgstr "停止模拟用户"
-
msgid "Sign in"
msgstr "登录"
@@ -401,6 +404,18 @@ msgstr "创建了一个新的认证token: %s. 请将它保存下来,您以后
msgid "Could not find which button was pressed."
msgstr "无法找到哪一个按钮被按下"
+#. Translators: %s is the string of the start time of a session.
+#, python-format
+msgid "started at %s"
+msgstr "开始于%s"
+
+#, python-format
+msgid "ended at %s"
+msgstr "结束于%s"
+
+msgid "Create recurring events"
+msgstr "创建重复进行的event"
+
#. Translators: format of event kind in Event model
msgid "Should be lower_case_with_underscores, no spaces allowed."
msgstr "只允许小写字母和下划线, 不允许有空格. "
@@ -447,41 +462,182 @@ msgctxt "Count of recurring events"
msgid "Count"
msgstr "次数"
-#, python-format
-msgid "'%(event_kind)s %(event_ordinal)d' already exists"
-msgstr "%(event_kind)s %(event_ordinal)d' 已经存在"
+msgid "Cancel"
+msgstr "放弃"
-msgctxt "Unkown time interval"
-msgid "unknown interval"
-msgstr "未知的时间间隔"
+#, python-format
+msgid "'%(exist_event)s' already exists"
+msgstr "%(exist_event)s' 已经存在"
msgid "may not edit events"
msgstr "不允许编辑event"
-msgid "No events created."
-msgstr "未创建event. "
+msgid "only AJAX GET is allowed"
+msgstr "只允许AJAX GET操作"
+
+msgid "may not GET by AJAX"
+msgstr "不允许AJAX GET"
msgid "Events created."
msgstr "Event创建成功. "
-msgid "Create recurring events"
-msgstr "创建重复进行的event"
+msgid "No events created."
+msgstr "未创建event. "
-msgid "Renumber"
+msgid "Renumber events"
msgstr "重新编号"
-msgid "Events renumbered."
-msgstr "Event重新编号完成. "
+msgid "The starting ordinal of this kind of events"
+msgstr "该kind的event的开始序号"
-msgid "No events found."
-msgstr "未找到Event. "
+msgid "Tick to preserve the order of ordinals of existing events."
+msgstr "勾选以维持现在event序号的先后次序."
-msgid "Renumber events"
+msgid "Preserve ordinal order"
+msgstr "维持序号的先后次序"
+
+msgid "Renumber"
msgstr "重新编号"
+msgid "Events renumbered."
+msgstr "Event重新编号完成. "
+
msgid "may not view calendar"
msgstr "不允许查看日历"
+msgid "may not edit calendar"
+msgstr "不允许编辑日历"
+
+msgid "may not fetch events"
+msgstr "不允许拉取event"
+
+msgid "may not fetch events as edit view"
+msgstr "不允许以编辑模式拉取event"
+
+msgid "Create a event"
+msgstr "创建event"
+
+msgid "Shown in students' calendar"
+msgstr "在学生的日历中显示"
+
+msgid "Save"
+msgstr "保存"
+
+#, python-format
+msgid "'%(exist_event)s' already exists."
+msgstr "'%(exist_event)s'已经存在."
+
+#, python-format
+msgid "Event created: '%s'."
+msgstr "Event创建成功: '%s'. "
+
+msgid "Delete event"
+msgstr "删除event"
+
+#, python-format
+msgid "Are you sure to delete event '%s'?"
+msgstr "你确认将删除event '%s'?"
+
+#, python-format
+msgid "Delete event '%s'"
+msgstr "删除event '%s'"
+
+#, python-format
+msgid "Delete all '%s' events"
+msgstr "所有所有'%s' event"
+
+#, python-format
+msgid "Delete this and following '%s' events"
+msgstr "删除此项及后续的'%s' event"
+
+#, python-format
+msgid "Delete all '%(kind)s' events (%(time)s)."
+msgstr "删除所有的'%(kind)s' event (%(time)s)."
+
+#, python-format
+msgid "Delete this and following '%(kind)s' events (%(time)s)."
+msgstr "删除此项及后续的'%(kind)s' event (%(time)s)."
+
+msgid "Operation"
+msgstr "操作"
+
+msgid "Select your operation:"
+msgstr "选择您的操作: "
+
+msgid "Delete"
+msgstr "删除"
+
+#, python-format
+msgid "%(number)d '%(kind)s' events deleted."
+msgstr "%(number)d个'%(kind)s' event被删除."
+
+#, python-format
+msgid "All '%(kind)s' events deleted."
+msgstr "所有'%(kind)s' event被删除."
+
+#, python-format
+msgid "All '%(kind)s' events (%(time)s) deleted."
+msgstr "所有'%(kind)s' events (%(time)s)被删除."
+
+#, python-format
+msgid "%(number)d '%(kind)s' events (%(time)s) deleted."
+msgstr "%(number)d个'%(kind)s' event (%(time)s)被删除."
+
+#, python-format
+msgid "Event '%s' deleted."
+msgstr "Event '%s'被删除. "
+
+#, python-format
+msgid "Update event '%s'"
+msgstr "更新event '%s'"
+
+msgid "Update all"
+msgstr "更新全部"
+
+msgid "Update this and following"
+msgstr "更新本项及后续的event"
+
+msgid "Update series"
+msgstr "更新本系列"
+
+msgid "Update this and following in series"
+msgstr "更新本项及本系列的后续event"
+
+msgid "only AJAX request is allowed"
+msgstr "只允许AJAX请求"
+
+msgid "No change was made."
+msgstr "未产生更改."
+
+#, python-format
+msgid "Event '%s' updated."
+msgstr "Event '%s' 更新成功. "
+
+msgid "Event updated"
+msgstr "Event更新成功"
+
+#, python-format
+msgid "All '%(kind)s' events updated"
+msgstr "所有 '%(kind)s' event已更新"
+
+#, python-format
+msgid "%(number)d '%(kind)s' events updated"
+msgstr "%(number)d个'%(kind)s' event已更新"
+
+#, python-format
+msgid "All '%(kind)s' events (%(time)s) updated"
+msgstr "所有'%(kind)s' event (%(time)s) 已更新"
+
+#, python-format
+msgid "%(number)d '%(kind)s' events (%(time)s) updated"
+msgstr "%(number)d个'%(kind)s' event (%(time)s) 已更新"
+
+msgid "unknown operation"
+msgstr "未知的操作"
+
+msgid "May not do bulk update when ordinal is None"
+msgstr "当ordinal设为空时,不能进行批量更新"
+
msgctxt "User status"
msgid "Unconfirmed"
msgstr "未确认"
@@ -847,10 +1003,6 @@ msgstr "未找到资源\"%s\""
msgid "I have no idea what a processing instruction is."
msgstr "我也不知道处理的指令是什么"
-#, python-format
-msgid "invalid period: %s"
-msgstr "无效的周期:%s"
-
#, python-format
msgid "unrecognized date/time specification: '%s' (interpreted as 'now')"
msgstr "无法识别的日期/时间设定: '%s' (被解释为\"现在\")"
@@ -975,9 +1127,6 @@ msgstr "移除tag"
msgid "Drop"
msgstr "删除"
-msgid "Operation"
-msgstr "操作"
-
msgid "Tag"
msgstr "标签"
@@ -1371,9 +1520,6 @@ msgstr "只能更改正在进行中的session"
msgid "invalid expiration mode"
msgstr "无效的过期模式"
-msgid "odd POST parameters"
-msgstr "怪异的POST参数设置"
-
msgid "Cannot end a session that's already ended"
msgstr "无法结束已经结束的session"
@@ -1432,9 +1578,6 @@ msgstr "为不计入成绩的flow session重新评分"
msgid "Re-allow changes"
msgstr "重新允许修改回答"
-msgid "Cancel"
-msgstr "放弃"
-
msgid "No prior answers found that could be un-submitted."
msgstr "找不到可以取消提交的回答."
@@ -1984,6 +2127,9 @@ msgstr "事件(event)"
msgid "Events"
msgstr "事件(event)"
+msgid "End time must not be ahead of start time."
+msgstr "结束时间不得早于开始时间."
+
#. Translators: name format of ParticipationTag
msgid "Format is lower-case-with-hyphens. Do not use spaces."
msgstr "格式:只允许小写字母与减号, 不允许有空格."
@@ -3251,9 +3397,29 @@ msgstr "你可以到以下页面中查看这个问题:"
msgid "Calendar"
msgstr "教学日历"
-msgid ""
-"Note: Some calendar entries are clickable and link to entries below."
-msgstr "注意: 一些日历的条目是可点击的, 且与其下方的输入内容相链接. "
+msgid "Switch to normal view"
+msgstr "切换至普通视图"
+
+msgid "Switch to edit view"
+msgstr "切换至编辑视图"
+
+msgid "New event"
+msgstr "新建event"
+
+msgid "Note"
+msgstr "注意"
+
+msgid "Some calendar entries are clickable and link to entries below."
+msgstr "一些日历的条目是可点击的, 且与其下方的输入内容相链接. "
+
+msgid "Different from normal view, this calender shows all events. "
+msgstr "与普通视图不同,本视图可以看见所有 的event."
+
+msgid "Edit event"
+msgstr "编辑event"
+
+msgid "Are you sure about this change?"
+msgstr "你确定要进行这些更改?"
#. Translators: The following are names for menu items in course page
msgctxt "menu item"
@@ -3684,6 +3850,9 @@ msgstr "(当前的)"
msgid "(submitted)"
msgstr "(已提交的)"
+msgid "not started"
+msgstr "未开始的"
+
msgid "unfinished"
msgstr "未完成"
@@ -4857,6 +5026,9 @@ msgid ""
"facility, as a reminder that this pretending is in progress."
msgstr "为所有虚拟教学设备的页面添加header,以提示目前正在虚拟过程中."
+msgid "may not pretend facilities"
+msgstr "不允许假装置身于教学设施中"
+
msgid "Pretend to be in Facilities"
msgstr "假装置身于教学设施中"
@@ -4895,11 +5067,6 @@ msgstr "不允许实施破例"
msgid "Grant Exception"
msgstr "给予破例"
-#. Translators: %s is the string of the start time of a session.
-#, python-format
-msgid "started at %s"
-msgstr "开始于%s"
-
#, python-format
msgid " tagged '%s'"
msgstr "(标签为'%s')"
@@ -4998,9 +5165,6 @@ msgstr "最高得分(按百分比)"
msgid "Maximum number of points (enforced cap)"
msgstr "最高得分(按分数)"
-msgid "Save"
-msgstr "保存"
-
msgid ""
"Must specify access expiration if 'due same as access expiration' is set."
msgstr ""
@@ -5024,8 +5188,10 @@ msgid "credit"
msgstr "应得分"
#, python-format
-msgid "Exception granted to '%(participation)s' for '%(flow_id)s'."
-msgstr "已对%(participation)s的%(flow_id)s给予破例. "
+msgid ""
+"'%(exception_type)s' exception granted to '%(participation)s' for "
+"'%(flow_id)s'."
+msgstr "已对%(participation)s的%(flow_id)s给予'%(exception_type)s'破例. "
#, python-format
msgid ""
@@ -5064,9 +5230,6 @@ msgstr "课程设置更新失败."
msgid "Edit Course"
msgstr "编辑课程"
-msgid "LearningWhat"
-msgstr ""
-
msgid "RELATE"
msgstr ""
@@ -5151,6 +5314,9 @@ msgstr "假装置身于教学设施中"
msgid "Now impersonating"
msgstr "正在模拟"
+msgid "Stop impersonating"
+msgstr "停止模拟用户"
+
msgid ""
"This website may not be compatible with your outdated Internet Explorer "
"version. If you want use Internet Explorer, please install one with version "
@@ -5198,6 +5364,9 @@ msgstr ""
"%(RELATE)s网站目前正在维护. 我们对因此为你带来不便深表抱歉, 我们很快将会恢"
"复. "
+msgid "Close"
+msgstr "关闭"
+
#, python-format
msgid "Sign in to %(relate_site_name)s"
msgstr "登录%(relate_site_name)s"
diff --git a/package.json b/package.json
index 7f3613cc7..0d513ef48 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"datatables.net-fixedcolumns-bs": "^3.2.4",
"datatables-i18n": "git+https://github.com/dzhuang/datatables-i18n.git",
"font-awesome": "^4.7.0",
- "fullcalendar": "^2.9.1",
+ "fullcalendar": "^3.9.0",
"jquery": "^3.3.1",
"jquery-ui-dist": "^1.12.1",
"jstree": "^3.3.5",
diff --git a/relate/templates/base-page-top.html b/relate/templates/base-page-top.html
index b7ad47bc0..da348a5bb 100644
--- a/relate/templates/base-page-top.html
+++ b/relate/templates/base-page-top.html
@@ -6,28 +6,30 @@
{% 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/(?P
edit)/$",
+ 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):
'some_kind4 ', 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"