diff --git a/hknweb/admin.py b/hknweb/admin.py index e95310f1..5272bb0e 100644 --- a/hknweb/admin.py +++ b/hknweb/admin.py @@ -14,6 +14,7 @@ Committee, Election, Committeeship, + DriveFolderID, ) from hknweb.forms import ProvisionCandidatesForm @@ -155,3 +156,4 @@ class CommitteeshipAdmin(admin.ModelAdmin): admin.site.register(CandidateProvisioningPassword) admin.site.register(Committee) admin.site.register(Election) +admin.site.register(DriveFolderID) diff --git a/hknweb/google_drive_utils.py b/hknweb/google_drive_utils.py new file mode 100644 index 00000000..9d7e699d --- /dev/null +++ b/hknweb/google_drive_utils.py @@ -0,0 +1,295 @@ +from functools import wraps +import google.oauth2.service_account as service_account +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import MediaIoBaseUpload +from django.core.exceptions import ImproperlyConfigured + + +from pathlib import Path +import os +import json + +# Thank you Brian Yu as much of this code and framework is taken from his work on the google calendar + +# Alter the path to a server environment path + + +def get_credentials(): # pragma: no cover + """ + Gets the google drive service account's credentials from the server. + Should not be called anywhere else. + + Returns: + google.oauth2.service_acount.Credentials + """ + SCOPE = ["https://www.googleapis.com/auth/drive"] + + json_env = os.getenv("GOOGLE_DRIVE_CREDENTIALS_JSON") + + if not json_env: + raise ImproperlyConfigured( + "No Drive credentials found in environment variables." + ) + + try: + info = json.loads(json_env) + except json.JSONDecodeError: + raise ImproperlyConfigured("env doesn't contain a valid JSON") + + creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPE) + + return creds + + +def check_credentials_wrapper(fn): + @wraps(fn) + def new_fn(*args, **kwargs): + get_credentials() + return fn(*args, **kwargs) + + return new_fn + + +@check_credentials_wrapper +def get_service(): # pragma: no cover + creds = get_credentials() + service = build("drive", "v3", credentials=creds) + + return service + + +def create_metadata( + name: str, + mimeType: str, + parents: list, + description: str, +) -> dict: + data = dict() + data["name"] = name + data["mimeType"] = mimeType + + if description is not None: + data["description"] = description + + if parents is not None: + data["parents"] = parents + + return data + + +def create_folder( + name: str, + parents: list = None, + description: str = None, +) -> dict: # pragma: no cover + folder_resource = create_metadata( + name=name, + mimeType="application/vnd.google-apps.folder", + description=description, + parents=parents, + ) + + try: + folder = ( + get_service() + .files() + .create(body=folder_resource, fields="id", supportsAllDrives=True) + .execute() + ) + + return {"status": True, "result": folder["id"]} + except HttpError as e: + if e.resp.status == 403: + return { + "status": False, + "result": "Service Account: Insufficent Permissions", + } + else: + raise + + +def create_pdf( + name: str, + file, + parents: list = None, + description: str = None, +) -> dict: # pragma: no cover + pdf_resource = create_metadata( + name=name, + mimeType="application/pdf", + description=description, + parents=parents, + ) + + try: + file.seek(0) + + media = MediaIoBaseUpload(file, mimetype="application/pdf") + + pdf = ( + get_service() + .files() + .create( + body=pdf_resource, media_body=media, fields="id", supportsAllDrives=True + ) + .execute() + ) + + return {"status": True, "result": pdf["id"]} + except HttpError as e: + if e.resp.status == 403: + return { + "status": False, + "result": "Service Account: Insufficient Permissions", + } + else: + raise + + +def create_permission( + fileID: str, + typeID: str, + role: str, + emailAddress: str = None, + domain: str = None, +) -> dict: # pragma: no cover + body = { + "type": typeID, + "role": role, + } + if typeID in ["user", "group"]: + if not emailAddress: + raise ValueError( + "Email Address required for 'user' and 'group' permissions" + ) + body["emailAddress"] = emailAddress + elif typeID == "domain": + if not domain: + raise ValueError("Domain required for 'domain' permissions") + body["domain"] = domain + body["allowFileDiscovery"] = False + elif typeID == "anyone": + body["allowFileDiscovery"] = False + + try: + permission = ( + get_service() + .permissions() + .create(fileId=fileID, body=body, fields="id", supportsAllDrives=True) + .execute() + ) + return {"status": True, "id": permission["id"]} + except HttpError as e: + if e.resp.status == 403: + return {"status": False} + else: + raise + + +def delete_permission( + fileID: str, typeID: str, role: str, emailAddress: str = None, domain: str = None +) -> dict: # pragma: no cover + permissionID = get_permission_id(fileID, typeID, role, emailAddress, domain) + if not permissionID: + return {"status": False, "result": "No permission found"} + try: + deletion = ( + get_service() + .permissions() + .delete(fileId=fileID, permissionId=permissionID, supportsAllDrives=True) + .execute() + ) + return {"status": True} + except HttpError as e: + if e.resp.status == 403: + return { + "status": False, + "result": "Service Account: Insufficent Permissions", + } + else: + raise + + +def update_permission( + fileID: str, + typeID: str, + role: str, + new_role: str, + emailAddress: str = None, + domain: str = None, +) -> dict: # pragma: no cover + permissionID = get_permission_id(fileID, typeID, role, emailAddress, domain) + if not permissionID: + return {"status": False, "result": "No permission found"} + try: + update = ( + get_service() + .permissions() + .update( + fileId=fileID, + permissionId=permissionID, + supportsAllDrives=True, + body={"role": new_role}, + ) + .execute() + ) + return {"status": True} + except HttpError as e: + if e.resp.status == 403: + return { + "status": False, + "result": "Service Account: Insufficent Permissions", + } + else: + raise + + +def get_permission_id( + fileID: str, typeID: str, role: str, emailAddress: str = None, domain: str = None +) -> str: # pragma: no cover + permissions = ( + get_service() + .permissions() + .list( + fileId=fileID, + fields="permissions(id,type,role,emailAddress,domain)", + supportsAllDrives=True, + ) + .execute() + .get("permissions", []) + ) + + for p in permissions: + if p["type"] != typeID: + continue + if p["role"] != role: + continue + if typeID in ["user", "group"] and p["emailAddress"] != emailAddress: + continue + if typeID == "domain" and p["domain"] != domain: + continue + return p["id"] + return None + + +def get_files(folderID: str, mimeType: str = None) -> str: # pragma: no cover + query = f"'{folderID}' in parents and trashed = false" + + if mimeType: + query += f" and mimeType = '{mimeType}'" + + files = ( + get_service() + .files() + .list( + q=query, + fields="files(id, name, mimeType)", + supportsAllDrives=True, + includeItemsFromAllDrives=True, + spaces="drive", + ) + .execute() + ) + + return files.get("files", []) diff --git a/hknweb/migrations/0025_drivefolderid.py b/hknweb/migrations/0025_drivefolderid.py new file mode 100644 index 00000000..4c26b4da --- /dev/null +++ b/hknweb/migrations/0025_drivefolderid.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.17 on 2026-01-28 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("hknweb", "0024_profile_preferred_courses"), + ] + + operations = [ + migrations.CreateModel( + name="DriveFolderID", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("folderID", models.CharField(max_length=50)), + ], + ), + ] diff --git a/hknweb/models.py b/hknweb/models.py index 2857694c..1e1a1e60 100644 --- a/hknweb/models.py +++ b/hknweb/models.py @@ -133,3 +133,12 @@ def people(self) -> Dict[str, "QuerySet[User]"]: "Assistant Officer": self.assistant_officers.all(), "Committee Member": self.committee_members.all(), } + + +class DriveFolderID(models.Model): + title = models.CharField(max_length=100) + + folderID = models.CharField(max_length=50) + + def __str__(self) -> str: # pragma: no cover + return self.title diff --git a/hknweb/studentservices/admin.py b/hknweb/studentservices/admin.py index d04ce0d4..d9b8a656 100644 --- a/hknweb/studentservices/admin.py +++ b/hknweb/studentservices/admin.py @@ -78,6 +78,7 @@ class CourseDescriptionAdmin(admin.ModelAdmin): fields = ( "title", "slug", + "folderID", "description", "quick_links", "topics_covered", diff --git a/hknweb/studentservices/migrations/0014_coursedescription_folderid.py b/hknweb/studentservices/migrations/0014_coursedescription_folderid.py new file mode 100644 index 00000000..44016e7a --- /dev/null +++ b/hknweb/studentservices/migrations/0014_coursedescription_folderid.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.17 on 2026-01-28 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("studentservices", "0013_alter_coursedescription_description_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="coursedescription", + name="folderID", + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/hknweb/studentservices/models.py b/hknweb/studentservices/models.py index fe16f2f5..c821d9f4 100644 --- a/hknweb/studentservices/models.py +++ b/hknweb/studentservices/models.py @@ -107,6 +107,8 @@ class CourseDescription(models.Model): topics_covered = MarkdownxField(max_length=2000, blank=True) more_info = MarkdownxField(max_length=10000, blank=True) + folderID = models.CharField(max_length=50, blank=True) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/hknweb/studentservices/views.py b/hknweb/studentservices/views.py index 2a946f78..2ba5f0d5 100644 --- a/hknweb/studentservices/views.py +++ b/hknweb/studentservices/views.py @@ -22,6 +22,7 @@ CourseDescription, ) from hknweb.studentservices.forms import DocumentForm, TourRequest, CourseEditForm +from hknweb.tutoring.models import CribSheet @allow_public_access @@ -181,10 +182,19 @@ def course_guide_data(request): @allow_public_access def course_description(request, slug): course = get_object_or_404(CourseDescription, slug=slug) + cribsheets = CribSheet.objects.filter(course=course, public=True) + quick_links = course.quick_links + if cribsheets: + for sheet in cribsheets: + quick_links += ( + "\n" + + f"[{sheet.title}](https://drive.google.com/file/d/{sheet.fileID}/view)" + ) + context = { "course": course, "description": markdownify(course.description), - "quick_links": markdownify(course.quick_links), + "quick_links": markdownify(quick_links), "prerequisites": markdownify(course.prerequisites), "topics_covered": markdownify(course.topics_covered), "more_info": markdownify(course.more_info), diff --git a/hknweb/templates/tutoring/crib.html b/hknweb/templates/tutoring/crib.html new file mode 100644 index 00000000..26d94b43 --- /dev/null +++ b/hknweb/templates/tutoring/crib.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %} Crib Sheets {% endblock %} + +{% block header %} + +{% endblock %} + +{% block heading %} Crib Sheets {% endblock %} + + +{% block content %} + +
+ {% csrf_token %} + {{ form }} + +
+ +


