import logging
from cacheops import invalidate_obj
from django.contrib import messages
from django.core.management import call_command
from django.db import transaction
from django.db.models import Count
from django.db.models.manager import Manager
from django.forms.formsets import formset_factory
from django.http import Http404
from django.shortcuts import redirect, render
from formtools.wizard.views import SessionWizardView
from .....utils.locking import lock_on
from .....utils.serialization import safe_json
from ....auth.decorators import eighth_admin_required
from ...forms.admin.activities import ActivitySelectionForm
from ...forms.admin.blocks import BlockSelectionForm
from ...forms.admin.scheduling import ScheduledActivityForm
from ...models import EighthActivity, EighthBlock, EighthRoom, EighthScheduledActivity, EighthSignup, EighthSponsor
from ...tasks import room_changed_single_email, transferred_activity_email
from ...utils import get_start_date
logger = logging.getLogger(__name__)
ScheduledActivityFormset = formset_factory(ScheduledActivityForm, extra=0)
[docs]@eighth_admin_required
def schedule_activity_view(request):
if request.method == "POST":
formset = ScheduledActivityFormset(request.POST)
if formset.is_valid():
for form in formset:
block = form.cleaned_data["block"]
activity = form.cleaned_data["activity"]
# Save changes to cancelled activities and scheduled activities
cancelled = EighthScheduledActivity.objects.filter(block=block, activity=activity, cancelled=True).exists()
instance = None
if form["scheduled"].value() or cancelled:
instance, _ = EighthScheduledActivity.objects.get_or_create(block=block, activity=activity)
invalidate_obj(instance)
invalidate_obj(block)
invalidate_obj(activity)
else:
schact = EighthScheduledActivity.objects.filter(block=block, activity=activity)
# Instead of deleting and messing up attendance,
# cancel the scheduled activity if it is unscheduled.
# If the scheduled activity needs to be completely deleted,
# the "Unschedule" box can be checked after it has been cancelled.
# If a both blocks activity, unschedule the other
# scheduled activities of it on the same day.
if schact:
if activity.both_blocks:
other_act = schact[0].get_both_blocks_sibling()
if other_act:
other_act.cancel()
invalidate_obj(other_act)
schact[0].cancel()
invalidate_obj(schact[0])
else:
for s in schact:
s.cancel()
invalidate_obj(s)
instance = schact[0]
cancelled = True
if instance:
fields = [
"rooms",
"capacity",
"sponsors",
"title",
"special",
"administrative",
"restricted",
"sticky",
"both_blocks",
"comments",
"admin_comments",
]
if "rooms" in form.cleaned_data:
for o in form.cleaned_data["rooms"]:
invalidate_obj(o)
if "sponsors" in form.cleaned_data:
for o in form.cleaned_data["sponsors"]:
invalidate_obj(o)
for field_name in fields:
obj = form.cleaned_data[field_name]
if (
field_name == "rooms"
and set(obj) != set(instance.rooms.all())
and (not instance.is_both_blocks or instance.block.block_letter == "A")
):
messages.success(request, "Notifying students of this room change.")
room_changed_single_email.delay(instance, instance.rooms.all(), obj)
# Properly handle ManyToMany relations in django 1.10+
if isinstance(getattr(instance, field_name), Manager):
getattr(instance, field_name).set(obj)
else:
setattr(instance, field_name, obj)
if field_name in ["rooms", "sponsors"]:
for o in obj:
invalidate_obj(o)
if form["scheduled"].value() or cancelled:
# Uncancel if this activity/block pairing was already
# created and cancelled
if form["scheduled"].value():
instance.uncancel()
# If an activity has already been cancelled and the
# unschedule checkbox has been checked, delete the
# EighthScheduledActivity instance. If there are students
# in the activity then error out.
if form["unschedule"].value() and instance.cancelled:
name = str(instance)
count = instance.eighthsignup_set.count()
bb_ok = True
sibling = False
if activity.both_blocks:
sibling = instance.get_both_blocks_sibling()
if sibling:
if not sibling.eighthsignup_set.count() == 0:
bb_ok = False
if count == 0 and bb_ok:
instance.delete()
if sibling:
sibling.delete()
messages.success(request, f"Unscheduled {name}")
continue # don't run instance.save()
elif count == 1:
messages.error(request, f"Did not unschedule {name} because there is {count} student signed up.")
else:
messages.error(request, f"Did not unschedule {name} because there are {count} students signed up.")
if instance:
instance.save()
instance.set_sticky_students(form.cleaned_data["sticky_students"])
messages.success(request, "Successfully updated schedule.")
# Force reload everything from the database to reset
# forms that weren't saved
return redirect(request.get_full_path())
else:
messages.error(request, "Error updating schedule.")
activities = EighthActivity.undeleted_objects.order_by("name")
activity_id = request.GET.get("activity", None)
activity = None
if activity_id is not None and activity_id:
try:
activity = EighthActivity.undeleted_objects.get(id=activity_id)
except (EighthActivity.DoesNotExist, ValueError):
pass
all_sponsors = {s["id"]: s for s in EighthSponsor.objects.values()}
all_rooms = {r["id"]: r for r in EighthRoom.objects.values()}
for sid, sponsor in all_sponsors.items():
if sponsor["show_full_name"]:
all_sponsors[sid]["full_name"] = sponsor["last_name"] + ", " + sponsor["first_name"]
else:
all_sponsors[sid]["full_name"] = sponsor["last_name"]
for rid, room in all_rooms.items():
all_rooms[rid]["description"] = room["name"] + " (" + str(room["capacity"]) + ")"
all_signups = {}
all_default_capacities = {}
context = {
"activities": activities,
"activity": activity,
"sponsors": all_sponsors,
"all_signups": all_signups,
"rooms": all_rooms,
"sponsors_json": safe_json(all_sponsors),
"rooms_json": safe_json(all_rooms),
}
if activity is not None:
start_date = get_start_date(request)
# end_date = start_date + timedelta(days=60)
blocks = EighthBlock.objects.filter(date__gte=start_date)
# , date__lte=end_date)
initial_formset_data = []
sched_act_queryset = (
EighthScheduledActivity.objects.filter(activity=activity)
.select_related("block")
.prefetch_related("rooms", "sponsors", "members", "sticky_students")
.all()
)
all_sched_acts = {sa.block.id: sa for sa in sched_act_queryset}
for block in blocks:
initial_form_data = {"block": block, "activity": activity}
try:
sched_act = all_sched_acts[block.id]
all_signups[block.id] = sched_act.members.count()
all_default_capacities[block.id] = sched_act.get_true_capacity()
initial_form_data.update(
{
"rooms": sched_act.rooms.all(),
"capacity": sched_act.capacity,
"sponsors": sched_act.sponsors.all(),
"title": sched_act.title,
"special": sched_act.special,
"administrative": sched_act.administrative,
"restricted": sched_act.restricted,
"sticky": sched_act.sticky,
"both_blocks": sched_act.both_blocks,
"comments": sched_act.comments,
"admin_comments": sched_act.admin_comments,
"scheduled": not sched_act.cancelled,
"cancelled": sched_act.cancelled,
"sticky_students": sched_act.sticky_students.all(),
}
)
except KeyError:
all_signups[block.id] = 0
all_default_capacities[block.id] = activity.capacity()
initial_formset_data.append(initial_form_data)
if request.method != "POST":
# There must be an error in the form if this is reached
formset = ScheduledActivityFormset(initial=initial_formset_data)
context["formset"] = formset
context["rows"] = list(zip(blocks, formset))
context["default_rooms"] = activity.rooms.all()
context["default_sponsors"] = activity.sponsors.all()
context["default_capacities"] = all_default_capacities
context["admin_page_title"] = "Schedule an Activity"
return render(request, "eighth/admin/schedule_activity.html", context)
[docs]@eighth_admin_required
def show_activity_schedule_view(request):
activities = EighthActivity.objects.order_by("name") # show deleted
activity_id = request.GET.get("activity", None)
activity = None
if activity_id is not None:
try:
activity = EighthActivity.undeleted_objects.get(id=activity_id)
except (EighthActivity.DoesNotExist, ValueError):
pass
context = {"activities": activities, "activity": activity}
if activity is not None:
start_date = get_start_date(request)
scheduled_activities = activity.eighthscheduledactivity_set.filter(block__date__gte=start_date).order_by("block__date", "block__block_letter")
context["scheduled_activities"] = scheduled_activities
context["admin_page_title"] = "View Activity Schedule"
return render(request, "eighth/admin/view_activity_schedule.html", context)
[docs]@eighth_admin_required
def distribute_students_view(request):
context = {}
return render(request, "eighth/admin/distribute_students.html", context)
[docs]class EighthAdminTransferStudentsWizard(SessionWizardView):
FORMS = [
("block_1", BlockSelectionForm),
("activity_1", ActivitySelectionForm),
("block_2", BlockSelectionForm),
("activity_2", ActivitySelectionForm),
]
TEMPLATES = {
"block_1": "eighth/admin/transfer_students.html",
"activity_1": "eighth/admin/transfer_students.html",
"block_2": "eighth/admin/transfer_students.html",
"activity_2": "eighth/admin/transfer_students.html",
}
[docs] def get_template_names(self):
return [self.TEMPLATES[self.steps.current]]
[docs] def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
context.update({"admin_page_title": "Transfer Students"})
return context
[docs] def done(self, form_list, **kwargs): # pylint: disable=unused-argument
form_list = list(form_list)
source_block = form_list[0].cleaned_data["block"]
source_activity = form_list[1].cleaned_data["activity"]
source_scheduled_activity = EighthScheduledActivity.objects.get(block=source_block, activity=source_activity)
dest_block = form_list[2].cleaned_data["block"]
dest_activity = form_list[3].cleaned_data["activity"]
dest_scheduled_activity = EighthScheduledActivity.objects.get(block=dest_block, activity=dest_activity)
req = f"source_act={source_scheduled_activity.id}&dest_act={dest_scheduled_activity.id}"
return redirect("/eighth/admin/scheduling/transfer_students_action?" + req)
transfer_students_view = eighth_admin_required(EighthAdminTransferStudentsWizard.as_view(EighthAdminTransferStudentsWizard.FORMS))
[docs]class EighthAdminUnsignupStudentsWizard(SessionWizardView):
FORMS = [("block_1", BlockSelectionForm), ("activity_1", ActivitySelectionForm)]
TEMPLATES = {"block_1": "eighth/admin/transfer_students.html", "activity_1": "eighth/admin/transfer_students.html"}
[docs] def get_template_names(self):
return [self.TEMPLATES[self.steps.current]]
[docs] def get_context_data(self, form, **kwargs):
context = super().get_context_data(form=form, **kwargs)
context.update({"admin_page_title": "Clear Student Signups for Activity"})
return context
[docs] def done(self, form_list, **kwargs): # pylint: disable=unused-argument
form_list = list(form_list)
source_block = form_list[0].cleaned_data["block"]
source_activity = form_list[1].cleaned_data["activity"]
source_scheduled_activity = EighthScheduledActivity.objects.get(block=source_block, activity=source_activity)
req = f"source_act={source_scheduled_activity.id}&dest_unsignup=1"
return redirect("/eighth/admin/scheduling/transfer_students_action?" + req)
unsignup_students_view = eighth_admin_required(EighthAdminUnsignupStudentsWizard.as_view(EighthAdminUnsignupStudentsWizard.FORMS))
[docs]@eighth_admin_required
def transfer_students_action(request):
"""Do the actual process of transferring students."""
if "source_act" in request.GET:
source_act = EighthScheduledActivity.objects.get(id=request.GET.get("source_act"))
elif "source_act" in request.POST:
source_act = EighthScheduledActivity.objects.get(id=request.POST.get("source_act"))
else:
raise Http404
dest_act = None
dest_unsignup = False
if "dest_act" in request.GET:
dest_act = EighthScheduledActivity.objects.get(id=request.GET.get("dest_act"))
elif "dest_act" in request.POST:
dest_act = EighthScheduledActivity.objects.get(id=request.POST.get("dest_act"))
elif "dest_unsignup" in request.POST or "dest_unsignup" in request.GET:
dest_unsignup = True
else:
raise Http404
send_emails = bool(request.POST.get("send_emails"))
num = source_act.members.count()
context = {
"admin_page_title": "Transfer Students",
"source_act": source_act,
"dest_act": dest_act,
"dest_unsignup": dest_unsignup,
"num": num,
"moved_students": None,
"send_emails": send_emails,
}
if request.method == "POST":
if dest_unsignup and not dest_act:
source_act.eighthsignup_set.all().delete()
invalidate_obj(source_act)
messages.success(request, f"Successfully removed signups for {num} students.")
elif dest_act.block != source_act.block:
# In order to prevent duplicate signups when transferring students between activities in different blocks,
# we need to delete any `EighthSignup`s already present in the block of `dest_act` for transferred students.
duplicate_sign_up_users = []
duplicate_signups = []
with transaction.atomic():
lock_on(source_act.members.all())
for u in source_act.members.all():
conflict_signup = u.eighthsignup_set.filter(scheduled_activity__block=dest_act.block)
if conflict_signup.exists():
duplicate_sign_up_users.append(u)
duplicate_signups.extend(list(conflict_signup))
for signup in duplicate_signups:
signup.delete()
logger.debug("Deleted %s signup", signup)
source_act.eighthsignup_set.update(scheduled_activity=dest_act)
invalidate_obj(source_act)
invalidate_obj(dest_act)
messages.success(request, f"Successfully transferred {num} students.")
if duplicate_sign_up_users:
if send_emails:
transferred_activity_email.delay(dest_act, source_act, duplicate_sign_up_users)
context["moved_students"] = duplicate_signups
else:
return redirect("eighth_admin_dashboard")
else:
# If we are moving students between activities in the same block, deleting signups is bad because we aren't
# able to then edit them. Duplicate signups are also not an issue.
with transaction.atomic():
source_act.eighthsignup_set.update(scheduled_activity=dest_act)
invalidate_obj(source_act)
invalidate_obj(dest_act)
messages.success(request, f"Successfully transferred {num} students.")
return redirect("eighth_admin_dashboard")
return render(request, "eighth/admin/transfer_students.html", context)
[docs]@eighth_admin_required
def remove_duplicates_view(request):
duplicates = (
EighthSignup.objects.all()
.annotate(Count("user"), Count("scheduled_activity__block"))
.order_by()
.filter(scheduled_activity__block__count__gt=1)
)
if request.method == "POST":
try:
call_command("delete_duplicate_signups")
messages.success(request, f"Successfully removed {duplicates.count()} duplicate signups.")
except Exception as e:
messages.error(request, e)
return redirect("eighth_admin_dashboard")
else:
context = {"admin_page_title": "Remove Duplicate Signups", "duplicates": duplicates}
return render(request, "eighth/admin/remove_duplicates.html", context)