Source code for intranet.apps.eighth.views.attendance

import csv
import io
import logging
from html import escape

from cacheops import invalidate_obj
from django import http
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from formtools.wizard.views import SessionWizardView
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_RIGHT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle

from ....utils.date import get_date_range_this_year
from ...auth.decorators import attendance_taker_required, deny_restricted, eighth_admin_required
from ...dashboard.views import gen_sponsor_schedule
from ...schedule.views import decode_date
from ..forms.admin.activities import ActivitySelectionForm
from ..forms.admin.blocks import BlockSelectionForm
from ..models import EighthActivity, EighthBlock, EighthScheduledActivity, EighthSignup, EighthSponsor, EighthWaitlist
from ..tasks import email_scheduled_activity_students_task
from ..utils import get_start_date

logger = logging.getLogger(__name__)


[docs]def should_show_activity_list(wizard): if "default_activity" in wizard.request.GET: act_id = wizard.request.GET["default_activity"] default_activity = EighthActivity.objects.filter(id=act_id) if default_activity.count() == 1: wizard.default_activity = default_activity[0] return False if wizard.request.user.is_eighth_admin: return True cleaned_data = wizard.get_cleaned_data_for_step("block") or {} if cleaned_data: activities = wizard.get_form("activity").fields["activity"].queryset if activities.count() == 1: wizard.default_activity = activities[0] return False if activities.count() == 0: wizard.no_activities = True return False return True
[docs]class EighthAttendanceSelectScheduledActivityWizard(SessionWizardView): FORMS = [("block", BlockSelectionForm), ("activity", ActivitySelectionForm)] TEMPLATES = {"block": "eighth/take_attendance.html", "activity": "eighth/take_attendance.html"}
[docs] def get_template_names(self): return [self.TEMPLATES[self.steps.current]]
[docs] def get_form_kwargs(self, step=None): kwargs = {} block = None block_title = None if step == "block": show_all_blocks = "show_all_blocks" in self.request.GET or "block" in self.request.GET if show_all_blocks: """Only show blocks after September 1st of the current school year.""" now, _ = get_date_range_this_year() kwargs.update({"exclude_before_date": now}) elif not self.request.user.is_eighth_admin: now = timezone.localdate() kwargs.update({"exclude_before_date": now}) else: start_date = get_start_date(self.request) kwargs.update({"exclude_before_date": start_date}) if step == "activity": block = self.get_cleaned_data_for_step("block") if block: block = block["block"] kwargs.update({"block": block}) if self.request and self.request.user and self.request.user.is_eighthoffice: kwargs.update({"include_cancelled": True}) block_title = "Take Attendance" if block.locked else "View Roster" try: sponsor = self.request.user.eighthsponsor except (EighthSponsor.DoesNotExist, AttributeError): sponsor = None if self.request.user.is_restricted and sponsor is not None: kwargs.update({"sponsor": sponsor}) labels = { "block": "Select a block", "activity": "Select an activity" if not block else block_title, } kwargs.update({"label": labels[step]}) return kwargs
[docs] def get_context_data(self, form, **kwargs): context = super().get_context_data(form=form, **kwargs) context.update({"admin_page_title": "Take Attendance"}) ####### if settings.ENABLE_HYBRID_EIGHTH: context.update({"hybrid": True}) ####### block = self.get_cleaned_data_for_step("block") context.update({"show_all_blocks": ("show_all_blocks" in self.request.GET or "block" in self.request.GET)}) context.update({"default_activity_not_scheduled": ("default_activity" in self.request.GET and not block)}) if block: block = block["block"] try: sponsor = self.request.user.eighthsponsor except (EighthSponsor.DoesNotExist, AttributeError): sponsor = None if sponsor and not self.request.user.is_eighthoffice: context.update({"sponsor_block": block}) sponsoring_filter = Q(sponsors=sponsor) | (Q(sponsors=None) & Q(activity__sponsors=sponsor)) sponsored_activities = EighthScheduledActivity.objects.filter(block=block).filter(sponsoring_filter).order_by("activity__name") context.update({"sponsored_activities": sponsored_activities}) elif "block" in self.request.GET: block_id = self.request.GET["block"] context["redirect_block_id"] = block_id return context
[docs] def done(self, form_list, **kwargs): # pylint: disable=unused-argument form_list = list(form_list) if hasattr(self, "no_activities"): response = redirect("eighth_attendance_choose_scheduled_activity") response["Location"] += "?na=1" return response if hasattr(self, "default_activity"): activity = self.default_activity # pylint: disable=no-member; We just checked if the attribute exists else: activity = form_list[1].cleaned_data["activity"] block = form_list[0].cleaned_data["block"] try: scheduled_activity = EighthScheduledActivity.objects.get(block=block, activity=activity) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404(f"The scheduled activity with block {block} and activity {activity} does not exist.") from e if "admin" in self.request.path: url_name = "eighth_admin_take_attendance" else: url_name = "eighth_take_attendance" response = redirect(url_name, scheduled_activity_id=scheduled_activity.id) if self.request.GET.get("show_all_blocks", "") == "1": response["Location"] += "?show_all_blocks=1" return response
_unsafe_choose_scheduled_activity_view = EighthAttendanceSelectScheduledActivityWizard.as_view( EighthAttendanceSelectScheduledActivityWizard.FORMS, condition_dict={"activity": should_show_activity_list} ) teacher_choose_scheduled_activity_view = attendance_taker_required(_unsafe_choose_scheduled_activity_view) admin_choose_scheduled_activity_view = eighth_admin_required(_unsafe_choose_scheduled_activity_view) @login_required @deny_restricted def roster_view(request, scheduled_activity_id): try: scheduled_activity = EighthScheduledActivity.objects.get(id=scheduled_activity_id) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404 from e signups = EighthSignup.objects.filter(scheduled_activity=scheduled_activity) viewable_members = scheduled_activity.get_viewable_members(request.user) num_hidden_members = len(scheduled_activity.get_hidden_members(request.user)) is_sponsor = scheduled_activity.user_is_sponsor(request.user) context = { "scheduled_activity": scheduled_activity, "viewable_members": viewable_members, "num_hidden_members": num_hidden_members, "signups": signups, "is_sponsor": is_sponsor, } return render(request, "eighth/roster.html", context)
[docs]@login_required def raw_roster_view(request, scheduled_activity_id): try: scheduled_activity = EighthScheduledActivity.objects.get(id=scheduled_activity_id) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404 from e signups = EighthSignup.objects.filter(scheduled_activity=scheduled_activity) viewable_members = scheduled_activity.get_viewable_members(request.user) num_hidden_members = scheduled_activity.get_hidden_members(request.user).count() context = { "scheduled_activity": scheduled_activity, "viewable_members": viewable_members, "num_hidden_members": num_hidden_members, "signups": signups, } return render(request, "eighth/roster_list.html", context)
@eighth_admin_required @deny_restricted def raw_waitlist_view(request, scheduled_activity_id): try: scheduled_activity = EighthScheduledActivity.objects.get(id=scheduled_activity_id) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404 from e context = {"ordered_waitlist": EighthWaitlist.objects.filter(scheduled_activity_id=scheduled_activity.id).order_by("time")} return render(request, "eighth/waitlist_list.html", context)
[docs]@attendance_taker_required def take_attendance_view(request, scheduled_activity_id): try: scheduled_activity = EighthScheduledActivity.objects.select_related("activity", "block").get( activity__deleted=False, id=scheduled_activity_id ) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404 from e # Attendance-only users can only see their own roster if request.user.is_restricted and not scheduled_activity.user_is_sponsor(request.user): raise http.Http404 edit_perm = request.user.is_eighth_admin or scheduled_activity.user_is_sponsor(request.user) edit_perm_cancelled = False if scheduled_activity.cancelled and not request.user.is_eighth_admin: edit_perm = False edit_perm_cancelled = True if request.method == "POST": if not edit_perm: if edit_perm_cancelled: return render( request, "error/403.html", {"reason": "You do not have permission to take attendance for this activity. The activity was cancelled."}, status=403, ) else: return render( request, "error/403.html", {"reason": "You do not have permission to take attendance for this activity. You are not a sponsor."}, status=403, ) if "admin" in request.path: url_name = "eighth_admin_take_attendance" else: url_name = "eighth_take_attendance" if "clear_attendance_bit" in request.POST: scheduled_activity.attendance_taken = False scheduled_activity.save() invalidate_obj(scheduled_activity) messages.success(request, f"Attendance bit cleared for {scheduled_activity}") redirect_url = reverse(url_name, args=[scheduled_activity.id]) if "no_attendance" in request.GET: redirect_url += "?no_attendance={}".format(request.GET["no_attendance"]) return redirect(redirect_url) if not scheduled_activity.block.locked and not request.user.is_eighth_admin: return render( request, "error/403.html", {"reason": "You do not have permission to take attendance for this activity. The block has not been locked yet."}, status=403, ) if not scheduled_activity.block.locked and request.user.is_eighth_admin: messages.success(request, "Note: Taking attendance on an unlocked block.") present_user_ids = list(request.POST.keys()) if request.FILES.get("attendance"): try: csv_file = request.FILES["attendance"].read().decode("utf-8") data = csv.DictReader(io.StringIO(csv_file)) unfound_users = [] for u in data: try: user = get_user_model().objects.filter(student_id=u["Email"].split("@")[0].strip()) if len(user) != 1: unfound_users.append(u["Name"]) else: present_user_ids.append(user[0]) except KeyError: messages.info(request, f"Error on line containing {u}. If this line isn't blank, please contact an admin.") if unfound_users: messages.success( request, "Took attendance from file, but could not find signups for {} who were present according to the Google Meet file.".format( ", ".join(unfound_users) ), ) except (csv.Error, ValueError, KeyError, IndexError): messages.error(request, "Could not interpret file. Did you upload a Google Meet attendance report without modification?") csrf = "csrfmiddlewaretoken" if csrf in present_user_ids: present_user_ids.remove(csrf) if "attendance" in present_user_ids: present_user_ids.remove("attendance") absent_signups = EighthSignup.objects.filter(scheduled_activity=scheduled_activity).exclude(user__in=present_user_ids) absent_signups.update(was_absent=True) for s in absent_signups: invalidate_obj(s) present_signups = EighthSignup.objects.filter(scheduled_activity=scheduled_activity, user__in=present_user_ids) present_signups.update(was_absent=False) for s in present_signups: invalidate_obj(s) passes = EighthSignup.objects.filter(scheduled_activity=scheduled_activity, after_deadline=True, pass_accepted=False) passes.update(was_absent=True) for s in passes: invalidate_obj(s) scheduled_activity.attendance_taken = True scheduled_activity.save() invalidate_obj(scheduled_activity) messages.success(request, "Attendance updated.") redirect_url = reverse(url_name, args=[scheduled_activity.id]) if "no_attendance" in request.GET: redirect_url += "?no_attendance={}".format(request.GET["no_attendance"]) return redirect(redirect_url) else: passes = EighthSignup.objects.select_related("user").filter(scheduled_activity=scheduled_activity, after_deadline=True, pass_accepted=False) users = scheduled_activity.members.exclude(eighthsignup__in=passes) members = [] absent_user_ids = ( EighthSignup.objects.select_related("user") .filter(scheduled_activity=scheduled_activity, was_absent=True) .values_list("user__id", flat=True) ) pass_users = ( EighthSignup.objects.select_related("user") .filter(scheduled_activity=scheduled_activity, after_deadline=True, pass_accepted=True) .values_list("user__id", flat=True) ) for user in users: members.append( { "id": user.id, "student_id": user.student_id, "name": user.last_first, # includes nickname "grade": user.grade.number if user.grade else None, "present": (scheduled_activity.attendance_taken and (user.id not in absent_user_ids)), "had_pass": user.id in pass_users, "pass_present": (not scheduled_activity.attendance_taken and user.id in pass_users and user.id not in absent_user_ids), "email": user.tj_email, } ) invalidate_obj(user) members.sort(key=lambda m: m["name"]) context = { "scheduled_activity": scheduled_activity, "passes": passes, "members": members, "p": pass_users, "no_edit_perm": not edit_perm, "edit_perm_cancelled": edit_perm_cancelled, "show_checkboxes": (scheduled_activity.block.locked or request.user.is_eighth_admin), "show_icons": (scheduled_activity.block.locked and scheduled_activity.block.attendance_locked() and not request.user.is_eighth_admin), "bbcu_script": settings.BBCU_SCRIPT, } if request.user.is_eighth_admin: context["scheduled_activities"] = EighthScheduledActivity.objects.filter(block__id=scheduled_activity.block.id) show_all_blocks = request.GET.get("show_all_blocks", "") == "1" if show_all_blocks: start_date, _ = get_date_range_this_year() else: start_date = get_start_date(request) context["blocks"] = EighthBlock.objects.filter(date__gte=start_date).order_by("date", "block_letter") if request.resolver_match.url_name == "eighth_admin_export_attendance_csv": response = http.HttpResponse(content_type="text/csv") response["Content-Disposition"] = 'attachment; filename="attendance.csv"' writer = csv.writer(response) writer.writerow( [ "Block", "Activity", "Name", "FCPS ID", "Student ID", "Grade", "Email", "Locked", "Rooms", "Sponsors", "Attendance Taken", "Present", "Had Pass", ] ) for member in members: row = [] row.append(str(scheduled_activity.block)) row.append(str(scheduled_activity.activity)) row.append(member["name"]) row.append(member["student_id"]) row.append(member["id"]) row.append(member["grade"]) row.append(member["email"]) row.append(scheduled_activity.block.locked) rooms = scheduled_activity.get_true_rooms() row.append(", ".join([f"{room.name} ({room.capacity})" for room in rooms])) sponsors = scheduled_activity.get_true_sponsors() row.append(" ,".join([sponsor.name for sponsor in sponsors])) row.append(scheduled_activity.attendance_taken) row.append(member["present"] if scheduled_activity.block.locked else "N/A") row.append(member["had_pass"] if scheduled_activity.block.locked else "N/A") writer.writerow(row) return response else: return render(request, "eighth/take_attendance.html", context)
[docs]@attendance_taker_required def accept_pass_view(request, signup_id): if request.method != "POST": return http.HttpResponseNotAllowed(["POST"], "HTTP 405: METHOD NOT ALLOWED") try: signup = EighthSignup.objects.get(id=signup_id) except EighthSignup.DoesNotExist as e: raise http.Http404 from e sponsor = request.user.get_eighth_sponsor() can_accept = signup.scheduled_activity.block.locked and ( sponsor and (sponsor in signup.scheduled_activity.get_true_sponsors()) or request.user.is_eighth_admin ) if not can_accept: return render(request, "error/403.html", {"reason": "You do not have permission to accept this pass."}, status=403) status = request.POST.get("status") if status == "accept": signup.accept_pass() elif status == "reject": signup.reject_pass() signup.save() if "admin" in request.path: url_name = "eighth_admin_take_attendance" else: url_name = "eighth_take_attendance" return redirect(url_name, scheduled_activity_id=signup.scheduled_activity.id)
[docs]@attendance_taker_required def accept_all_passes_view(request, scheduled_activity_id): if request.method != "POST": return http.HttpResponseNotAllowed(["POST"], "HTTP 405: METHOD NOT ALLOWED") try: scheduled_activity = EighthScheduledActivity.objects.get(id=scheduled_activity_id) except EighthScheduledActivity.DoesNotExist as e: raise http.Http404 from e sponsor = request.user.get_eighth_sponsor() can_accept = scheduled_activity.block.locked and (sponsor and (sponsor in scheduled_activity.get_true_sponsors()) or request.user.is_eighth_admin) if not can_accept: return render(request, "error/403.html", {"reason": "You do not have permission to take accept these passes."}, status=403) EighthSignup.objects.filter(after_deadline=True, scheduled_activity=scheduled_activity).update(pass_accepted=True, was_absent=False) invalidate_obj(scheduled_activity) if "admin" in request.path: url_name = "eighth_admin_take_attendance" else: url_name = "eighth_take_attendance" return redirect(url_name, scheduled_activity_id=scheduled_activity.id)
[docs]def generate_roster_pdf(sched_act_ids): r"""Generates a PDF roster for one or more. :class:`EighthScheduledActivity`\s. Args sched_act_ids The list of IDs of the scheduled activities to show in the PDF. Returns a BytesIO object for the PDF. """ pdf_buffer = io.BytesIO() h_margin = 1 * inch v_margin = 0.5 * inch doc = SimpleDocTemplate(pdf_buffer, pagesize=letter, rightMargin=h_margin, leftMargin=h_margin, topMargin=v_margin, bottomMargin=v_margin) elements = [] styles = getSampleStyleSheet() styles.add(ParagraphStyle(name="Center", alignment=TA_CENTER)) styles.add(ParagraphStyle(name="BlockLetter", fontSize=60, leading=72, alignment=TA_CENTER)) styles.add(ParagraphStyle(name="BlockLetterSmall", fontSize=30, leading=72, alignment=TA_CENTER)) styles.add(ParagraphStyle(name="BlockLetterSmallest", fontSize=20, leading=72, alignment=TA_CENTER)) styles.add(ParagraphStyle(name="ActivityAttribute", fontSize=15, leading=18, alignment=TA_RIGHT)) for i, said in enumerate(sched_act_ids): sact = EighthScheduledActivity.objects.get(id=said) sponsor_names = sact.get_true_sponsors().values_list("first_name", "last_name") sponsors_str = "; ".join(l + ", " + f for f, l in sponsor_names) room_names = sact.get_true_rooms().values_list("name", flat=True) if len(room_names) == 1: rooms_str = "Room " + room_names[0] else: rooms_str = "Rooms: " + ", ".join(r for r in room_names) block_letter = sact.block.block_letter if len(block_letter) < 4: block_letter_width = 1 * inch block_letter_width += (0.5 * inch) * (len(block_letter) - 1) block_letter_style = "BlockLetter" elif len(block_letter) < 7: block_letter_width = 0.4 * inch block_letter_width += (0.3 * inch) * (len(block_letter) - 1) block_letter_style = "BlockLetterSmall" else: block_letter_width = 0.3 * inch block_letter_width += (0.2 * inch) * (len(block_letter) - 1) block_letter_style = "BlockLetterSmallest" header_data = [ [ Paragraph(f"<b>Activity ID: {sact.activity.id}<br/>Scheduled ID: {sact.id}</b>", styles["Normal"]), Paragraph( "{}<br/>{}<br/>{}".format(sponsors_str, rooms_str, sact.block.date.strftime("%A, %B %-d, %Y")), styles["ActivityAttribute"] ), Paragraph(block_letter, styles[block_letter_style]), ] ] header_style = TableStyle( [ ("VALIGN", (0, 0), (0, 0), "TOP"), ("VALIGN", (1, 0), (2, 0), "MIDDLE"), ("TOPPADDING", (0, 0), (0, 0), 15), ("RIGHTPADDING", (1, 0), (1, 0), 0), ] ) elements.append(Table(header_data, style=header_style, colWidths=[2 * inch, None, block_letter_width])) elements.append(Spacer(0, 10)) elements.append(Paragraph(sact.full_title, styles["Title"])) num_members = sact.members.count() num_members_label = "{} Student{}".format(num_members, "s" if num_members != 1 else "") elements.append(Paragraph(num_members_label, styles["Center"])) elements.append(Spacer(0, 5)) attendance_data = [ [Paragraph("Present", styles["Heading5"]), Paragraph("Student Name (ID)", styles["Heading5"]), Paragraph("Grade", styles["Heading5"])] ] members = [] for member in sact.members.all(): members.append( ( member.last_name + ", " + member.first_name, (member.student_id if member.student_id else f"User {member.id}"), int(member.grade) if member.grade else "?", ) ) members = sorted(members) for member_name, member_id, member_grade in members: row = ["", f"{member_name} ({member_id})", member_grade] attendance_data.append(row) # Line commands are like this: # op, start, stop, weight, colour, cap, dashes, join, linecount, linespacing attendance_style = TableStyle( [ ("LINEABOVE", (0, 1), (2, 1), 1, colors.black, None, None, None, 2), ("LINEBELOW", (0, 1), (0, len(attendance_data)), 1, colors.black), ("TOPPADDING", (0, 1), (-1, -1), 6), ("BOTTOMPADDING", (0, 1), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, 0), 5), ] ) elements.append(Table(attendance_data, style=attendance_style, colWidths=[1.3 * inch, None, 0.8 * inch])) elements.append(Spacer(0, 15)) # NOTE: We should really not be writing raw HTML instructions = f""" <b>Highlight or circle</b> the names of students who are <b>absent</b>, and put an <b>"X"</b> next to those <b>present</b>.<br/> If a student arrives and their name is not on the roster, please send them to the <b>8th Period Office</b>.<br/> If a student leaves your activity early, please make a note. <b>Do not make any additions to the roster.</b><br/> Before leaving for the day, return the roster and any passes to 8th Period coordinator, {escape(settings.EIGHTH_COORDINATOR_NAME)}'s mailbox in the <b>main office</b>. For questions, please call extension 5046 or 5078. Thank you!<br/>""" elements.append(Paragraph(instructions, styles["Normal"])) if i != len(sched_act_ids) - 1: elements.append(PageBreak()) def first_page(canvas, _): canvas.setTitle("Eighth Activity Roster") canvas.setAuthor("Generated by Ion") doc.build(elements, onFirstPage=first_page) return pdf_buffer
@login_required @deny_restricted def eighth_absences_view(request, user_id=None): if user_id and request.user.is_eighth_admin: user = get_object_or_404(get_user_model(), id=user_id) elif "user" in request.GET and request.user.is_eighth_admin: user = get_object_or_404(get_user_model(), id=request.GET["user"]) elif request.user.is_student: user = request.user else: return redirect("eighth_admin_dashboard") absences = ( EighthSignup.objects.filter(user=user, was_absent=True, scheduled_activity__attendance_taken=True) .select_related("scheduled_activity__block", "scheduled_activity__activity") .order_by("scheduled_activity__block") ) context = {"absences": absences, "user": user} return render(request, "eighth/absences.html", context) @login_required @deny_restricted def sponsor_schedule_widget_view(request): user = request.user eighth_sponsor = user.get_eighth_sponsor() num_blocks = 6 surrounding_blocks = None date = None if "date" in request.GET: date = decode_date(request.GET.get("date")) if date: block = EighthBlock.objects.filter(date__gte=date).first() if block: surrounding_blocks = [block] + list(block.next_blocks(num_blocks - 1)) else: surrounding_blocks = [] if surrounding_blocks is None: surrounding_blocks = EighthBlock.objects.get_upcoming_blocks(num_blocks) context = {} if eighth_sponsor: sponsor_sch = gen_sponsor_schedule(user, eighth_sponsor, num_blocks, surrounding_blocks, date) context.update(sponsor_sch) # "sponsor_schedule", "no_attendance_today", "num_attendance_acts", # "sponsor_schedule_cur_date", "sponsor_schedule_prev_date", "sponsor_schedule_next_date" context.update({"eighth_sponsor": eighth_sponsor}) return render(request, "eighth/sponsor_widget.html", context) @login_required @deny_restricted def email_students_view(request, scheduled_activity_id): scheduled_activity = get_object_or_404(EighthScheduledActivity, id=scheduled_activity_id) if not scheduled_activity.user_is_sponsor(request.user) and not request.user.is_eighth_admin: raise Http404 if request.method == "POST" and request.POST.get("body"): subject = settings.EMAIL_SUBJECT_PREFIX + f"{scheduled_activity}: Message from {request.user.full_name}" if request.POST.get("subject"): subject += ": " + request.POST["subject"] body = """{} has requested to send you the following email regarding {}:\n\n{}""".format( request.user.full_name, scheduled_activity.activity, request.POST["body"] ) email_scheduled_activity_students_task.delay( scheduled_activity_id=scheduled_activity_id, sender_id=request.user.id, subject=subject, body=body, ) messages.success(request, "Email sent.") return redirect("eighth_take_attendance", scheduled_activity_id) context = {"scheduled_activity": scheduled_activity} return render(request, "eighth/email_students.html", context)