+ +
+ + + + + + + +
+ + + + + + + + + + + + + + {% for sheet in sheets %} + + + + + + + + + {% empty %} + + {% endfor %} +
Course Semester Title Comment Upload Date Public?
{{ sheet.course }} {{ sheet.semester }} {{ sheet.title}} {{ sheet.comment }} {{ sheet.upload_date }} +
+ {% csrf_token %} + + +
+
+ +
+ + {% if page_obj.has_previous %} + « first + previous + {% endif %} + + + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}. + + + {% if page_obj.has_next %} + next + last » + {% endif %} + +
+ + + +{% endblock %} + diff --git a/hknweb/templates/tutoring/portal.html b/hknweb/templates/tutoring/portal.html index 0b967a5c..11a3b882 100644 --- a/hknweb/templates/tutoring/portal.html +++ b/hknweb/templates/tutoring/portal.html @@ -5,12 +5,18 @@ {% block heading %} Tutoring Admin Page {% endblock%} {% block "portal-content"%} - +

Course Descriptions

+ +
+

Crib Sheets

+
+
+

Return to Committees

diff --git a/hknweb/tutoring/admin.py b/hknweb/tutoring/admin.py index 8cb8cdd8..9c0001b6 100644 --- a/hknweb/tutoring/admin.py +++ b/hknweb/tutoring/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from hknweb.tutoring.models import Room, TutoringLogistics, Slot +from hknweb.tutoring.models import Room, TutoringLogistics, Slot, CribSheet @admin.register(TutoringLogistics) @@ -13,4 +13,23 @@ class SlotAdmin(admin.ModelAdmin): autocomplete_fields = ("tutors",) +@admin.register(CribSheet) +class CribSheetsAdmin(admin.ModelAdmin): + fields = [ + "semester", + "course", + "title", + "fileID", + "comment", + "public", + "upload_date", + ] + readonly_fields = ["upload_date"] + list_display = ("semester", "course", "title", "fileID", "public", "upload_date") + list_filter = ("semester", "course", "public") + search_fields = ("title", "course__title", "semester__year", "semester__semester") + + ordering = ("-upload_date",) + + admin.site.register(Room) diff --git a/hknweb/tutoring/forms.py b/hknweb/tutoring/forms.py index 828e5f76..08dde324 100644 --- a/hknweb/tutoring/forms.py +++ b/hknweb/tutoring/forms.py @@ -4,7 +4,10 @@ from hknweb.coursesemester.models import Course from hknweb.studentservices.models import CourseDescription +from hknweb.tutoring.models import CribSheet from hknweb.tutoring.views.autocomplete import get_tutors +from django.core.exceptions import ValidationError +import os class CourseFilterForm(forms.Form): @@ -39,3 +42,29 @@ class AddCourseForm(forms.ModelForm): class Meta: model = CourseDescription fields = ["title", "slug"] + + +class AddCribForm(forms.Form): + course = forms.ModelChoiceField(queryset=CourseDescription.objects.all()) + title = forms.CharField(max_length=100) + comment = forms.CharField(max_length=300, required=False) + file = forms.FileField(widget=forms.ClearableFileInput(attrs={"accept": ".pdf"})) + + MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB + ALLOWED_EXTENSIONS = {".pdf"} + ALLOWED_MIME_TYPES = {"application/pdf"} + + def clean_file(self): + file = self.cleaned_data["file"] + + if file.size > self.MAX_FILE_SIZE: + raise ValidationError("File too large (max 5MB).") + + ext = os.path.splitext(file.name)[1].lower() + if ext not in self.ALLOWED_EXTENSIONS: + raise ValidationError("Unsupported file type.") + + if file.content_type not in self.ALLOWED_MIME_TYPES: + raise ValidationError("Unsupported file type.") + + return file diff --git a/hknweb/tutoring/migrations/0004_cribsheet_and_more.py b/hknweb/tutoring/migrations/0004_cribsheet_and_more.py new file mode 100644 index 00000000..04024797 --- /dev/null +++ b/hknweb/tutoring/migrations/0004_cribsheet_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.17 on 2026-01-28 19:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("studentservices", "0014_coursedescription_folderid"), + ("coursesemester", "0002_auto_20210202_0225"), + ("tutoring", "0003_auto_20221128_1703"), + ] + + operations = [ + migrations.CreateModel( + name="CribSheet", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("fileID", models.CharField(max_length=50)), + ("title", models.CharField(max_length=100)), + ("comment", models.TextField(blank=True)), + ("upload_date", models.DateTimeField(auto_now_add=True)), + ("public", models.BooleanField(default=False)), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="studentservices.coursedescription", + ), + ), + ( + "semester", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="coursesemester.semester", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="cribsheet", + constraint=models.UniqueConstraint( + fields=("semester", "course", "title"), + name="unique_cribsheet_per_course_per_semester", + ), + ), + ] diff --git a/hknweb/tutoring/models.py b/hknweb/tutoring/models.py index 3a534850..a5e54a33 100644 --- a/hknweb/tutoring/models.py +++ b/hknweb/tutoring/models.py @@ -8,6 +8,7 @@ from django.contrib.auth.models import User from hknweb.coursesemester.models import Semester +from hknweb.studentservices.models import CourseDescription class Room(models.Model): @@ -65,3 +66,30 @@ def tutor_names(self) -> str: full_name=Concat("first_name", Value(" "), "last_name") ).values_list("full_name", flat=True) return ", ".join(tutors) + + +class CribSheet(models.Model): + semester = models.ForeignKey(Semester, on_delete=models.PROTECT) + + course = models.ForeignKey(CourseDescription, on_delete=models.PROTECT) + + fileID = models.CharField(max_length=50) + + title = models.CharField(max_length=100) + + comment = models.TextField(blank=True) + + upload_date = models.DateTimeField(auto_now_add=True) + + public = models.BooleanField(default=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["semester", "course", "title"], + name="unique_cribsheet_per_course_per_semester", + ) + ] + + def __str__(self) -> str: # pragma: no cover + return f"{self.course} - {self.title} ({self.semester})" diff --git a/hknweb/tutoring/urls.py b/hknweb/tutoring/urls.py index c1864907..16bd7913 100644 --- a/hknweb/tutoring/urls.py +++ b/hknweb/tutoring/urls.py @@ -18,4 +18,6 @@ ), path("portal", views.tutoringportal, name="tutoring_portal"), path("courses", views.courses, name="courses"), + path("crib", views.CribView.as_view(), name="crib"), + path("crib/toggle_public/", views.toggle_public, name="toggle_public"), ] diff --git a/hknweb/tutoring/views/__init__.py b/hknweb/tutoring/views/__init__.py index 53b4700b..6941a237 100644 --- a/hknweb/tutoring/views/__init__.py +++ b/hknweb/tutoring/views/__init__.py @@ -3,3 +3,4 @@ from hknweb.tutoring.views.tutoringportal import tutoringportal from hknweb.tutoring.views.courses import courses from hknweb.tutoring.views.autocomplete import course_autocomplete, tutor_autocomplete +from hknweb.tutoring.views.crib import CribView, toggle_public diff --git a/hknweb/tutoring/views/crib.py b/hknweb/tutoring/views/crib.py new file mode 100644 index 00000000..9a4fd993 --- /dev/null +++ b/hknweb/tutoring/views/crib.py @@ -0,0 +1,122 @@ +from django.shortcuts import render, get_object_or_404, redirect +from hknweb.utils import login_and_committee +from hknweb.tutoring.models import CribSheet, CourseDescription +from hknweb.tutoring.forms import AddCribForm +from hknweb.models import DriveFolderID +from hknweb.coursesemester.models import Semester +from django.conf import settings +from hknweb.google_drive_utils import ( + create_pdf, + create_folder, + create_permission, + delete_permission, +) +from django.views import View +from django.core.paginator import Paginator +from django.db.models import Q +from django.utils.decorators import method_decorator + + +@method_decorator(login_and_committee(settings.TUTORING_GROUP), name="dispatch") +class CribView(View): + template = "tutoring/crib.html" + paginate_by = 10 + + def get_queryset(self, request): + qs = CribSheet.objects.all() + + q = request.GET.get("q", "").strip() + if q: + qs = qs.filter( + Q(course__title__icontains=q) + | Q(title__icontains=q) + | Q(semester__semester__icontains=q) + | Q(semester__year__icontains=q) + ) + + course_query = request.GET.get("course", "").strip() + if course_query: + qs = qs.filter(course__title=course_query) + + semester_query = request.GET.get("semester", "").strip() + if semester_query: + semester_semester, semester_year = semester_query.split() + qs = qs.filter( + semester__semester=semester_semester, semester__year=semester_year + ) + + return qs.order_by("-upload_date") + + def get_context(self, request, qs): + paginator = Paginator(qs, self.paginate_by) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + "sheets": page_obj.object_list, + "semesters": Semester.objects.all().order_by("-year", "-semester"), + "courses": CourseDescription.objects.all(), + "form": AddCribForm(), + } + + return context + + def get(self, request): + qs = self.get_queryset(request) + context = self.get_context(request, qs) + return render(request, self.template, context=context) + + def post(self, request): + form = AddCribForm(request.POST, request.FILES) + if form.is_valid(): + file = request.FILES["file"] + course = form.cleaned_data["course"] + title = form.cleaned_data["title"] + comment = form.cleaned_data["comment"] + semester = Semester.get_current_semester() + + folderID = course.folderID + if not folderID: + parent_folderID = DriveFolderID.objects.get( + title="Crib Sheets" + ).folderID + folder_name = f"{course.title}" + result = create_folder(folder_name, parents=[parent_folderID]) + if result["status"]: + folderID = result["result"] + course.folderID = folderID + course.save() + else: + return + + result = create_pdf(title, file, parents=[folderID], description=comment) + + if result["status"]: + sheet = CribSheet.objects.create( + semester=semester, + course=course, + fileID=result["result"], + title=title, + comment=comment, + ) + sheet.save() + + qs = self.get_queryset(request) + context = self.get_context(request, qs) + + return render(request, self.template, context=context) + + +@login_and_committee(settings.TUTORING_GROUP) +def toggle_public(request, pk): + sheet = get_object_or_404(CribSheet, pk=pk) + sheet.public = not sheet.public + sheet.save() + + if sheet.public: + create_permission(sheet.fileID, typeID="anyone", role="reader") + else: + delete_permission(sheet.fileID, typeID="anyone", role="reader") + + return redirect(request.POST.get("next", "/")) diff --git a/tests/tutoring/views/test_crib.py b/tests/tutoring/views/test_crib.py new file mode 100644 index 00000000..9f6a2bf1 --- /dev/null +++ b/tests/tutoring/views/test_crib.py @@ -0,0 +1,107 @@ +from django.test import TestCase + +from django.urls import reverse + +from hknweb import settings +from hknweb.models import DriveFolderID +from tests.candidate.models.utils import ModelFactory +from django.contrib.auth.models import Group, AnonymousUser + +from hknweb.tutoring.models import CribSheet +from hknweb.coursesemester.models import Semester +from hknweb.google_drive_utils import create_pdf, create_folder +from hknweb.studentservices.models import CourseDescription +from django.core.files.uploadedfile import SimpleUploadedFile +from unittest.mock import patch + + +class CribViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.anon = AnonymousUser() + cls.tutoring_officer = ModelFactory.create_user(username="tutoring_officer") + cls.member = ModelFactory.create_user(username="member") + cls.tutoring_group = Group.objects.create(name=settings.TUTORING_GROUP) + cls.tutoring_officer.groups.add(cls.tutoring_group) + + cls.users = [(cls.anon, 302), (cls.member, 403), (cls.tutoring_officer, 200)] + + def test_crib_view_get(self): + for user, status_code in self.users: + self.client.force_login( + user + ) if user.is_authenticated else self.client.logout() + response = self.client.get(reverse("tutoring:crib")) + self.assertEqual(response.status_code, status_code) + if status_code == 200: + self.assertTemplateUsed(response, "tutoring/crib.html") + + def test_crib_view_get_query(self): + CourseDescription.objects.create(title="CS161", slug="cs161") + Semester.objects.create(semester="Spring", year=2026) + CribSheet.objects.create( + course=CourseDescription.objects.get(title="CS161"), + semester=Semester.objects.get(semester="Spring", year=2026), + title="Test Sheet", + ) + + for user, status_code in self.users: + self.client.force_login( + user + ) if user.is_authenticated else self.client.logout() + response = self.client.get( + reverse("tutoring:crib"), + {"q": "test", "course": "CS161", "semester": "Spring 2026"}, + ) + self.assertEqual(response.status_code, status_code) + if status_code == 200: + self.assertTemplateUsed(response, "tutoring/crib.html") + + def test_crib_view_post_empty_form(self): + self.client.force_login(self.tutoring_officer) + response = self.client.post(reverse("tutoring:crib"), {}) + + form = response.context.get("form") + self.assertIsNotNone(form) + self.assertFalse(form.is_valid()) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tutoring/crib.html") + + @patch("hknweb.tutoring.views.crib.create_pdf") + @patch("hknweb.tutoring.views.crib.create_folder") + def test_crib_view_post_valid_form(self, mock_create_folder, mock_create_pdf): + self.client.force_login(self.tutoring_officer) + + course = CourseDescription.objects.create(title="CS161", slug="cs161") + Semester.objects.create(semester="Spring", year=2026) + DriveFolderID.objects.create(title="Crib Sheets", folderID="test_folder_id") + + mock_create_pdf.return_value = {"status": True, "result": "test_pdf_id"} + mock_create_folder.return_value = {"status": True, "result": "test_folder_id"} + + valid_file = SimpleUploadedFile( + "test_crib.pdf", + b"%PDF-1.4 test pdf content", + content_type="application/pdf", + ) + + valid_data = { + "course": course.pk, + "title": "Test Sheet", + "file": valid_file, + } + + response = self.client.post( + reverse("tutoring:crib"), + data=valid_data, + files={"file": valid_file}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "tutoring/crib.html") + self.assertTrue( + CribSheet.objects.filter(title="Test Sheet", course=course).exists() + ) + mock_create_folder.assert_called_once() + course.refresh_from_db()