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 %}
+
+
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()