├── backend
├── ohq
│ ├── __init__.py
│ ├── management
│ │ ├── __init__.py
│ │ └── commands
│ │ │ ├── __init__.py
│ │ │ ├── calculatewaittimes.py
│ │ │ ├── archive.py
│ │ │ ├── queue_heatmap_stat.py
│ │ │ ├── queue_daily_stat.py
│ │ │ ├── user_stat.py
│ │ │ ├── course_stat.py
│ │ │ └── createcourse.py
│ ├── migrations
│ │ ├── __init__.py
│ │ ├── 0011_merge_20210415_2110.py
│ │ ├── 0015_question_templates.py
│ │ ├── 0010_auto_20210407_0145.py
│ │ ├── 0004_auto_20200825_1344.py
│ │ ├── 0018_auto_20220125_0344.py
│ │ ├── 0017_auto_20211031_1615.py
│ │ ├── 0014_question_student_descriptor.py
│ │ ├── 0013_auto_20210924_2056.py
│ │ ├── 0005_auto_20201016_1702.py
│ │ ├── 0016_auto_20211008_2136.py
│ │ ├── 0021_queue_question_timer_enabled_and_more.py
│ │ ├── 0003_auto_20200822_1116.py
│ │ ├── 0010_auto_20210405_1720.py
│ │ ├── 0008_auto_20210119_2218.py
│ │ ├── 0006_auto_20210105_2000.py
│ │ ├── 0012_queue_require_video_chat_url_on_questions.py
│ │ ├── 0007_announcement.py
│ │ ├── 0020_auto_20240326_0226.py
│ │ └── 0019_auto_20211114_1800.py
│ ├── apps.py
│ ├── routing.py
│ ├── pagination.py
│ ├── backends.py
│ ├── admin.py
│ ├── tasks.py
│ ├── sms.py
│ ├── queues.py
│ ├── filters.py
│ ├── invite.py
│ └── urls.py
├── tests
│ ├── __init__.py
│ └── ohq
│ │ ├── __init__.py
│ │ ├── test_health.py
│ │ ├── test_backends.py
│ │ └── test_invite.py
├── officehoursqueue
│ ├── settings
│ │ ├── __init__.py
│ │ ├── staging.py
│ │ ├── ci.py
│ │ ├── development.py
│ │ ├── proddev.py
│ │ └── production.py
│ ├── __init__.py
│ ├── wsgi.py
│ ├── templates
│ │ ├── emails
│ │ │ ├── course_invitation.html
│ │ │ ├── course_added.html
│ │ │ └── base.html
│ │ └── redoc.html
│ ├── asgi.py
│ ├── routing.py
│ ├── celery.py
│ └── urls.py
├── .dockerignore
├── scripts
│ └── asgi-run
├── setup.cfg
├── manage.py
├── Dockerfile
├── nginx-proddev.conf
├── pyproject.toml
└── docker-compose.yaml
├── frontend
├── .projectile
├── global.d.ts
├── .prettierrc.json
├── public
│ ├── ohq.png
│ ├── favicon.ico
│ ├── ohq-login.png
│ ├── joining-oh-1.png
│ ├── open-queue-1.png
│ ├── open-queue-2.png
│ ├── put-queue-1.png
│ ├── answer-queue-1.png
│ ├── create-course-1.png
│ ├── create-course-2.png
│ ├── create-course-3.png
│ ├── join-course-1.png
│ ├── join-course-2.png
│ ├── notifications-1.png
│ ├── remove-queue-1.png
│ ├── invite-members-1.png
│ ├── while-in-queue-1.png
│ └── vercel.svg
├── .editorconfig
├── components
│ ├── Course
│ │ ├── CourseSettings
│ │ │ ├── CourseForm.module.css
│ │ │ └── CourseSettings.tsx
│ │ ├── InstructorQueuePage
│ │ │ ├── aol.mp3
│ │ │ ├── timeupsound.mp3
│ │ │ ├── notification.mp3
│ │ │ ├── FinishConfirmModal.tsx
│ │ │ ├── QuestionTimer.tsx
│ │ │ ├── Tags.tsx
│ │ │ ├── ClearQueueModal.tsx
│ │ │ ├── Questions.tsx
│ │ │ └── QueueSettings
│ │ │ │ └── QueueSettings.tsx
│ │ ├── Analytics
│ │ │ ├── Cards
│ │ │ │ └── AnalyticsCard.tsx
│ │ │ ├── Analytics.tsx
│ │ │ └── Heatmaps
│ │ │ │ ├── Averages.tsx
│ │ │ │ └── Heatmap.tsx
│ │ ├── Roster
│ │ │ ├── ChangeRoleDropdown.tsx
│ │ │ ├── Invites
│ │ │ │ ├── AddForm.tsx
│ │ │ │ └── InviteModal.tsx
│ │ │ ├── RemoveButton.tsx
│ │ │ └── RosterForm.tsx
│ │ ├── StudentQueuePage
│ │ │ ├── StudentQueuePage.tsx
│ │ │ ├── DeleteQuestionModal.tsx
│ │ │ └── QueueMenuItem.tsx
│ │ └── CourseSidebarInstructorList.tsx
│ ├── Home
│ │ ├── AccountSettings
│ │ │ ├── VerificationModal.module.css
│ │ │ ├── AccountSettings.tsx
│ │ │ └── VerificationModal.tsx
│ │ ├── Home.tsx
│ │ └── Dashboard
│ │ │ ├── Modals
│ │ │ ├── ModalShowNewChanges.tsx
│ │ │ ├── ModalRedirectAddCourse.tsx
│ │ │ ├── ModalLeaveStudentCourse.tsx
│ │ │ └── ModalAddStudentCourse.tsx
│ │ │ ├── Messages
│ │ │ └── tips.json
│ │ │ ├── Cards
│ │ │ ├── AddCard.tsx
│ │ │ └── ArchivedCourseCard.tsx
│ │ │ └── Forms
│ │ │ └── AddStudentForm.tsx
│ ├── SignOut
│ │ └── index.tsx
│ ├── common
│ │ ├── ui
│ │ │ ├── LinkedText.tsx
│ │ │ └── ResponsiveIconButton.tsx
│ │ ├── AboutModal.tsx
│ │ └── Footer.tsx
│ ├── Changelog
│ │ └── changelogfile.md
│ ├── Guide
│ │ ├── utils.tsx
│ │ ├── StudentGuide.tsx
│ │ └── InstructorGuide.tsx
│ ├── Auth
│ │ └── AuthPrompt.tsx
│ └── Calendar
│ │ ├── calendarUtils.ts
│ │ └── DashboardCalendar
│ │ └── EventCard.tsx
├── utils
│ ├── gippage.ts
│ ├── ga
│ │ ├── googleAnalytics.ts
│ │ └── withGA.tsx
│ ├── redirect.ts
│ ├── sentry.tsx
│ ├── notifications.ts
│ ├── staffcheck.ts
│ ├── protectpage.tsx
│ ├── index.tsx
│ ├── enums.tsx
│ └── fetch.tsx
├── next-env.d.ts
├── .dockerignore
├── pages
│ ├── faq.tsx
│ ├── changelog.tsx
│ ├── calendar.tsx
│ ├── settings.tsx
│ ├── health.tsx
│ ├── _document.tsx
│ ├── _app.tsx
│ ├── _error.tsx
│ ├── courses
│ │ └── [course]
│ │ │ ├── calendar.tsx
│ │ │ ├── analytics.tsx
│ │ │ └── settings.tsx
│ └── index.tsx
├── Dockerfile
├── hooks
│ ├── debounce.ts
│ ├── player.ts
│ └── data-fetching
│ │ ├── resources.ts
│ │ ├── dashboard.ts
│ │ ├── account.ts
│ │ └── questionsummary.ts
├── styles
│ ├── landingpage.module.css
│ └── index.css
├── csrf.ts
├── tsconfig.json
├── next.config.js
├── constants.ts
├── server.js
├── context
│ └── auth.tsx
└── .eslintrc.json
├── k8s
├── .gitignore
├── cdk8s.yaml
├── package.json
└── tsconfig.json
├── .gitignore
├── LICENSE
└── ohq.code-workspace
/backend/ohq/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/.projectile:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/tests/ohq/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/ohq/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/k8s/.gitignore:
--------------------------------------------------------------------------------
1 | *.d.ts
2 | *.js
3 | node_modules
4 | dist/
5 |
--------------------------------------------------------------------------------
/k8s/cdk8s.yaml:
--------------------------------------------------------------------------------
1 | language: typescript
2 | app: node main.js
3 |
--------------------------------------------------------------------------------
/frontend/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.mp3";
2 | declare module "*.md";
3 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 4,
3 | "trailingComma": "es5"
4 | }
5 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/staging.py:
--------------------------------------------------------------------------------
1 | from officehoursqueue.settings.base import * # noqa: F401, F403
2 |
--------------------------------------------------------------------------------
/frontend/public/ohq.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/ohq.png
--------------------------------------------------------------------------------
/frontend/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*.{js, jsx, ts, tsx}]
4 | indent_style = space
5 | indent_size = 4
6 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/backend/ohq/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class OhqConfig(AppConfig):
5 | name = "ohq"
6 |
--------------------------------------------------------------------------------
/frontend/public/ohq-login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/ohq-login.png
--------------------------------------------------------------------------------
/frontend/public/joining-oh-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/joining-oh-1.png
--------------------------------------------------------------------------------
/frontend/public/open-queue-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/open-queue-1.png
--------------------------------------------------------------------------------
/frontend/public/open-queue-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/open-queue-2.png
--------------------------------------------------------------------------------
/frontend/public/put-queue-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/put-queue-1.png
--------------------------------------------------------------------------------
/frontend/public/answer-queue-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/answer-queue-1.png
--------------------------------------------------------------------------------
/frontend/public/create-course-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/create-course-1.png
--------------------------------------------------------------------------------
/frontend/public/create-course-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/create-course-2.png
--------------------------------------------------------------------------------
/frontend/public/create-course-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/create-course-3.png
--------------------------------------------------------------------------------
/frontend/public/join-course-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/join-course-1.png
--------------------------------------------------------------------------------
/frontend/public/join-course-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/join-course-2.png
--------------------------------------------------------------------------------
/frontend/public/notifications-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/notifications-1.png
--------------------------------------------------------------------------------
/frontend/public/remove-queue-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/remove-queue-1.png
--------------------------------------------------------------------------------
/backend/officehoursqueue/__init__.py:
--------------------------------------------------------------------------------
1 | from officehoursqueue.celery import app as celery_app
2 |
3 |
4 | __all__ = ("celery_app",)
5 |
--------------------------------------------------------------------------------
/frontend/components/Course/CourseSettings/CourseForm.module.css:
--------------------------------------------------------------------------------
1 | .department-input input {
2 | text-transform: uppercase;
3 | }
4 |
5 |
--------------------------------------------------------------------------------
/frontend/public/invite-members-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/invite-members-1.png
--------------------------------------------------------------------------------
/frontend/public/while-in-queue-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/public/while-in-queue-1.png
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/aol.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/components/Course/InstructorQueuePage/aol.mp3
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/timeupsound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/components/Course/InstructorQueuePage/timeupsound.mp3
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/notification.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pennlabs/office-hours-queue/HEAD/frontend/components/Course/InstructorQueuePage/notification.mp3
--------------------------------------------------------------------------------
/frontend/utils/gippage.ts:
--------------------------------------------------------------------------------
1 | import { NextPageContext, NextPage } from "next";
2 |
3 | export type GIPPage
= NextPage
& {
4 | getInitialProps: (ctx: NextPageContext) => P | Promise
;
5 | };
6 |
--------------------------------------------------------------------------------
/frontend/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/backend/ohq/routing.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from ohq.urls import realtime_router
4 |
5 |
6 | websocket_urlpatterns = [
7 | path("api/ws/subscribe/", realtime_router.as_consumer().as_asgi(), name="subscriptions"),
8 | ]
9 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/ci.py:
--------------------------------------------------------------------------------
1 | from officehoursqueue.settings.base import * # noqa: F401, F403
2 |
3 |
4 | TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner"
5 | TEST_OUTPUT_VERBOSE = 2
6 | TEST_OUTPUT_DIR = "test-results"
7 |
--------------------------------------------------------------------------------
/frontend/components/Home/AccountSettings/VerificationModal.module.css:
--------------------------------------------------------------------------------
1 | .react-code-input input[type=number]::-webkit-inner-spin-button,
2 | .react-code-input input[type=number]::-webkit-outer-spin-button {
3 | -webkit-appearance: none;
4 | margin: 0;
5 | }
6 |
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | # Docker
2 | Dockerfile
3 | .dockerignore
4 |
5 | # git
6 | .circleci
7 | .git
8 | .gitignore
9 | .gitmodules
10 | **/*.md
11 | LICENSE
12 |
13 | # Misc
14 | .coverage
15 | **/__pycache__/
16 | tests/
17 | postgres/
18 | .venv
19 |
--------------------------------------------------------------------------------
/backend/ohq/pagination.py:
--------------------------------------------------------------------------------
1 | from rest_framework.pagination import PageNumberPagination
2 |
3 |
4 | class QuestionSearchPagination(PageNumberPagination):
5 | """
6 | Custom pagination for QuestionListView.
7 | """
8 |
9 | page_size = 20
10 |
--------------------------------------------------------------------------------
/frontend/.dockerignore:
--------------------------------------------------------------------------------
1 | # Docker
2 | Dockerfile
3 | .dockerignore
4 |
5 | # git
6 | .circleci
7 | .git
8 | .gitignore
9 | .gitmodules
10 | **/*.md
11 | !components/Changelog/changelogfile.md
12 | LICENSE
13 |
14 | # Misc
15 | node_modules/
16 | .next/
17 |
--------------------------------------------------------------------------------
/backend/scripts/asgi-run:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Django Migrate
4 | /usr/local/bin/python3 /app/manage.py migrate --noinput
5 |
6 | # Switch to project folder
7 | cd /app/
8 |
9 | # Run Uvicorn through Gunicorn
10 | exec /usr/local/bin/gunicorn -b 0.0.0.0:80 -w 4 -k uvicorn.workers.UvicornWorker officehoursqueue.asgi:application
11 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0011_merge_20210415_2110.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-04-15 21:10
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0010_auto_20210407_0145"),
10 | ("ohq", "0010_auto_20210405_1720"),
11 | ]
12 |
13 | operations = []
14 |
--------------------------------------------------------------------------------
/backend/tests/ohq/test_health.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.urls import reverse
3 |
4 | class HealthTestCase(TestCase):
5 | def test_health(self):
6 | url = reverse("health")
7 | resp = self.client.get(url)
8 | self.assertEqual(resp.status_code, 200)
9 | self.assertEqual(resp.data, {"message": "OK"})
10 |
--------------------------------------------------------------------------------
/frontend/components/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from "semantic-ui-react";
2 | import HomeSidebar from "./HomeSidebar";
3 |
4 | const Home = ({ children, ...props }) => {
5 | return (
6 |
7 |
8 | {children}
9 |
10 | );
11 | };
12 |
13 | export default Home;
14 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/calculatewaittimes.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from ohq.queues import calculate_wait_times
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Calculates the estimated wait times of all unarchived queues."
8 |
9 | def handle(self, *args, **kwargs):
10 | calculate_wait_times()
11 | self.stdout.write("Updated estimated queue wait times!")
12 |
--------------------------------------------------------------------------------
/frontend/pages/faq.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Home from "../components/Home/Home";
3 | import Guide from "../components/Guide";
4 |
5 | const GuidePage = () => {
6 | return (
7 | <>
8 |
9 | OHQ | FAQ
10 |
11 |
12 |
13 |
14 | >
15 | );
16 | };
17 |
18 | export default GuidePage;
19 |
--------------------------------------------------------------------------------
/frontend/utils/ga/googleAnalytics.ts:
--------------------------------------------------------------------------------
1 | import ReactGA from "react-ga";
2 |
3 | const prod = process.env.NODE_ENV === "production";
4 |
5 | export const initGA = () => {
6 | if (prod) {
7 | ReactGA.initialize("UA-21029575-18");
8 | }
9 | };
10 |
11 | export const logPageView = () => {
12 | if (prod) {
13 | ReactGA.set({ page: window.location.pathname });
14 | ReactGA.pageview(window.location.pathname);
15 | }
16 | };
17 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0015_question_templates.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 |
6 | dependencies = [
7 | ("ohq", "0014_question_student_descriptor"),
8 | ]
9 |
10 | operations = [
11 | migrations.AddField(
12 | model_name="queue",
13 | name="question_template",
14 | field=models.TextField(blank=True, default=""),
15 | ),
16 | ]
17 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0010_auto_20210407_0145.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-04-07 01:45
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0009_auto_20210201_2224"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="announcement",
15 | name="content",
16 | field=models.TextField(),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18-buster-slim
2 |
3 | LABEL maintainer="Penn Labs"
4 |
5 | WORKDIR /app/
6 |
7 | # Copy project dependencies
8 | COPY package.json /app/
9 | COPY yarn.lock /app/
10 |
11 | # Install project dependencies
12 | RUN yarn install --frozen-lockfile --production=true
13 |
14 | # Copy project files
15 | COPY . /app/
16 |
17 | # Disable telemetry back to zeit
18 | ENV NEXT_TELEMETRY_DISABLED=1
19 |
20 | # Build project
21 | RUN yarn build
22 |
23 | CMD ["yarn", "start"]
24 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0004_auto_20200825_1344.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1 on 2020-08-25 17:44
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0003_auto_20200822_1116"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="queue",
15 | name="estimated_wait_time",
16 | field=models.IntegerField(default=-1),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0018_auto_20220125_0344.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2022-01-25 03:44
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0017_auto_20211031_1615"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="course",
15 | name="course_title",
16 | field=models.CharField(max_length=100),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/frontend/pages/changelog.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Head from "next/head";
3 | import Home from "../components/Home/Home";
4 | import Changelog from "../components/Changelog";
5 |
6 | const LandingPage = () => {
7 | return (
8 | <>
9 |
10 | OHQ | Changelog
11 |
12 |
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export default LandingPage;
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | frontend/package-lock.json
2 | frontend/yarn-error.log
3 |
4 | # IDE files
5 | .vscode/
6 | .idea/
7 |
8 | # Python files
9 | __pycache__/
10 | *.pyc
11 |
12 | # Distribution
13 | build/
14 | dist/
15 | *.egg-info/
16 |
17 | # Code testing/coverage
18 | .tox
19 | test-results/
20 | .coverage
21 | htmlcov/
22 |
23 | # Test database
24 | db.sqlite3
25 | postgres
26 |
27 | # Mac
28 | .DS_Store
29 |
30 | # React
31 | node_modules/
32 | .next/
33 | .log
34 |
35 | # Firebase credentials
36 | ohq-firebase-*.json
37 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for officehoursqueue project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 |
15 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.production")
16 |
17 | application = get_wsgi_application()
18 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0017_auto_20211031_1615.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-10-31 16:15
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0016_auto_20211008_2136"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="queue",
15 | name="pin",
16 | field=models.CharField(blank=True, max_length=50, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0014_question_student_descriptor.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-10-03 17:13
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0013_auto_20210924_2056"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="question",
15 | name="student_descriptor",
16 | field=models.CharField(blank=True, max_length=255, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/templates/emails/course_invitation.html:
--------------------------------------------------------------------------------
1 | {% extends 'emails/base.html' %}
2 |
3 | {% block content %}
4 |
5 | Invitation to join {{ course }} OHQ
6 |
7 | You have been invited to join {{ course }} as a {{ role }}.
8 |
9 | Click below to create your account!
10 |
11 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for officehoursqueue project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | import django
13 | from channels.routing import get_default_application
14 |
15 |
16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.production")
17 | django.setup()
18 | application = get_default_application()
19 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/templates/emails/course_added.html:
--------------------------------------------------------------------------------
1 | {% extends 'emails/base.html' %}
2 |
3 | {% block content %}
4 |
5 | You've been added to {{ course }} OHQ
6 |
7 | You have been added to {{ course }} as a {{ role }}.
8 |
9 | Click below to view this course's office hours queues!
10 |
11 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/frontend/pages/calendar.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import Home from "../components/Home/Home";
3 | import Calendar from "../components/Calendar/StudentCalendar/StudentCalendar";
4 | import { withAuth } from "../context/auth";
5 |
6 | const CalendarPage = () => {
7 | return (
8 | <>
9 |
10 | OHQ | Calendar
11 |
12 |
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export default withAuth(CalendarPage);
20 |
--------------------------------------------------------------------------------
/frontend/hooks/debounce.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 |
3 | function useDebounce(
4 | f: (...args: T) => void,
5 | interval: number
6 | ): (...args: T) => void {
7 | const ref = useRef(null);
8 |
9 | const debouncedF = (...args: T): void => {
10 | if (ref.current) {
11 | clearTimeout(ref.current);
12 | }
13 | ref.current = setTimeout(() => {
14 | f(...args);
15 | }, interval);
16 | };
17 |
18 | return debouncedF;
19 | }
20 |
21 | export default useDebounce;
22 |
--------------------------------------------------------------------------------
/frontend/pages/settings.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { withAuth } from "../context/auth";
3 | import Home from "../components/Home/Home";
4 | import AccountSettings from "../components/Home/AccountSettings/AccountSettings";
5 |
6 | const LandingPage = () => {
7 | return (
8 | <>
9 |
10 | OHQ | Account Settings
11 |
12 |
13 |
14 |
15 | >
16 | );
17 | };
18 |
19 | export default withAuth(LandingPage);
20 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/routing.py:
--------------------------------------------------------------------------------
1 | from channels.auth import AuthMiddlewareStack
2 | from channels.routing import ProtocolTypeRouter, URLRouter
3 | from channels.security.websocket import AllowedHostsOriginValidator
4 |
5 | import ohq.routing
6 | import ohq.urls # DO NOT DELETE THIS IMPORT!
7 |
8 |
9 | # Django REST Live requires urls too be imported from the async entrypoint.
10 |
11 | application = ProtocolTypeRouter(
12 | {
13 | "websocket": AllowedHostsOriginValidator(
14 | AuthMiddlewareStack(URLRouter(ohq.routing.websocket_urlpatterns))
15 | )
16 | }
17 | )
18 |
--------------------------------------------------------------------------------
/frontend/pages/health.tsx:
--------------------------------------------------------------------------------
1 | import { GetServerSideProps } from "next";
2 |
3 | const HealthPage = () => {
4 | return OK
;
5 | };
6 |
7 | export const getServerSideProps: GetServerSideProps = async ({ req }) => {
8 | const userAgent = req.headers["user-agent"] || "";
9 |
10 | if (userAgent !== "service-status") {
11 | return {
12 | redirect: {
13 | destination: "/",
14 | permanent: false,
15 | },
16 | };
17 | }
18 |
19 | return {
20 | props: {},
21 | };
22 | };
23 |
24 | export default HealthPage;
25 |
--------------------------------------------------------------------------------
/frontend/components/SignOut/index.tsx:
--------------------------------------------------------------------------------
1 | import { Menu, Icon } from "semantic-ui-react";
2 |
3 | const SignOutButton = () => {
4 | return (
5 |
15 |
16 | Sign Out
17 |
18 | );
19 | };
20 |
21 | export default SignOutButton;
22 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0013_auto_20210924_2056.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-09-24 20:56
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0012_queue_require_video_chat_url_on_questions"),
10 | ]
11 |
12 | operations = [
13 | migrations.RemoveField(
14 | model_name="course",
15 | name="require_video_chat_url_on_questions",
16 | ),
17 | migrations.RemoveField(
18 | model_name="course",
19 | name="video_chat_enabled",
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/development.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from officehoursqueue.settings.base import * # noqa: F401, F403
4 | from officehoursqueue.settings.base import INSTALLED_APPS, MIDDLEWARE
5 |
6 |
7 | # Development extensions
8 | INSTALLED_APPS += ["django_extensions", "debug_toolbar"]
9 |
10 | MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE
11 | INTERNAL_IPS = ["127.0.0.1"]
12 |
13 | # Allow http callback for DLA
14 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
15 |
16 | # Use the console for email in development
17 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
18 |
--------------------------------------------------------------------------------
/backend/ohq/backends.py:
--------------------------------------------------------------------------------
1 | from accounts.backends import LabsUserBackend
2 |
3 | from ohq.models import Membership, MembershipInvite
4 |
5 |
6 | class OHQBackend(LabsUserBackend):
7 | """
8 | A custom DLA backend that converts Membership Invites into Memberships on user creation.
9 | """
10 |
11 | def post_authenticate(self, user, created, dictionary):
12 | if created:
13 | invites = MembershipInvite.objects.filter(email__istartswith=f"{user.username}@")
14 |
15 | for invite in invites:
16 | Membership.objects.create(course=invite.course, kind=invite.kind, user=user)
17 |
18 | invites.delete()
19 | user.save()
20 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0005_auto_20201016_1702.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.1 on 2020-10-16 21:02
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0004_auto_20200825_1344"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="question",
15 | name="note",
16 | field=models.CharField(blank=True, max_length=255, null=True),
17 | ),
18 | migrations.AddField(
19 | model_name="question",
20 | name="resolved_note",
21 | field=models.BooleanField(default=True),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0016_auto_20211008_2136.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-10-08 21:36
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0015_question_templates"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="queue",
15 | name="pin_enabled",
16 | field=models.BooleanField(default=False),
17 | ),
18 | migrations.AddField(
19 | model_name="queue",
20 | name="pin",
21 | field=models.CharField(blank=True, default="", max_length=5, null=True),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/frontend/styles/landingpage.module.css:
--------------------------------------------------------------------------------
1 | .about-landing {
2 | position: fixed;
3 | bottom: 0;
4 | right: 0;
5 | margin: 10px 5px 15px 10px;
6 | }
7 |
8 | @media only screen and (max-width: 767px) {
9 | .about-dashboard {
10 | position: absolute;
11 | bottom: 0;
12 | margin: 12px 12px 12px 22px;
13 | }
14 | }
15 |
16 | @media only screen and (min-width: 768px) {
17 | .about-dashboard {
18 | position: fixed;
19 | bottom: 0;
20 | margin: 12px 12px 12px 13px;
21 | }
22 | }
23 |
24 | .about p {
25 | cursor: pointer;
26 | color: #4285f5;
27 | text-decoration: underline;
28 | font-size: 10pt;
29 | font-weight: 800;
30 | }
31 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/proddev.py:
--------------------------------------------------------------------------------
1 | # Django config based off of production config, but with minor changes to ensure it works on dev machines
2 |
3 | from officehoursqueue.settings.production import *
4 |
5 | import officehoursqueue.settings.base as base
6 |
7 | # No https on dev machines
8 | SECURE_PROXY_SSL_HEADER = ()
9 |
10 | # Prevents request rejection on dev machines
11 | ALLOWED_HOSTS = ["*"]
12 |
13 | # Use local login instead of UPenn's
14 | PLATFORM_ACCOUNTS = base.PLATFORM_ACCOUNTS
15 |
16 | # Allow http callback for DLA
17 | os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
18 |
19 | # Use the console for email in development
20 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
21 |
--------------------------------------------------------------------------------
/frontend/csrf.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @returns {string | boolean} The CSRF token used by the Django REST Framework
3 | */
4 | const getCsrf = () => {
5 | const result =
6 | document.cookie &&
7 | document.cookie
8 | .split("; ")
9 | .reduce(
10 | (acc, cookie) =>
11 | acc ||
12 | (cookie.substring(0, "csrftoken".length + 1) ===
13 | "csrftoken=" &&
14 | decodeURIComponent(
15 | cookie.substring("csrftoken=".length)
16 | )),
17 | null
18 | );
19 | return result;
20 | };
21 |
22 | export default getCsrf;
23 |
--------------------------------------------------------------------------------
/backend/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 100
3 | exclude = .venv, migrations
4 | inline-quotes = double
5 |
6 | [isort]
7 | default_section = THIRDPARTY
8 | known_first_party = ohq, officehoursqueue
9 | line_length = 100
10 | lines_after_imports = 2
11 | multi_line_output = 3
12 | include_trailing_comma = True
13 | use_parentheses = True
14 |
15 | # [coverage:run]
16 | # omit = */tests/*, */migrations/*, */settings/*, */asgi.py, */wsgi.py, */apps.py, */schemas.py, */.venv/*, manage.py, */management/commands/populate.py
17 | # source = .
18 |
19 | [uwsgi]
20 | http-socket = :80
21 | chdir = /app/
22 | module = officehoursqueue.wsgi:application
23 | master = true
24 | static-map = /assets=/app/static
25 | processes = 5
26 |
--------------------------------------------------------------------------------
/frontend/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from "next/document";
2 |
3 | const Document = () => {
4 | return (
5 |
6 |
7 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default Document;
23 |
--------------------------------------------------------------------------------
/frontend/utils/redirect.ts:
--------------------------------------------------------------------------------
1 | import Router from "next/router";
2 | import { NextPageContext } from "next";
3 |
4 | export default function nextRedirect(
5 | { req, res }: NextPageContext,
6 | condition: (url: string) => boolean,
7 | redirectUrl: string
8 | ) {
9 | // if redirect is called server side
10 | if (req && res) {
11 | // if request doesn't have an associated URL or the condition
12 | // on the url applies, we redirect
13 | if (!req.url || condition(req.url)) {
14 | res.writeHead(302, { Location: redirectUrl });
15 | res.end();
16 | }
17 | } else if (condition(window.location.pathname)) {
18 | Router.replace(redirectUrl);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0021_queue_question_timer_enabled_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.0.3 on 2024-10-11 21:08
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0020_auto_20240326_0226"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="queue",
15 | name="question_timer_enabled",
16 | field=models.BooleanField(default=False),
17 | ),
18 | migrations.AddField(
19 | model_name="queue",
20 | name="question_timer_start_time",
21 | field=models.IntegerField(blank=True, null=True),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "strictNullChecks": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "noEmit": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "incremental": true
22 | },
23 | "exclude": [
24 | "node_modules"
25 | ],
26 | "include": [
27 | "next-env.d.ts",
28 | "**/*.ts",
29 | "**/*.tsx"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/backend/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.development")
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed and "
14 | "available on your PYTHONPATH environment variable? Did you "
15 | "forget to activate a virtual environment?"
16 | ) from exc
17 | execute_from_command_line(sys.argv)
18 |
19 |
20 | if __name__ == "__main__":
21 | main()
22 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/templates/redoc.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ReDoc
5 |
6 |
7 |
8 |
9 |
10 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/celery.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from celery import Celery
4 | from django.conf import settings
5 |
6 |
7 | # set the default Django settings module for the 'celery' program.
8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "officehoursqueue.settings.development")
9 |
10 | app = Celery("officehoursqueue", broker=settings.MESSAGE_BROKER_URL)
11 |
12 | # Using a string here means the worker doesn't have to serialize
13 | # the configuration object to child processes.
14 | # - namespace='CELERY' means all celery-related configuration keys
15 | # should have a `CELERY_` prefix.
16 | app.config_from_object("django.conf:settings", namespace="CELERY")
17 |
18 | # Load task modules from all registered Django app configs.
19 | app.autodiscover_tasks()
20 |
--------------------------------------------------------------------------------
/backend/ohq/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from ohq.models import (
4 | Announcement,
5 | Course,
6 | CourseStatistic,
7 | Membership,
8 | MembershipInvite,
9 | Profile,
10 | Question,
11 | Queue,
12 | QueueStatistic,
13 | Semester,
14 | Tag,
15 | UserStatistic,
16 | )
17 |
18 |
19 | admin.site.register(Course)
20 | admin.site.register(CourseStatistic)
21 | admin.site.register(Membership)
22 | admin.site.register(MembershipInvite)
23 | admin.site.register(Profile)
24 | admin.site.register(Question)
25 | admin.site.register(Queue)
26 | admin.site.register(Semester)
27 | admin.site.register(QueueStatistic)
28 | admin.site.register(Announcement)
29 | admin.site.register(Tag)
30 | admin.site.register(UserStatistic)
31 |
--------------------------------------------------------------------------------
/k8s/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "k8s",
3 | "version": "1.0.0",
4 | "main": "main.js",
5 | "types": "main.ts",
6 | "license": "Apache-2.0",
7 | "private": true,
8 | "scripts": {
9 | "import": "cdk8s import",
10 | "synth": "cdk8s synth",
11 | "compile": "tsc",
12 | "watch": "tsc -w",
13 | "test": "jest",
14 | "build": "npm run compile && npm run synth",
15 | "upgrade": "npm i cdk8s@latest cdk8s-cli@latest",
16 | "upgrade:next": "npm i cdk8s@next cdk8s-cli@next"
17 | },
18 | "dependencies": {
19 | "@pennlabs/kittyhawk": "^1.1.11",
20 | "cdk8s": "^2.2.63",
21 | "constructs": "^10.0.110"
22 | },
23 | "devDependencies": {
24 | "@types/jest": "^26.0.24",
25 | "@types/node": "^14.18.12",
26 | "jest": "^26.6.3",
27 | "ts-jest": "^26.5.6",
28 | "typescript": "^4.6.3"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Modals/ModalShowNewChanges.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button, Modal } from "semantic-ui-react";
3 | import Changelog from "../../../Changelog";
4 |
5 | interface ModalShowNewChangesProps {
6 | openModal: boolean;
7 | setOpen: (boolean) => void;
8 | }
9 |
10 | const ModalShowNewChanges = (props: ModalShowNewChangesProps) => {
11 | const { openModal, setOpen } = props;
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | };
23 |
24 | export default ModalShowNewChanges;
25 |
--------------------------------------------------------------------------------
/frontend/components/common/ui/LinkedText.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import Linkify from "react-linkify";
3 |
4 | interface LinkedTextProps {
5 | text: string;
6 | }
7 |
8 | const LinkedText = ({ text }: LinkedTextProps) => {
9 | const componentDecorator = (
10 | decoratedHref: string,
11 | decoratedText: string,
12 | key: number
13 | ) => (
14 |
20 | {decoratedText}
21 |
22 | );
23 |
24 | const linkified = (
25 | {text}
26 | );
27 | return linkified;
28 | };
29 |
30 | export default LinkedText;
31 |
--------------------------------------------------------------------------------
/frontend/styles/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
4 | "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
5 | "Helvetica Neue", sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
15 | #__next {
16 | /* Grid components use negative margin of 1rem */
17 | /* We can consider fixing them, since negative margins might not*/
18 | /* necessarily be good practice, but it is really hard to get the*/
19 | /* same behavior back so we do this as a "quick hack" */
20 | height: calc(100% - 1rem);
21 | width: 100%;
22 | display: flex;
23 | }
24 |
--------------------------------------------------------------------------------
/frontend/utils/sentry.tsx:
--------------------------------------------------------------------------------
1 | let Sentry;
2 |
3 | const SENTRY_URL =
4 | "https://8bac880d12c34567b17d8af1ea4ea0d1@sentry.pennlabs.org/18";
5 | const dev = process.env.NODE_ENV !== "production";
6 |
7 | if (process.browser) {
8 | // If the code is running in user's browser
9 | Sentry = require("@sentry/browser");
10 | } else {
11 | // If code is running on the server
12 | Sentry = require("@sentry/node");
13 | }
14 |
15 | if (!dev) {
16 | Sentry.init({ dsn: SENTRY_URL });
17 | }
18 |
19 | export function logException(ex: Error, context?: any): void {
20 | Sentry.captureException(ex, {
21 | extra: context,
22 | });
23 | window.console && console.error && console.error(ex); // eslint-disable-line no-console
24 | }
25 |
26 | export function logMessage(msg: string): void {
27 | Sentry.captureMessage(msg);
28 | }
29 |
--------------------------------------------------------------------------------
/backend/ohq/tasks.py:
--------------------------------------------------------------------------------
1 | from celery import shared_task
2 |
3 | from ohq.models import Question
4 | from ohq.sms import sendUpNextNotification
5 |
6 |
7 | @shared_task(name="ohq.tasks.sendUpNextNotificationTask")
8 | def sendUpNextNotificationTask(queue_id):
9 | """
10 | Send an SMS notification to the 3rd person in a queue if they have verified their phone number
11 | and the queue was at least 4 people long when they joined it.
12 | """
13 |
14 | questions = Question.objects.filter(queue=queue_id, status=Question.STATUS_ASKED).order_by(
15 | "time_asked"
16 | )
17 | if questions.count() >= 3:
18 | question = questions[2]
19 | user = question.asked_by
20 | if question.should_send_up_soon_notification and user.profile.sms_verified:
21 | sendUpNextNotification(user, question.queue.course)
22 |
--------------------------------------------------------------------------------
/frontend/components/Home/AccountSettings/AccountSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Segment, Header, Grid } from "semantic-ui-react";
2 | import AccountForm from "./AccountForm";
3 | import Footer from "../../common/Footer";
4 |
5 | const AccountSettings = () => {
6 | return (
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default AccountSettings;
27 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0003_auto_20200822_1116.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1 on 2020-08-22 15:16
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0002_auto_20200816_1727"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="question",
15 | name="status",
16 | field=models.CharField(
17 | choices=[
18 | ("ASKED", "Asked"),
19 | ("WITHDRAWN", "Withdrawn"),
20 | ("ACTIVE", "Active"),
21 | ("REJECTED", "Rejected"),
22 | ("ANSWERED", "Answered"),
23 | ],
24 | default="ASKED",
25 | max_length=9,
26 | ),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/k8s/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "alwaysStrict": true,
4 | "charset": "utf8",
5 | "declaration": true,
6 | "experimentalDecorators": true,
7 | "inlineSourceMap": true,
8 | "inlineSources": true,
9 | "lib": [
10 | "es2016"
11 | ],
12 | "module": "CommonJS",
13 | "noEmitOnError": true,
14 | "noFallthroughCasesInSwitch": true,
15 | "noImplicitAny": true,
16 | "noImplicitReturns": true,
17 | "noImplicitThis": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "resolveJsonModule": true,
21 | "strict": true,
22 | "strictNullChecks": true,
23 | "strictPropertyInitialization": true,
24 | "stripInternal": true,
25 | "target": "ES2017"
26 | },
27 | "include": [
28 | "**/*.ts"
29 | ],
30 | "exclude": [
31 | "node_modules"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/archive.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 |
3 | from ohq.models import Course, Semester
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Creates a course with default settings and invites users to course"
8 |
9 | def add_arguments(self, parser):
10 | parser.add_argument(
11 | "term", type=str, choices=[choice[0] for choice in Semester.TERM_CHOICES]
12 | )
13 | parser.add_argument("year", type=int)
14 |
15 | def handle(self, *args, **kwargs):
16 | term = kwargs["term"]
17 | year = kwargs["year"]
18 |
19 | courses = Course.objects.filter(semester__year=year, semester__term=term)
20 | for course in courses:
21 | course.archived = True
22 | course.save()
23 |
24 | self.stdout.write(f"{len(courses)} course(s) archived")
25 |
--------------------------------------------------------------------------------
/frontend/utils/ga/withGA.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { NextPage } from "next";
3 | import { initGA, logPageView } from "./googleAnalytics";
4 |
5 | export default function withGA(WrappedComponent: NextPage) {
6 | const GAComponent = ({ ...props }: T) => {
7 | useEffect(() => {
8 | // @ts-ignore - this hacks the window object and is apparently recommended
9 | // https://coderrocketfuel.com/article/add-google-analytics-to-a-next-js-and-react-website
10 | if (!window.GA_INITIALIZED) {
11 | initGA();
12 | // @ts-ignore
13 | window.GA_INITIALIZED = true;
14 | }
15 | logPageView();
16 | }, []);
17 | /* eslint-disable */
18 | // @ts-ignore
19 | return ;
20 | /* eslint-enable */
21 | };
22 |
23 | return GAComponent;
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/components/Course/Analytics/Cards/AnalyticsCard.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Header } from "semantic-ui-react";
2 | import React from "react";
3 |
4 | interface AnalyticsCardProps {
5 | label: string;
6 | content: string;
7 | isValidating: boolean;
8 | }
9 |
10 | export default function AnalyticsCard({
11 | label,
12 | content,
13 | isValidating,
14 | }: AnalyticsCardProps) {
15 | return (
16 |
24 |
25 |
26 |
27 | {isValidating ? "..." : content}
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/utils/notifications.ts:
--------------------------------------------------------------------------------
1 | import { logException } from "./sentry";
2 |
3 | export function browserSupportsNotifications() {
4 | return typeof window !== "undefined" && "Notification" in window;
5 | }
6 |
7 | export function askNotificationPermissions() {
8 | if (browserSupportsNotifications()) {
9 | try {
10 | Notification.requestPermission();
11 | } catch (e) {
12 | logException(e);
13 | }
14 | }
15 | }
16 |
17 | export function playNotification(message: string) {
18 | if (browserSupportsNotifications()) {
19 | try {
20 | /* eslint-disable-next-line */
21 | new Notification("Alert", {
22 | body: message,
23 | icon: "../favicon.ico",
24 | });
25 | } catch (e) {
26 | logException(e);
27 | }
28 | }
29 | }
30 |
31 | export function checkPermissions() {
32 | return localStorage && localStorage.getItem("notifs") === "false";
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/components/Changelog/changelogfile.md:
--------------------------------------------------------------------------------
1 | # 2024-08-25
2 | ### Added
3 | - Courses may now add a calendar to schedule office hours and provide additional event information to students. Please visit the FAQ for more information.
4 |
5 | ## 2022-04-03
6 | ### Added
7 | - New analytics cards listing summary statistics for queues, specifically the number of questions answered, average wait time, number of students helped, and average time helping each student.
8 | ### Changed
9 | - The max character limit for course titles has been increased to 100.
10 |
11 | ## 2022-02-05
12 | ### Added
13 | - Pin feature that can be turned on and off in queue settings. If selected, generates a random pin upon opening queue that students must input when asking a question. This pin can be changed by instructors.
14 |
15 | ## 2021-11-21
16 | ### Added
17 | - Changelog feature to share updates to OHQ. Check here primarily for bug fixes. A more extensive guide on how to use OHQ features can be found in our [FAQ page](faq).
18 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Messages/tips.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "title": "Feedback",
4 | "description": "Think something could be better? Tell us! Submit any thoughts/bugs/ideas to “Feedback”",
5 | "student": true
6 | },
7 | {
8 | "title": "SMS Notification",
9 | "description": "Get notified when you’re almost through the queue! Find “Account Settings” < Enable SMS Notifications < Save",
10 | "student": true
11 | },
12 | {
13 | "title": "Changelog",
14 | "description": "Take a look at new features and changes to the site! Find “Changelogs”",
15 | "student": true
16 | },
17 | {
18 | "title": "FAQ",
19 | "description": "Have questions? Look through our “FAQ” tab on the sidebar!",
20 | "student": true
21 | },
22 | {
23 | "title": "[Instructor] View Past Questions",
24 | "description": "Go to Question Summary to view past questions.",
25 | "student": false
26 | }
27 | ]
28 |
--------------------------------------------------------------------------------
/frontend/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "semantic-ui-css/semantic.min.css";
2 | import "../styles/index.css";
3 |
4 | import { useEffect } from "react";
5 | import { SWRConfig } from "swr";
6 |
7 | import { doApiRequest } from "../utils/fetch";
8 | import withGA from "../utils/ga/withGA";
9 | import { askNotificationPermissions } from "../utils/notifications";
10 |
11 | const MyApp = ({ Component, pageProps }) => {
12 | useEffect(() => {
13 | askNotificationPermissions();
14 | }, []);
15 | return (
16 |
19 | doApiRequest(path, ...args).then((res) => res.json()),
20 | refreshWhenHidden: true,
21 | }}
22 | >
23 | {/* This is necessary for generic HOC */}
24 | {/* eslint-disable-next-line react/jsx-props-no-spreading */}
25 |
26 |
27 | );
28 | };
29 |
30 | export default withGA(MyApp);
31 |
--------------------------------------------------------------------------------
/backend/ohq/sms.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from sentry_sdk import capture_message
3 | from twilio.base.exceptions import TwilioException, TwilioRestException
4 | from twilio.rest import Client
5 |
6 |
7 | def sendSMS(to, body):
8 | try:
9 | client = Client(settings.TWILIO_SID, settings.TWILIO_AUTH_TOKEN)
10 | client.messages.create(to=str(to), from_=settings.TWILIO_NUMBER, body=body)
11 | except TwilioRestException as e:
12 | capture_message(e, level="error")
13 | except TwilioException as e: # likely a credential issue in development
14 | capture_message(e, level="error")
15 |
16 |
17 | def sendSMSVerification(to, verification_code):
18 | body = f"Your OHQ Verification Code is: {verification_code}"
19 | sendSMS(to, body)
20 |
21 |
22 | def sendUpNextNotification(user, course):
23 | course_title = f"{course.department} {course.course_code}"
24 | body = f"You are currently 3rd in line for {course_title}, be ready soon!"
25 | sendSMS(user.profile.phone_number, body)
26 |
--------------------------------------------------------------------------------
/frontend/utils/staffcheck.ts:
--------------------------------------------------------------------------------
1 | import { NextPageContext } from "next";
2 | import { User, Kind } from "../types";
3 |
4 | /**
5 | * staffCheck is a helper function used to verify that the user
6 | * visiting a *course* page is a staff member.
7 | *
8 | * Note: this method returns false if the page is not associated with a course
9 | *
10 | */
11 |
12 | export default function staffCheck(user: User, ctx: NextPageContext): boolean {
13 | const {
14 | query: { course },
15 | } = ctx;
16 |
17 | if (!course) {
18 | return false;
19 | }
20 |
21 | let parsedCourse: number;
22 | if (typeof course == "string") {
23 | parsedCourse = parseInt(course, 10);
24 | } else {
25 | parsedCourse = parseInt(course[0], 10);
26 | }
27 |
28 | const foundCourse = user.membershipSet.find(
29 | (membership) => membership.course.id === parsedCourse
30 | );
31 |
32 | if (!foundCourse) {
33 | return false;
34 | }
35 |
36 | return foundCourse.kind !== Kind.STUDENT;
37 | }
38 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0010_auto_20210405_1720.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-04-05 21:20
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0009_auto_20210201_2224"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="queuestatistic",
15 | name="metric",
16 | field=models.CharField(
17 | choices=[
18 | ("HEATMAP_AVG_WAIT", "Average wait-time heatmap"),
19 | ("HEATMAP_QUESTIONS_PER_TA", "Questions per TA heatmap"),
20 | ("AVG_WAIT", "Average wait-time per day"),
21 | ("NUM_ANSWERED", "Number of questions answered per day"),
22 | ("STUDENTS_HELPED", "Students helped per day"),
23 | ("AVG_TIME_HELPING", "Average time helping students"),
24 | ],
25 | max_length=256,
26 | ),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | webpack(config, options) {
3 | const { isServer } = options;
4 | config.module.rules.push({
5 | test: /\.(ogg|mp3|wav|mpe?g)$/i,
6 | exclude: config.exclude,
7 | use: [
8 | {
9 | loader: require.resolve("url-loader"),
10 | options: {
11 | limit: config.inlineImageLimit,
12 | fallback: require.resolve("file-loader"),
13 | publicPath: `${config.assetPrefix}/_next/static/images/`,
14 | outputPath: `${isServer ? "../" : ""}static/images/`,
15 | name: "[name]-[hash].[ext]",
16 | esModule: config.esModule || false,
17 | },
18 | },
19 | ],
20 | });
21 | config.module.rules.push({
22 | test: /\.md/i,
23 | type: "asset/source",
24 | });
25 |
26 | return config;
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/frontend/hooks/player.ts:
--------------------------------------------------------------------------------
1 | import UIfx from "uifx";
2 | import { useState, useRef, useEffect, Dispatch, SetStateAction } from "react";
3 | import { NotificationProps } from "../types";
4 | import { playNotification, checkPermissions } from "../utils/notifications";
5 |
6 | export function usePlayer(
7 | audio: string
8 | ): [boolean, Dispatch>, NotificationProps] {
9 | const player = useRef();
10 | useEffect(() => {
11 | player.current = new UIfx(audio, { throttleMs: 100 });
12 | }, [audio]);
13 |
14 | const [notifs, setNotifs] = useState(true);
15 |
16 | useEffect(() => {
17 | if (checkPermissions()) {
18 | setNotifs(false);
19 | }
20 | }, []);
21 |
22 | const playFunc = (message: string) => {
23 | if (notifs) {
24 | player.current?.play();
25 | playNotification(message);
26 | }
27 | };
28 |
29 | const play = useRef<(string) => void>(playFunc);
30 | play.current = playFunc;
31 |
32 | return [notifs, setNotifs, play];
33 | }
34 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0008_auto_20210119_2218.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.2 on 2021-01-20 03:18
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("ohq", "0007_announcement"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="queue",
15 | name="rate_limit_enabled",
16 | field=models.BooleanField(default=False),
17 | ),
18 | migrations.AddField(
19 | model_name="queue",
20 | name="rate_limit_length",
21 | field=models.IntegerField(blank=True, null=True),
22 | ),
23 | migrations.AddField(
24 | model_name="queue",
25 | name="rate_limit_minutes",
26 | field=models.IntegerField(blank=True, null=True),
27 | ),
28 | migrations.AddField(
29 | model_name="queue",
30 | name="rate_limit_questions",
31 | field=models.IntegerField(blank=True, null=True),
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/frontend/utils/protectpage.tsx:
--------------------------------------------------------------------------------
1 | import { NextPageContext } from "next";
2 | import { AuthProps } from "../context/auth";
3 | import { User } from "../types";
4 | import nextRedirect from "./redirect";
5 | import { GIPPage } from "./gippage";
6 |
7 | export function withProtectPage(
8 | // this enforces that WrappedComponent has a
9 | // getInitialProps that contains a user object
10 | WrappedComponent: GIPPage,
11 | condition: (user: User, ctx: NextPageContext) => boolean
12 | ) {
13 | const ProtectedComponent = ({ ...props }: T) => {
14 | // eslint-disable-next-line
15 | return ;
16 | };
17 |
18 | ProtectedComponent.getInitialProps = async (ctx: NextPageContext) => {
19 | const wrappedProps = await WrappedComponent.getInitialProps(ctx);
20 | const { user } = wrappedProps;
21 | if (user && !condition(user, ctx)) {
22 | nextRedirect(ctx, () => true, "/404");
23 | }
24 |
25 | return wrappedProps;
26 | };
27 |
28 | return ProtectedComponent;
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/ohq/queues.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from django.db.models import Avg, F
4 | from django.utils import timezone
5 |
6 | from ohq.models import Question, Queue
7 |
8 |
9 | def calculate_wait_times():
10 | """
11 | Generate the average wait time for a queue by averaging the time it took to respond to all
12 | questions in the last 10 minutes. Set the wait time to -1 for all closed queues with no
13 | remaining questions.
14 | """
15 |
16 | # TODO: don't set wait time to -1 if a queue still has questions in it
17 | Queue.objects.filter(archived=False, active=False).update(estimated_wait_time=-1)
18 |
19 | time = timezone.now() - timedelta(minutes=10)
20 | queues = Queue.objects.filter(archived=False, active=True)
21 | for queue in queues:
22 | avg = Question.objects.filter(queue=queue, time_response_started__gt=time).aggregate(
23 | avg_wait=Avg(F("time_response_started") - F("time_asked"))
24 | )
25 | wait = avg["avg_wait"]
26 | queue.estimated_wait_time = wait.seconds // 60 if wait else 0
27 | queue.save()
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Penn Labs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/frontend/components/Home/AccountSettings/VerificationModal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Modal, Button } from "semantic-ui-react";
3 |
4 | import "./VerificationModal.module.css";
5 | import VerificationForm from "./VerificationForm";
6 |
7 | interface VerificationModalProps {
8 | open: boolean;
9 | openFunc: React.Dispatch>;
10 | toastFunc: (Toast) => void;
11 | mutate: any; // TODO: make this more strict
12 | }
13 | const VerificationModal = (props: VerificationModalProps) => {
14 | const { open, openFunc } = props;
15 | return (
16 |
17 | Phone Number Verification
18 |
19 | {/* TODO: Is there a way to make this more strict */}
20 | {/* eslint-disable-next-line */}
21 |
22 |
23 |
24 |
26 |
27 | );
28 | };
29 |
30 | export default VerificationModal;
31 |
--------------------------------------------------------------------------------
/ohq.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "backend"
5 | },
6 | {
7 | "path": "frontend"
8 | }
9 | ],
10 | "settings": {},
11 | "tasks": {
12 | "version": "2.0.0",
13 | "tasks": [
14 | {
15 | "label": "Run frontend",
16 | "type": "shell",
17 | "args": [
18 | "dev",
19 | ],
20 | "command": "yarn",
21 | "options": {
22 | "cwd": "${workspaceFolder:frontend}"
23 | },
24 | },
25 | {
26 | "label": "Run database",
27 | "type": "shell",
28 | "args": [
29 | "up",
30 | ],
31 | "command": "docker-compose",
32 | "options": {
33 | "cwd": "${workspaceFolder:backend}"
34 | },
35 | },
36 | {
37 | "label": "Run backend",
38 | "type": "shell",
39 | "args": [
40 | "run",
41 | "./manage.py",
42 | "runserver",
43 | ],
44 | "command": "pipenv",
45 | "options": {
46 | "cwd": "${workspaceFolder:backend}"
47 | },
48 | },
49 | {
50 | "label": "Run OHQ",
51 | "dependsOrder": "sequence",
52 | "dependsOn": [
53 | "Run database",
54 | "Run backend",
55 | "Run frontend",
56 | ],
57 | },
58 | ]
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM pennlabs/django-base:11d476546bd11c7a499e0e93be8db6af035d360f-3.11
2 |
3 | LABEL maintainer="Penn Labs"
4 |
5 | # Install uv
6 | COPY --from=ghcr.io/astral-sh/uv@sha256:2381d6aa60c326b71fd40023f921a0a3b8f91b14d5db6b90402e65a635053709 /uv /uvx /bin/
7 |
8 | # Copy project dependencies
9 | COPY pyproject.toml uv.lock /app/
10 |
11 | # Install dependencies
12 | RUN uv sync --frozen --no-dev --no-install-project --python $(which python); \
13 | ln -s /app/.venv/bin/uwsgi /usr/local/bin/uwsgi; \
14 | ln -s /app/.venv/bin/gunicorn /usr/local/bin/gunicorn
15 |
16 | # Make installed binaries available for POSIX compliant scripts
17 | ENV PATH="/app/.venv/bin:$PATH"
18 |
19 | # Patch for scripts that use a hard-coded path to python (django-run, asgi-run)
20 | ENV PYTHONPATH="/app/.venv/lib/python3.11/site-packages/:$PYTHONPATH"
21 |
22 | # Copy project files
23 | COPY . /app/
24 |
25 | ENV DJANGO_SETTINGS_MODULE officehoursqueue.settings.production
26 | ENV SECRET_KEY 'temporary key just to build the docker image'
27 |
28 | # Copy custom asgi-run
29 | COPY ./scripts/asgi-run /usr/local/bin/
30 |
31 | # Collect static files
32 | RUN python3 /app/manage.py collectstatic --noinput
33 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/FinishConfirmModal.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Modal } from "semantic-ui-react";
2 |
3 | interface FinishConfirmModalProps {
4 | onFinish?: () => void;
5 | onClose?: () => void;
6 | open: boolean;
7 | }
8 |
9 | const FinishConfirmModal = (props: FinishConfirmModalProps) => {
10 | const { onFinish, onClose, open } = props;
11 |
12 | return (
13 |
14 | Finish Confirmation
15 |
16 |
17 | You are about to finish a question that you did not start.
18 | Is this intended?
19 |
20 |
21 |
22 |
23 |
29 |
30 |
31 | );
32 | };
33 |
34 | export default FinishConfirmModal;
35 |
--------------------------------------------------------------------------------
/frontend/pages/_error.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 | import NextErrorComponent from "next/error";
3 | import { NextPageContext } from "next";
4 |
5 | import { logMessage } from "../utils/sentry";
6 |
7 | // Inspired by https://leerob.io/blog/configuring-sentry-for-nextjs-apps
8 | // TODO: clean this up/add a custom error page
9 | const Error = ({
10 | statusCode = 500,
11 | message = "Something went wrong",
12 | }: ErrorProps): ReactElement => {
13 | return ;
14 | };
15 |
16 | Error.getInitialProps = ({ res, err }: NextPageContext) => {
17 | const statusCode =
18 | (res && res.statusCode) || (err && err.statusCode) || 404;
19 | let message = (err && err.message) || undefined;
20 |
21 | if (!message) {
22 | if (statusCode === 404) {
23 | message = "The page you were looking for does not exist.";
24 | } else {
25 | message = "Something went wrong.";
26 | }
27 | } else {
28 | logMessage(`${statusCode}: ${message}`);
29 | }
30 |
31 | return { statusCode, message };
32 | };
33 |
34 | type ErrorProps = {
35 | statusCode?: number;
36 | message?: string;
37 | };
38 |
39 | export default Error;
40 |
--------------------------------------------------------------------------------
/frontend/components/common/ui/ResponsiveIconButton.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import { useMediaQuery } from "@material-ui/core";
3 | import { Button, ButtonProps } from "semantic-ui-react";
4 | import { MOBILE_BP } from "../../../constants";
5 |
6 | interface ResponsiveIconButtonProps extends ButtonProps {
7 | text: string;
8 | icon: NonNullable;
9 | mobileProps?: ButtonProps;
10 | desktopProps?: ButtonProps;
11 | }
12 |
13 | /* Icon button with text component. Text disappears at smaller breakpoints to leave only the icon */
14 | const ResponsiveIconButton = ({
15 | text,
16 | icon,
17 | mobileProps,
18 | desktopProps,
19 | ...sharedProps
20 | }: ResponsiveIconButtonProps) => {
21 | const isMobile = useMediaQuery(`(max-width: ${MOBILE_BP})`);
22 |
23 | const iconButton = ;
24 | const iconTextButton = (
25 |
31 | );
32 |
33 | return isMobile ? iconButton : iconTextButton;
34 | };
35 |
36 | export default ResponsiveIconButton;
37 |
--------------------------------------------------------------------------------
/frontend/components/Course/CourseSettings/CourseSettings.tsx:
--------------------------------------------------------------------------------
1 | import { Segment, Header, Grid } from "semantic-ui-react";
2 | import CourseForm from "./CourseForm";
3 | import { Course, Tag } from "../../../types";
4 | import { useCourse } from "../../../hooks/data-fetching/course";
5 |
6 | interface CourseSettingsProps {
7 | course: Course;
8 | tags: Tag[];
9 | }
10 |
11 | const CourseSettings = (props: CourseSettingsProps) => {
12 | const { course: rawCourse, tags } = props;
13 | const { data: courseData, mutate } = useCourse(rawCourse.id, rawCourse);
14 |
15 | // courseData is non null because initialData is provided
16 | // and the key stays the same
17 | const course = courseData!;
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default CourseSettings;
40 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0006_auto_20210105_2000.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.1 on 2021-01-06 01:00
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("ohq", "0005_auto_20201016_1702"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="Tag",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
21 | ),
22 | ),
23 | ("name", models.CharField(max_length=255)),
24 | (
25 | "course",
26 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.course"),
27 | ),
28 | ],
29 | ),
30 | migrations.AddField(
31 | model_name="question",
32 | name="tags",
33 | field=models.ManyToManyField(blank=True, to="ohq.Tag"),
34 | ),
35 | migrations.AddConstraint(
36 | model_name="tag",
37 | constraint=models.UniqueConstraint(fields=("name", "course"), name="unique_course_tag"),
38 | ),
39 | ]
40 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0012_queue_require_video_chat_url_on_questions.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-09-17 16:29
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | def get_video_chat_setting(queue):
7 | if queue.course.require_video_chat_url_on_questions:
8 | return "REQUIRED"
9 | elif queue.course.video_chat_enabled:
10 | return "OPTIONAL"
11 | else:
12 | return "DISABLED"
13 |
14 |
15 | def populate_require_url_in_queue(apps, schema_editor):
16 | Queue = apps.get_model("ohq", "Queue")
17 | for queue in Queue.objects.all():
18 | queue.video_chat_setting = get_video_chat_setting(queue)
19 | queue.save()
20 |
21 |
22 | class Migration(migrations.Migration):
23 |
24 | dependencies = [
25 | ("ohq", "0011_merge_20210415_2110"),
26 | ]
27 |
28 | operations = [
29 | migrations.AddField(
30 | model_name="queue",
31 | name="video_chat_setting",
32 | field=models.CharField(
33 | choices=[
34 | ("REQUIRED", "required"),
35 | ("OPTIONAL", "optional"),
36 | ("DISABLED", "disabled"),
37 | ],
38 | default="OPTIONAL",
39 | max_length=8,
40 | ),
41 | ),
42 | migrations.RunPython(populate_require_url_in_queue),
43 | ]
44 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/settings/production.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import sentry_sdk
4 | from sentry_sdk.integrations.celery import CeleryIntegration
5 | from sentry_sdk.integrations.django import DjangoIntegration
6 |
7 | from officehoursqueue.settings.base import * # noqa: F401, F403
8 | from officehoursqueue.settings.base import DOMAINS, REDIS_URL
9 |
10 |
11 | DEBUG = False
12 |
13 | # Honour the 'X-Forwarded-Proto' header for request.is_secure()
14 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
15 |
16 | # Allow production host headers
17 | ALLOWED_HOSTS = DOMAINS
18 |
19 | # Make sure SECRET_KEY is set to a secret in production
20 | SECRET_KEY = os.environ.get("SECRET_KEY", None)
21 |
22 | # Sentry settings
23 | SENTRY_URL = os.environ.get("SENTRY_URL", "")
24 | sentry_sdk.init(dsn=SENTRY_URL, integrations=[CeleryIntegration(), DjangoIntegration()])
25 |
26 | # DLA settings
27 | PLATFORM_ACCOUNTS = {"ADMIN_PERMISSION": "ohq_admin"}
28 |
29 | # Email client settings
30 | EMAIL_HOST = os.getenv("SMTP_HOST")
31 | EMAIL_PORT = int(os.getenv("SMTP_PORT", 587))
32 | EMAIL_HOST_USER = os.getenv("SMTP_USERNAME")
33 | EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD")
34 | EMAIL_USE_TLS = True
35 |
36 | # Redis Channel Layer
37 | CHANNEL_LAYERS = {
38 | "default": {
39 | "BACKEND": "channels_redis.core.RedisChannelLayer",
40 | "CONFIG": {"hosts": [REDIS_URL]},
41 | },
42 | }
43 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib import admin
3 | from django.urls import include, path
4 | from django.views.generic import TemplateView
5 | from djangorestframework_camel_case.render import CamelCaseJSONRenderer
6 | from rest_framework.schemas import get_schema_view
7 |
8 |
9 | admin.site.site_header = "Office Hours Queue Admin"
10 |
11 | urlpatterns = [
12 | path("", include("ohq.urls")),
13 | path("accounts/", include("accounts.urls", namespace="accounts")),
14 | path(
15 | "openapi/",
16 | get_schema_view(
17 | title="Office Hours Queue Documentation",
18 | public=True,
19 | renderer_classes=[CamelCaseJSONRenderer],
20 | ),
21 | name="openapi-schema",
22 | ),
23 | path(
24 | "documentation/",
25 | TemplateView.as_view(
26 | template_name="redoc.html", extra_context={"schema_url": "openapi-schema"}
27 | ),
28 | name="documentation",
29 | ),
30 | ]
31 |
32 | urlpatterns = [
33 | path("api/", include(urlpatterns)),
34 | path("admin/", admin.site.urls),
35 | ]
36 |
37 | if settings.DEBUG: # pragma: no cover
38 | import debug_toolbar
39 |
40 | urlpatterns = [
41 | path("__debug__/", include(debug_toolbar.urls)),
42 | path("emailpreview/", include("email_tools.urls")),
43 | ] + urlpatterns
44 |
--------------------------------------------------------------------------------
/frontend/constants.ts:
--------------------------------------------------------------------------------
1 | export const ANNOUNCEMENTS_POLL_INTERVAL = 10000;
2 | export const QUEUE_STATUS_POLL_INTERVAL = 30000;
3 | export const STAFF_QUESTION_POLL_INTERVAL = 30000;
4 | export const STUDENT_QUESTION_POS_POLL_INTERVAL = 10000;
5 | export const STUDENT_QUOTA_POLL_INTERVAL = 60000;
6 | export const CALENDAR_POLL_INTERVAL = 300000;
7 | export const COURSE_TITLE_CHAR_LIMIT = 100;
8 | export const TEXT_CHAR_LIMIT = 1000;
9 | export const STUD_DESC_CHAR_LIMIT = 100;
10 | export const PIN_CHAR_LIMIT = 50;
11 | export const ALLOWED_LINKS = [
12 | "zoom.us",
13 | "zoom.com",
14 | "zoom.com.cn",
15 | "bluejeans.com",
16 | "meet.google.com",
17 | "whereby.com",
18 | ];
19 |
20 | export const ANALYTICS_SURVEY_SHOWN_LS_TOKEN =
21 | "__instructor_analytics_survey_shown";
22 | export const SPRING_2021_TRANSITION_MESSAGE_TOKEN =
23 | "__spring_2021_transition_message_shown";
24 | export const ANALYTICS_SURVEY_SHOWN_SPRING_2021_END_TOKEN =
25 | "__analytics_survey_sp_2021_end_shown";
26 | export const SPRING_2023_TRANSITION_MESSAGE_TOKEN =
27 | "__spring_2023_transition_message_shown";
28 | export const SUMMER_2023_TRANSITION_MESSAGE_TOKEN =
29 | "__summer_2023_transition_message_shown";
30 | export const FALL_2023_TRANSITION_MESSAGE_TOKEN =
31 | "__fall_2023_transition_message_shown";
32 | export const CHANGELOG_TOKEN = "__changelog";
33 |
34 | export const MOBILE_BP = "766px";
35 |
--------------------------------------------------------------------------------
/frontend/components/Course/Roster/ChangeRoleDropdown.tsx:
--------------------------------------------------------------------------------
1 | import { Dropdown, Popup } from "semantic-ui-react";
2 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
3 | import { staffRoleOptions } from "../../../utils/enums";
4 | import { Membership } from "../../../types";
5 |
6 | interface ChangeRoleDropdownProps {
7 | id: number;
8 | role: string;
9 | disabled: boolean;
10 | successFunc: () => void;
11 | mutate: mutateResourceListFunction;
12 | }
13 | const ChangeRoleDropdown = (props: ChangeRoleDropdownProps) => {
14 | const { role, disabled, id: membershipId, mutate } = props;
15 |
16 | const handleInputChange = async (e, { name, value }) => {
17 | await mutate(membershipId, { kind: value });
18 | props.successFunc();
19 | };
20 |
21 | const dropdown = (
22 |
30 | );
31 |
32 | return (
33 | {dropdown}}
35 | disabled={!disabled}
36 | content="Cannot change only user in leadership role"
37 | on="hover"
38 | position="left center"
39 | />
40 | );
41 | };
42 |
43 | export default ChangeRoleDropdown;
44 |
--------------------------------------------------------------------------------
/frontend/components/Course/Roster/Invites/AddForm.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from "semantic-ui-react";
2 | import { roleOptions } from "../../../../utils/enums";
3 |
4 | interface AddFormProps {
5 | changeFunc: any; // TODO: restrict this
6 | }
7 | const AddForm = (props: AddFormProps) => {
8 | const { changeFunc } = props;
9 | const onChange = (text) => {
10 | changeFunc(undefined, {
11 | name: "emails",
12 | value: text.target.value,
13 | });
14 | };
15 |
16 | return (
17 |
19 |
20 |
21 | {/* TODO: add a validation step here */}
22 |
27 |
28 |
29 |
30 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default AddForm;
44 |
--------------------------------------------------------------------------------
/backend/ohq/filters.py:
--------------------------------------------------------------------------------
1 | from django.db.models import Q
2 | from django_filters import rest_framework as filters
3 |
4 | from ohq.models import CourseStatistic, Question, QueueStatistic
5 |
6 |
7 | class QuestionSearchFilter(filters.FilterSet):
8 | # time_asked = filters.DateFilter(lookup_expr="icontains")
9 | search = filters.CharFilter(method="search_filter")
10 | order_by = filters.OrderingFilter(fields=["time_asked", "time_responded_to"])
11 |
12 | class Meta:
13 | model = Question
14 | fields = {
15 | "time_asked": ["gt", "lt"],
16 | "queue": ["exact"],
17 | "status": ["exact"],
18 | "time_responded_to": ["gt", "lt"],
19 | }
20 |
21 | def search_filter(self, queryset, name, value):
22 | return queryset.filter(
23 | Q(text__icontains=value)
24 | | Q(asked_by__first_name__icontains=value)
25 | | Q(asked_by__last_name__icontains=value)
26 | | Q(responded_to_by__first_name__icontains=value)
27 | | Q(responded_to_by__last_name__icontains=value)
28 | )
29 |
30 |
31 | class CourseStatisticFilter(filters.FilterSet):
32 | class Meta:
33 | model = CourseStatistic
34 | fields = ["metric", "date"]
35 |
36 |
37 | class QueueStatisticFilter(filters.FilterSet):
38 | class Meta:
39 | model = QueueStatistic
40 | fields = {"metric": ["exact"], "date": ["gt", "lt", "gte", "lte", "exact"]}
41 |
--------------------------------------------------------------------------------
/frontend/hooks/data-fetching/resources.ts:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from "react";
2 | import useSWR, { ConfigInterface } from "swr";
3 |
4 | export interface FilteredResourceResponse {
5 | data?: R;
6 | error: any;
7 | isValidating: boolean;
8 | filters: Partial;
9 | updateFilter: (filter: Partial) => void;
10 | }
11 |
12 | export function useFilteredResource(
13 | listUrl: string | (() => string),
14 | filterToQuery: (filter: Partial) => string,
15 | initialData?: R,
16 | initialFilter?: Partial,
17 | config?: ConfigInterface
18 | ): FilteredResourceResponse {
19 | const hasMounted = useRef(false);
20 | useEffect(() => {
21 | hasMounted.current = true;
22 | }, []);
23 |
24 | const [filters, setFilters] = useState>(
25 | // eslint-disable-next-line
26 | initialFilter ? initialFilter : {}
27 | );
28 |
29 | const query = listUrl + filterToQuery(filters);
30 |
31 | const { data, error, isValidating } = useSWR(query, {
32 | initialData: hasMounted.current ? undefined : initialData,
33 | ...config,
34 | });
35 |
36 | const updateFilter = (f: Partial) => {
37 | setFilters({
38 | ...filters,
39 | ...f,
40 | });
41 | };
42 |
43 | return {
44 | data,
45 | error,
46 | isValidating,
47 | filters,
48 | updateFilter,
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/queue_heatmap_stat.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from django.utils import timezone
3 |
4 | from ohq.models import Queue
5 | from ohq.statistics import (
6 | queue_calculate_questions_per_ta_heatmap,
7 | queue_calculate_wait_time_heatmap,
8 | )
9 |
10 |
11 | class Command(BaseCommand):
12 | def add_arguments(self, parser):
13 | parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics")
14 |
15 | def calculate_statistics(self, queues, weekdays):
16 | """
17 | Helper function to calculate the heatmap statistics
18 | """
19 | for queue in queues:
20 | for weekday in weekdays:
21 | for hour in range(24):
22 | queue_calculate_questions_per_ta_heatmap(queue, weekday, hour)
23 | queue_calculate_wait_time_heatmap(queue, weekday, hour)
24 |
25 | def handle(self, *args, **kwargs):
26 | if kwargs["hist"]:
27 | queues = Queue.objects.all()
28 | weekdays = [i for i in range(1, 8)]
29 | else:
30 | queues = Queue.objects.filter(archived=False)
31 |
32 | # assuming the cron job runs at midnight, we only need to update yesterday's weekday
33 | yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1)
34 | weekdays = [(yesterday.weekday() + 1) % 7 + 1]
35 |
36 | self.calculate_statistics(queues, weekdays)
37 |
--------------------------------------------------------------------------------
/backend/tests/ohq/test_backends.py:
--------------------------------------------------------------------------------
1 | from django.contrib import auth
2 | from django.test import TestCase
3 |
4 | from ohq.models import Course, Membership, MembershipInvite, Semester
5 |
6 |
7 | class BackendTestCase(TestCase):
8 | def setUp(self):
9 | self.remote_user = {
10 | "pennid": 1,
11 | "first_name": "First",
12 | "last_name": "Last",
13 | "username": "user",
14 | "email": "user@seas.upenn.edu",
15 | "affiliation": [],
16 | "user_permissions": [],
17 | "groups": ["student", "member"],
18 | "token": {"access_token": "abc", "refresh_token": "123", "expires_in": 100},
19 | }
20 |
21 | def test_convert_invites(self):
22 | semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER)
23 | course = Course.objects.create(
24 | course_code="000", department="TEST", course_title="Title", semester=semester
25 | )
26 | MembershipInvite.objects.create(
27 | course=course, kind=Membership.KIND_PROFESSOR, email="user@seas.upenn.edu"
28 | )
29 | user = auth.authenticate(remote_user=self.remote_user)
30 | self.assertEqual(MembershipInvite.objects.all().count(), 0)
31 | self.assertEqual(Membership.objects.all().count(), 1)
32 | membership = Membership.objects.get(course=course)
33 | self.assertEqual(membership.kind, Membership.KIND_PROFESSOR)
34 | self.assertEqual(membership.user, user)
35 |
--------------------------------------------------------------------------------
/backend/nginx-proddev.conf:
--------------------------------------------------------------------------------
1 | # Reverse proxy configuration for emulating what will actually be run in production as described in /k8s/main.ts
2 |
3 | events { }
4 |
5 | http {
6 | server {
7 | listen 80;
8 |
9 | # Frontend is served unless overridden by other locations
10 | location / {
11 | proxy_pass http://proddev-frontend:3000;
12 | proxy_set_header Host $host;
13 | proxy_set_header X-Real-IP $remote_addr;
14 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
15 | }
16 |
17 | # The wsgi backend is used on routes starting with '/api', '/admin', or '/assets'
18 | location ~ ^/(api|admin|assets) {
19 | proxy_pass http://proddev-backend-wsgi:80;
20 | proxy_set_header Host $host;
21 | proxy_set_header X-Real-IP $remote_addr;
22 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
23 | }
24 |
25 | # The asgi backend is used for websocket requests on routes starting with '/api/ws'
26 | location /api/ws {
27 | proxy_pass http://proddev-backend-asgi:80;
28 | proxy_set_header Host $host;
29 | proxy_set_header X-Real-IP $remote_addr;
30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
31 |
32 | # For web sockets
33 | proxy_http_version 1.1;
34 | proxy_set_header Upgrade $http_upgrade;
35 | proxy_set_header Connection "Upgrade";
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/components/Guide/utils.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from "react";
2 | import * as React from "react";
3 | import { List } from "semantic-ui-react";
4 |
5 | interface SectionBody {
6 | ref: React.MutableRefObject;
7 | }
8 |
9 | // Takes in title and body and wraps them with required refs
10 | // so clicking on the title will scroll to the body
11 | export function useSection(
12 | title: string,
13 | body: (props: SectionBody) => JSX.Element
14 | ) {
15 | const ref = useRef();
16 |
17 | const Header = () => {
18 | return (
19 |
20 |
23 | ref.current && ref.current.scrollIntoView(true)
24 | }
25 | >
26 | {title}
27 |
28 |
29 | );
30 | };
31 |
32 | const WrappedBody = () => {
33 | return body({ ref });
34 | };
35 |
36 | return [Header, WrappedBody];
37 | }
38 |
39 | export function CenteredImage({ src, alt }: { src: string; alt: string }) {
40 | return (
41 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0007_announcement.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.1 on 2021-01-06 16:03
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("ohq", "0006_auto_20210105_2000"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="Announcement",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
23 | ),
24 | ),
25 | ("content", models.CharField(max_length=255)),
26 | ("time_updated", models.DateTimeField(auto_now=True)),
27 | (
28 | "author",
29 | models.ForeignKey(
30 | on_delete=django.db.models.deletion.CASCADE,
31 | related_name="announcements",
32 | to=settings.AUTH_USER_MODEL,
33 | ),
34 | ),
35 | (
36 | "course",
37 | models.ForeignKey(
38 | on_delete=django.db.models.deletion.CASCADE,
39 | related_name="announcements",
40 | to="ohq.course",
41 | ),
42 | ),
43 | ],
44 | ),
45 | ]
46 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Cards/AddCard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Segment, Header } from "semantic-ui-react";
3 |
4 | const cardStyle = {
5 | height: "120px",
6 | display: "flex",
7 | alignItems: "center",
8 | justifyContent: "center",
9 | cursor: "pointer",
10 | border: "dashed #21ba45",
11 | };
12 |
13 | const hoverStyle = {
14 | height: "120px",
15 | display: "flex",
16 | alignItems: "center",
17 | justifyContent: "center",
18 | cursor: "pointer",
19 | border: "dashed #21ba45",
20 | backgroundColor: "#b5f4c4",
21 | };
22 | interface AddCardProps {
23 | isStudent: boolean;
24 | clickFunc: () => void;
25 | }
26 | const AddCard = (props: AddCardProps) => {
27 | const { isStudent, clickFunc } = props;
28 | const [hovered, setHovered] = useState(false);
29 |
30 | return (
31 |
32 | setHovered(true)}
35 | onMouseLeave={() => setHovered(false)}
36 | onClick={clickFunc}
37 | >
38 |
47 |
48 |
49 | );
50 | };
51 |
52 | export default AddCard;
53 |
--------------------------------------------------------------------------------
/backend/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = ""
3 | version = "0.0.1"
4 | requires-python = "==3.11.4"
5 | dependencies = [
6 | "dj-database-url==2.2.0",
7 | "djangorestframework==3.15.2",
8 | "psycopg2-binary==2.9.6",
9 | "uvloop==0.17.0",
10 | "sentry-sdk",
11 | "django==5.0.3",
12 | "django-cors-headers==4.4.0",
13 | "pyyaml==6.0.2",
14 | "uritemplate==4.1.1",
15 | "uwsgi==2.0.27",
16 | "django-labs-accounts==0.7.1",
17 | "django-phonenumber-field[phonenumbers]==8.0.0",
18 | "drf-nested-routers==0.94.1",
19 | "django-email-tools==0.1.1",
20 | "twilio==9.3.3",
21 | "djangorestframework-camel-case==1.4.2",
22 | "django-filter==24.3",
23 | "celery==5.4.0",
24 | "redis==5.1.1",
25 | "django-auto-prefetching==0.2.12",
26 | "django-rest-live==0.7.0",
27 | "channels==3.0.5",
28 | "channels-redis==4.2.0",
29 | "uvicorn[standard]==0.31.0",
30 | "gunicorn==23.0.0",
31 | "django-schedules-ohq==0.10.1.4",
32 | "typing-extensions==4.12.2",
33 | "drf-excel==2.4.1",
34 | "pytz==2024.2",
35 | "inflection==0.5.1",
36 | ]
37 |
38 | [tool.black]
39 | line-length = 100
40 |
41 | [dependency-groups]
42 | dev = [
43 | "black==22.3.0",
44 | "unittest-xml-reporting==3.2.0",
45 | "flake8==7.1.1",
46 | "flake8-absolute-import==1.0.0.2",
47 | "flake8-isort==6.1.1",
48 | "flake8-quotes==3.4.0",
49 | "django-debug-toolbar==4.4.6",
50 | "django-extensions==3.2.3",
51 | "parameterized==0.9.0",
52 | "tblib==3.0.0",
53 | ]
54 |
55 | [tool.uv]
56 | package = false
57 |
58 | [[tool.uv.index]]
59 | name = "pypi"
60 | url = "https://pypi.org/simple"
61 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/QuestionTimer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { Label } from "semantic-ui-react";
3 | import timerAudio from "./timeupsound.mp3";
4 | import { usePlayer } from "../../../hooks/player";
5 | import { checkPermissions } from "../../../utils/notifications";
6 |
7 | interface QuestionTimerProps {
8 | questionStartTime: string;
9 | timerStartTime: number;
10 | }
11 | const QuestionTimer = ({
12 | questionStartTime,
13 | timerStartTime,
14 | }: QuestionTimerProps) => {
15 | const [, , play] = usePlayer(timerAudio);
16 | const now = Date.now();
17 | const diff =
18 | timerStartTime * 60 * 1000 -
19 | (now - new Date(questionStartTime).getTime());
20 | const minutes = Math.floor(Math.abs(diff) / (1000 * 60));
21 | const seconds = Math.floor((Math.abs(diff) % (1000 * 60)) / 1000);
22 |
23 | const sign = diff < 0 ? "-" : ""; // Add a negative sign if the time difference is negative
24 | // Play a sound effect when hit zero (once)
25 | useEffect(() => {
26 | if (minutes + seconds === 0) {
27 | if (!checkPermissions())
28 | play.current(
29 | `Timer is up! You have answered this question for ${timerStartTime} minute(s).`
30 | );
31 | }
32 | }, [sign]);
33 | const formated = `${sign}${minutes.toString().padStart(2, "0")}:${seconds
34 | .toString()
35 | .padStart(2, "0")}`;
36 |
37 | if (!questionStartTime) return null;
38 | return ;
39 | };
40 |
41 | export default QuestionTimer;
42 |
--------------------------------------------------------------------------------
/frontend/components/common/AboutModal.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Modal } from "semantic-ui-react";
2 |
3 | interface AboutModalProps {
4 | open: boolean;
5 | closeFunc: () => void;
6 | }
7 | const AboutModal = (props: AboutModalProps) => {
8 | const { open, closeFunc } = props;
9 | return (
10 |
11 |
12 | OHQ is maintained by Penn Labs and was originally built by
13 | Steven Bursztyn, Chris Fischer, Monal Garg, Karen Shen, and
14 | Marshall Vail
15 |
16 |
17 | To submit a bug, please use this{" "}
18 |
23 | form
24 |
25 | .
26 |
27 |
28 | If you have any questions, feedback, or suggestions you can
29 | reach us at{" "}
30 |
35 | contact@ohq.io
36 |
37 | .
38 |
39 |
40 |
42 |
43 | );
44 | };
45 |
46 | export default AboutModal;
47 |
--------------------------------------------------------------------------------
/frontend/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const express = require("express");
3 | const next = require("next");
4 |
5 | const devProxy = {
6 | "/api": {
7 | target: "http://127.0.0.1:8000",
8 | changeOrigin: true,
9 | },
10 | };
11 |
12 | const port = parseInt(process.env.PORT, 10) || 3000;
13 | const env = process.env.NODE_ENV;
14 | const dev = env !== "production";
15 | const app = next({
16 | dir: ".", // base directory where everything is, could move to src later
17 | dev,
18 | });
19 |
20 | const handle = app.getRequestHandler();
21 |
22 | let server;
23 | app.prepare()
24 | .then(() => {
25 | server = express();
26 |
27 | if (dev) {
28 | server.on("upgrade", handle);
29 |
30 | // Set up the proxy.
31 | if (devProxy) {
32 | /* eslint-disable */
33 | const {
34 | createProxyMiddleware,
35 | } = require("http-proxy-middleware");
36 | Object.keys(devProxy).forEach(function (context) {
37 | server.use(
38 | createProxyMiddleware(context, devProxy[context])
39 | );
40 | });
41 | /* eslint-enable */
42 | }
43 | }
44 |
45 | // Default catch-all handler to allow Next.js to handle all other routes
46 | server.all("*", (req, res) => handle(req, res));
47 |
48 | server.listen(port, (err) => {
49 | if (err) {
50 | throw err;
51 | }
52 | console.log(`> Ready on port ${port} [${env}]`);
53 | });
54 | })
55 | .catch((err) => {
56 | console.log("An error occurred, unable to start the server");
57 | console.log(err);
58 | });
59 |
--------------------------------------------------------------------------------
/frontend/components/common/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useState } from "react";
3 | import { Container } from "semantic-ui-react";
4 |
5 | import AboutModal from "./AboutModal";
6 |
7 | export default function Footer() {
8 | const [showModal, setShowModal] = useState(false);
9 | return (
10 | <>
11 |
12 | Made with{" "}
13 |
14 |
15 | {" "}
16 | by{" "}
17 |
22 | Penn Labs
23 | {" "}
24 | &{" "}
25 | setShowModal(true)}
28 | style={{
29 | cursor: "pointer",
30 | color: "#4285f5",
31 | }}
32 | >
33 | Friends
34 | {" "}
35 | | Changelog |{" "}
36 |
41 | Feedback
42 |
43 |
44 | setShowModal(false)}
47 | />
48 | >
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/frontend/components/Guide/StudentGuide.tsx:
--------------------------------------------------------------------------------
1 | import { Segment, List } from "semantic-ui-react";
2 |
3 | import {
4 | EnrollCourse,
5 | Calendar,
6 | Notifications,
7 | JoiningOfficeHours,
8 | WhileInQueue,
9 | Settings,
10 | } from "./StudentGuideContent";
11 | import { useSection } from "./utils";
12 |
13 | export default function StudentGuide() {
14 | const [EnrollCourseHeader, EnrollCourseBody] = useSection(
15 | "Enrolling in a Course",
16 | EnrollCourse
17 | );
18 | const [NotificationsHeader, NotificationsBody] = useSection(
19 | "Setting Up Notifications",
20 | Notifications
21 | );
22 | const [JoiningOHHeader, JoiningOHBody] = useSection(
23 | "Joining Office Hours",
24 | JoiningOfficeHours
25 | );
26 | const [WhileInQueueHeader, WhileInQueueBody] = useSection(
27 | "While In Queue...",
28 | WhileInQueue
29 | );
30 | const [CalendarHeader, CalendarBody] = useSection(
31 | "Viewing your Calendar",
32 | Calendar
33 | );
34 | const [SettingsHeader, SettingsBody] = useSection("Settings", Settings);
35 | return (
36 | <>
37 |
38 | Jump to:
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | >
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/frontend/hooks/data-fetching/dashboard.ts:
--------------------------------------------------------------------------------
1 | import { useResource } from "@pennlabs/rest-hooks";
2 | import { Course, UserMembership } from "../../types";
3 | import { doApiRequest } from "../../utils/fetch";
4 |
5 | export async function getCourses(inputValue: string): Promise {
6 | return doApiRequest(`/api/courses/?search=${inputValue}`)
7 | .then((res) => res.json())
8 | .then((res) => res.map((course) => course))
9 | .catch((_) => []);
10 | }
11 |
12 | export async function joinCourse(courseId: string): Promise {
13 | const res = await doApiRequest(`/api/courses/${courseId}/members/`, {
14 | method: "POST",
15 | });
16 |
17 | if (!res.ok) {
18 | throw new Error("Unable to join course");
19 | }
20 | }
21 |
22 | export async function leaveCourse(
23 | courseId: string,
24 | membershipId: string
25 | ): Promise {
26 | const res = await doApiRequest(
27 | `/api/courses/${courseId}/members/${membershipId}/`,
28 | {
29 | method: "DELETE",
30 | }
31 | );
32 |
33 | if (!res.ok) {
34 | throw new Error("Unable to leave course");
35 | }
36 | }
37 |
38 | export async function createCourse(payload: any): Promise {
39 | const res = await doApiRequest("/api/courses/", {
40 | method: "POST",
41 | body: payload,
42 | });
43 |
44 | if (!res.ok) {
45 | throw new Error("Unable to create course");
46 | }
47 | }
48 |
49 | export function useMemberships(initialUser) {
50 | const { data, error, isValidating, mutate } = useResource(
51 | "/api/accounts/me/",
52 | {
53 | initialData: initialUser,
54 | }
55 | );
56 | const memberships: UserMembership[] = data ? data.membershipSet : [];
57 |
58 | return { memberships, error, isValidating, mutate };
59 | }
60 |
--------------------------------------------------------------------------------
/frontend/components/Course/Analytics/Analytics.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { useState } from "react";
3 | import { Segment, Grid, Dropdown } from "semantic-ui-react";
4 | import { Course, Queue } from "../../../types";
5 | import Averages from "./Heatmaps/Averages";
6 | import SummaryCards from "./Cards/SummaryCards";
7 |
8 | interface AnalyticsProps {
9 | course: Course;
10 | queues: Queue[];
11 | }
12 |
13 | const Analytics = ({ course, queues }: AnalyticsProps) => {
14 | const [queueId, setQueueId] = useState(
15 | queues.length !== 0 ? queues[0].id : undefined
16 | );
17 |
18 | const queueOptions = queues.map((queue) => {
19 | return {
20 | key: queue.id,
21 | value: queue.id,
22 | text: queue.name,
23 | };
24 | });
25 |
26 | return (
27 |
28 | {queueId ? (
29 | <>
30 | {
36 | setQueueId(value as number);
37 | }}
38 | />
39 |
40 |
41 | >
42 | ) : (
43 |
44 | You have no queues. Create a queue on the{" "}
45 | queue page to
46 | see analytics.
47 |
48 | )}
49 |
50 | );
51 | };
52 |
53 | export default Analytics;
54 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Modals/ModalRedirectAddCourse.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button, Modal } from "semantic-ui-react";
3 | import { mutate } from "swr";
4 | import { joinCourse } from "../../../../hooks/data-fetching/dashboard";
5 | import { Course, Toast } from "../../../../types";
6 | import { logException } from "../../../../utils/sentry";
7 |
8 | interface ModalRedirectAddCourseProps {
9 | course: Course;
10 | toastFunc: (toast: Toast) => void;
11 | }
12 |
13 | const ModalRedirectAddCourse = (props: ModalRedirectAddCourseProps) => {
14 | const { course, toastFunc } = props;
15 | const [open, setOpen] = useState(true);
16 |
17 | const onJoin = async () => {
18 | setOpen(false);
19 | try {
20 | await joinCourse(`${course.id}`);
21 | mutate("/api/accounts/me/");
22 | toastFunc({
23 | message: `Added ${course.department} ${course.courseCode}!`,
24 | success: true,
25 | });
26 | } catch (e) {
27 | logException(e);
28 | toastFunc({ message: "Something went wrong!", success: false });
29 | }
30 | };
31 |
32 | return (
33 |
34 |
35 | Do you want to join this course?
36 |
37 | {course.department} {course.courseCode}:{" "}
38 | {course.courseTitle}
39 |
40 |
41 |
42 |
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default ModalRedirectAddCourse;
52 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/Tags.tsx:
--------------------------------------------------------------------------------
1 | import { Segment, Header, Label, Grid } from "semantic-ui-react";
2 |
3 | // TODO: eventually implement tags on questions
4 | const Tags = (props) => {
5 | const { tags } = props;
6 |
7 | /* HANDLER FUNCTIONS */
8 | const clearTags = () => {
9 | tags.forEach((tag) => {
10 | // eslint-disable-next-line no-param-reassign
11 | tag.isActive = false;
12 | });
13 | };
14 |
15 | const handleTagClick = (index) => {
16 | tags[index].isActive = !tags[index].isActive;
17 | };
18 |
19 | return (
20 |
21 |
22 |
23 |
24 | {tags && tags.length > 0 ? (
25 | tags.map((tag, index) => (
26 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default Tags;
54 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Modals/ModalLeaveStudentCourse.tsx:
--------------------------------------------------------------------------------
1 | import { Modal, Button } from "semantic-ui-react";
2 | import { mutateResourceFunction } from "@pennlabs/rest-hooks/dist/types";
3 | import { leaveCourse } from "../../../../hooks/data-fetching/dashboard";
4 | import { Toast, UserMembership } from "../../../../types";
5 | import { logException } from "../../../../utils/sentry";
6 |
7 | interface ModalLeaveStudentCourseProps {
8 | open: boolean;
9 | leaveMembership: UserMembership;
10 | closeFunc: () => void;
11 | mutate: mutateResourceFunction;
12 | toastFunc: (toast: Toast) => void;
13 | }
14 | const ModalLeaveStudentCourse = (props: ModalLeaveStudentCourseProps) => {
15 | const { open, leaveMembership, closeFunc, mutate, toastFunc } = props;
16 |
17 | const leaveFunc = async () => {
18 | try {
19 | await leaveCourse(
20 | `${leaveMembership.course.id}`,
21 | `${leaveMembership.id}`
22 | );
23 | toastFunc({
24 | message: `Left ${leaveMembership.course.department} ${leaveMembership.course.courseCode}!`,
25 | success: true,
26 | });
27 | } catch (e) {
28 | logException(e);
29 | toastFunc({ message: "Something went wrong!", success: false });
30 | }
31 | mutate();
32 | closeFunc();
33 | };
34 |
35 | return (
36 |
37 | Leave Course
38 | {`Are you sure you want to leave ${leaveMembership.course.department} ${leaveMembership.course.courseCode}?`}
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | export default ModalLeaveStudentCourse;
48 |
--------------------------------------------------------------------------------
/frontend/utils/index.tsx:
--------------------------------------------------------------------------------
1 | import { ALLOWED_LINKS } from "../constants";
2 | import { Membership, Kind, User } from "../types";
3 |
4 | export function isValidEmail(email: string) {
5 | const pattern =
6 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
7 | return pattern.test(email);
8 | }
9 |
10 | export function isValidVideoChatURL(url: string) {
11 | try {
12 | // URL constructor does not prevent "http://www.zoom.us Meeting ID: ..."
13 | const pattern =
14 | /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)$/;
15 |
16 | if (!pattern.test(url)) {
17 | return false;
18 | }
19 |
20 | const urlObject = new URL(url);
21 | return ALLOWED_LINKS.reduce(
22 | (acc: boolean, link: string) =>
23 | acc ||
24 | urlObject.hostname === link ||
25 | urlObject.hostname.endsWith(`.${link}`),
26 | false
27 | );
28 | } catch (e) {
29 | return false;
30 | }
31 | }
32 |
33 | export function roleSortFunc(a: Kind, b: Kind) {
34 | const order = ["PROFESSOR", "HEAD_TA", "TA", "STUDENT"];
35 | return order.indexOf(a) - order.indexOf(b);
36 | }
37 |
38 | export function leadershipSortFunc(a: Membership, b: Membership) {
39 | if (a.kind !== b.kind) {
40 | return roleSortFunc(a.kind, b.kind);
41 | }
42 | if (a.user.firstName !== b.user.firstName) {
43 | return a.user.firstName < b.user.firstName ? -1 : 1;
44 | }
45 | if (a.user.lastName !== b.user.lastName) {
46 | return a.user.lastName < b.user.lastName ? -1 : 1;
47 | }
48 | if (a.user.email < b.user.email) {
49 | return a.user.email < b.user.email ? -1 : 1;
50 | }
51 | return 0;
52 | }
53 |
54 | export function getFullName(user: User): string {
55 | return `${user.firstName} ${user.lastName}`;
56 | }
57 |
--------------------------------------------------------------------------------
/backend/officehoursqueue/templates/emails/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
63 |
64 |
65 |
66 |
67 |
70 |
71 |
72 |
73 | {% block content %}{% endblock %}
74 |
75 |
79 |
80 |
--------------------------------------------------------------------------------
/frontend/components/Guide/InstructorGuide.tsx:
--------------------------------------------------------------------------------
1 | import { Segment, List } from "semantic-ui-react";
2 |
3 | import {
4 | CreateCourse,
5 | InviteMembers,
6 | CreateQueue,
7 | HoldOfficeHours,
8 | Analytics,
9 | Calendar,
10 | Settings,
11 | } from "./InstructorGuideContent";
12 | import { useSection } from "./utils";
13 |
14 | export default function InstructorGuide() {
15 | const [CreateCourseHeader, CreateCourseBody] = useSection(
16 | "Create your course",
17 | CreateCourse
18 | );
19 | const [InviteMembersHeader, InviteMembersBody] = useSection(
20 | "Invite Students and Instructors",
21 | InviteMembers
22 | );
23 | const [CreateQueueHeader, CreateQueueBody] = useSection(
24 | "Create a Queue",
25 | CreateQueue
26 | );
27 | const [CalendarHeader, CalendarBody] = useSection(
28 | "Editing a Course Calendar",
29 | Calendar
30 | );
31 | const [HoldOHHeader, HoldOHBody] = useSection(
32 | "Hold Office Hours",
33 | HoldOfficeHours
34 | );
35 |
36 | const [AnalyticsHeader, AnalyticsBody] = useSection("Analytics", Analytics);
37 |
38 | const [SettingsHeader, SettingsBody] = useSection("Settings", Settings);
39 |
40 | return (
41 | <>
42 |
43 | Jump to:
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | >
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/frontend/hooks/data-fetching/account.ts:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 | import { useResource } from "@pennlabs/rest-hooks";
3 | import { User } from "../../types";
4 | import { doApiRequest } from "../../utils/fetch";
5 | import { logException } from "../../utils/sentry";
6 |
7 | export function useAccountInfo(initialUser?: User) {
8 | const { data, error, isValidating, mutate } = useResource(
9 | "/api/accounts/me/",
10 | {
11 | initialData: initialUser,
12 | }
13 | );
14 | if (data) {
15 | return { data, error, isValidating, mutate: () => mutate() };
16 | }
17 | throw new Error("Could not get user account info");
18 | }
19 |
20 | export async function validateSMS(code) {
21 | const payload = {
22 | profile: {
23 | smsVerificationCode: code,
24 | },
25 | };
26 | const res = await doApiRequest("/api/accounts/me/", {
27 | method: "PATCH",
28 | body: payload,
29 | });
30 |
31 | if (!res.ok) {
32 | const body = await res.json();
33 | throw new Error(body.detail);
34 | }
35 | }
36 |
37 | export async function resendSMSVerification() {
38 | const res = await doApiRequest("/api/accounts/me/resend/", {
39 | method: "POST",
40 | });
41 |
42 | if (!res.ok) {
43 | throw new Error("could not resend verification code");
44 | }
45 | }
46 |
47 | export async function updateUser(payload: Partial) {
48 | const processedPayload: Partial = _.cloneDeep(payload);
49 | if (
50 | processedPayload.profile &&
51 | !processedPayload.profile.smsNotificationsEnabled
52 | ) {
53 | delete processedPayload.profile.phoneNumber;
54 | }
55 |
56 | const res = await doApiRequest("/api/accounts/me/", {
57 | method: "PATCH",
58 | body: processedPayload,
59 | });
60 |
61 | if (!res.ok) {
62 | const error = await res.json();
63 | logException(error);
64 | throw new Error("Update user failed");
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/queue_daily_stat.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from django.utils import timezone
3 |
4 | from ohq.models import Question, Queue
5 | from ohq.statistics import (
6 | queue_calculate_avg_time_helping,
7 | queue_calculate_avg_wait,
8 | queue_calculate_num_questions_ans,
9 | queue_calculate_num_students_helped,
10 | )
11 |
12 |
13 | class Command(BaseCommand):
14 | def add_arguments(self, parser):
15 | parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics")
16 |
17 | def calculate_statistics(self, queues, earliest_date):
18 | yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1)
19 |
20 | for queue in queues:
21 | queue_questions = Question.objects.filter(queue=queue)
22 |
23 | if earliest_date:
24 | iter_date = earliest_date
25 | else:
26 | iter_date = (
27 | timezone.template_localtime(
28 | queue_questions.earliest("time_asked").time_asked
29 | ).date()
30 | if queue_questions
31 | else yesterday
32 | )
33 |
34 | while iter_date <= yesterday:
35 |
36 | queue_calculate_avg_wait(queue, iter_date)
37 | queue_calculate_avg_time_helping(queue, iter_date)
38 | queue_calculate_num_questions_ans(queue, iter_date)
39 | queue_calculate_num_students_helped(queue, iter_date)
40 |
41 | iter_date += timezone.timedelta(days=1)
42 |
43 | def handle(self, *args, **kwargs):
44 | if kwargs["hist"]:
45 | queues = Queue.objects.all()
46 | earliest_date = None
47 | else:
48 | queues = Queue.objects.filter(archived=False)
49 | earliest_date = timezone.datetime.today().date() - timezone.timedelta(days=1)
50 |
51 | self.calculate_statistics(queues, earliest_date)
52 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/user_stat.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from datetime import timedelta
3 |
4 | from django.contrib.auth import get_user_model
5 | from django.core.management.base import BaseCommand
6 | from django.db.models import Q
7 | from django.utils import timezone
8 |
9 | from ohq.models import Course, Question
10 | from ohq.statistics import (
11 | user_calculate_questions_answered,
12 | user_calculate_questions_asked,
13 | user_calculate_students_helped,
14 | user_calculate_time_helped,
15 | user_calculate_time_helping,
16 | )
17 |
18 |
19 | User = get_user_model()
20 |
21 |
22 | class Command(BaseCommand):
23 | def add_arguments(self, parser):
24 | parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics")
25 |
26 | def calculate_statistics(self, courses, earliest_date):
27 | for course in courses:
28 |
29 | questions_queryset = Question.objects.filter(
30 | queue__course=course, time_asked__gte=earliest_date
31 | )
32 | users_union = User.objects.filter(
33 | Q(id__in=questions_queryset.values_list("asked_by", flat=True))
34 | | Q(id__in=questions_queryset.values_list("responded_to_by", flat=True))
35 | )
36 |
37 | for user in users_union:
38 | user_calculate_questions_asked(user)
39 | user_calculate_questions_answered(user)
40 | user_calculate_time_helped(user)
41 | user_calculate_time_helping(user)
42 | user_calculate_students_helped(user)
43 |
44 | def handle(self, *args, **kwargs):
45 | if kwargs["hist"]:
46 | courses = Course.objects.all()
47 | earliest_date = timezone.make_aware(datetime.datetime.utcfromtimestamp(0))
48 | else:
49 | courses = Course.objects.filter(archived=False)
50 | earliest_date = timezone.now().date() - timedelta(days=1)
51 |
52 | self.calculate_statistics(courses, earliest_date)
53 |
--------------------------------------------------------------------------------
/frontend/utils/enums.tsx:
--------------------------------------------------------------------------------
1 | import { Kind, QuestionStatus } from "../types";
2 |
3 | export const roleOptions = [
4 | {
5 | key: 0,
6 | value: Kind.PROFESSOR,
7 | text: "Professor",
8 | },
9 | {
10 | key: 1,
11 | value: Kind.HEAD_TA,
12 | text: "Head TA",
13 | },
14 | {
15 | key: 2,
16 | value: Kind.TA,
17 | text: "TA",
18 | },
19 | {
20 | key: 3,
21 | value: Kind.STUDENT,
22 | text: "Student",
23 | },
24 | ];
25 |
26 | export const staffRoleOptions = [
27 | {
28 | key: 0,
29 | value: Kind.PROFESSOR,
30 | text: "Professor",
31 | },
32 | {
33 | key: 1,
34 | value: Kind.HEAD_TA,
35 | text: "Head TA",
36 | },
37 | {
38 | key: 2,
39 | value: Kind.TA,
40 | text: "TA",
41 | },
42 | ];
43 |
44 | export function prettifyRole(role: Kind) {
45 | const optObj = roleOptions.find((o) => o.value === role);
46 | if (!optObj) {
47 | throw new Error("invariant broken");
48 | }
49 | return optObj.text;
50 | }
51 |
52 | export function isLeadershipRole(role: Kind) {
53 | return ["PROFESSOR", "HEAD_TA"].indexOf(role) >= 0;
54 | }
55 |
56 | export const questionStateOptions = [
57 | {
58 | key: 0,
59 | value: "ASKED",
60 | text: "Asked",
61 | },
62 | {
63 | key: 1,
64 | value: "WITHDRAWN",
65 | text: "Withdrawn",
66 | },
67 | {
68 | key: 2,
69 | value: "ACTIVE",
70 | text: "Active",
71 | },
72 | {
73 | key: 3,
74 | value: "REJECTED",
75 | text: "Rejected",
76 | },
77 | {
78 | key: 4,
79 | value: "ANSWERED",
80 | text: "Answered",
81 | },
82 | ];
83 |
84 | export function prettifyQuestionState(state: QuestionStatus) {
85 | const optObj = questionStateOptions.find((o) => o.value === state);
86 | if (!optObj) {
87 | throw new Error("invariant broken");
88 | }
89 | return optObj.text;
90 | }
91 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0020_auto_20240326_0226.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2024-03-26 02:26
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("ohq", "0019_auto_20211114_1800"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="UserStatistic",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
23 | ),
24 | ),
25 | (
26 | "metric",
27 | models.CharField(
28 | choices=[
29 | ("TOTAL_QUESTIONS_ASKED", "Total questions asked"),
30 | ("TOTAL_QUESTIONS_ANSWERED", "Total questions answered"),
31 | ("TOTAL_TIME_BEING_HELPED", "Total time being helped"),
32 | ("TOTAL_TIME_HELPING", "Total time helping"),
33 | ("TOTAL_STUDENTS_HELPED", "Total students helped"),
34 | ],
35 | max_length=256,
36 | ),
37 | ),
38 | ("value", models.DecimalField(decimal_places=8, max_digits=16)),
39 | (
40 | "user",
41 | models.ForeignKey(
42 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
43 | ),
44 | ),
45 | ],
46 | ),
47 | migrations.AddConstraint(
48 | model_name="userstatistic",
49 | constraint=models.UniqueConstraint(
50 | fields=("user", "metric"), name="unique_user_statistic"
51 | ),
52 | ),
53 | ]
54 |
--------------------------------------------------------------------------------
/backend/ohq/invite.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.core.validators import validate_email
3 | from django.db.models import Q
4 |
5 | from ohq.models import Membership, MembershipInvite
6 |
7 |
8 | User = get_user_model()
9 |
10 |
11 | def parse_and_send_invites(course, emails, kind):
12 | """
13 | Take in a list of emails, validate them. Then:
14 | 1. Create memberships for emails that belong to an existing user
15 | 2. Send out membership invites to the remaining emails
16 | """
17 |
18 | # Validate emails
19 | for email in emails:
20 | validate_email(email)
21 |
22 | # Map of pennkey to invite email (which may be different from the user's email)
23 | email_map = {email.split("@")[0]: email for email in emails}
24 |
25 | # Remove invitees already in class
26 | existing = Membership.objects.filter(
27 | course=course, user__username__in=email_map.keys()
28 | ).values_list("user__username", flat=True)
29 | existing = [email_map[pennkey] for pennkey in existing]
30 |
31 | emails = list(set(emails) - set(existing))
32 |
33 | # Remove users already invited
34 | existing = MembershipInvite.objects.filter(course=course, email__in=emails).values_list(
35 | "email", flat=True
36 | )
37 | emails = list(set(emails) - set(existing))
38 |
39 | # Generate final map of pennkey to email of users that need to be invited
40 | email_map = {email.split("@")[0]: email for email in emails}
41 |
42 | # Directly add invitees with existing accounts
43 | users = User.objects.filter(Q(email__in=emails) | Q(username__in=email_map.keys())).distinct()
44 | for user in users:
45 | membership = Membership.objects.create(course=course, user=user, kind=kind)
46 | membership.send_email()
47 | del email_map[user.username]
48 |
49 | # Create membership invites for invitees without an account
50 | for email in email_map.values():
51 | invite = MembershipInvite.objects.create(email=email, course=course, kind=kind)
52 | invite.send_email()
53 |
54 | return (users.count(), len(email_map))
55 |
--------------------------------------------------------------------------------
/frontend/context/auth.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import * as React from "react";
3 | import { NextPageContext, NextPage } from "next";
4 | import nextRedirect from "../utils/redirect";
5 | import { doApiRequest } from "../utils/fetch";
6 | import { User } from "../types";
7 | import { GIPPage } from "../utils/gippage";
8 |
9 | export const AuthUserContext: React.Context<{ user?: User }> = createContext(
10 | {}
11 | );
12 |
13 | export interface AuthProps {
14 | user?: User;
15 | }
16 |
17 | export function withAuth(
18 | WrappedComponent: NextPage
19 | ): GIPPage {
20 | const AuthedComponent = ({ user, ...props }: T & AuthProps) => {
21 | return (
22 | // eslint-disable-next-line react/jsx-no-constructed-context-values
23 |
24 | {/* eslint-disable */}
25 | {/* @ts-ignore */}
26 |
27 | {/* eslint-enable */}
28 |
29 | );
30 | };
31 |
32 | AuthedComponent.getInitialProps = async (ctx: NextPageContext) => {
33 | const headers = {
34 | credentials: "include",
35 | headers: ctx.req ? { cookie: ctx.req.headers.cookie } : undefined,
36 | };
37 |
38 | const res = await doApiRequest("/api/accounts/me/", headers);
39 | let user: User | undefined;
40 | if (res.ok) {
41 | user = await res.json();
42 | } else {
43 | nextRedirect(
44 | ctx,
45 | (url) => url !== "/",
46 | `/api/accounts/login/?next=${ctx.asPath}`
47 | );
48 | }
49 |
50 | if (WrappedComponent.getInitialProps) {
51 | const props = await WrappedComponent.getInitialProps(ctx);
52 | return { ...props, user };
53 | } else {
54 | // Cast is sound: if WrappedComponent doesn't have
55 | // getInitialProps, then T : {}
56 | return { user } as T & AuthProps;
57 | }
58 | };
59 |
60 | return AuthedComponent;
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "plugins": ["prettier"],
4 | "extends": ["airbnb", "react-app", "prettier"],
5 | "env": {
6 | "browser": true
7 | },
8 | "rules": {
9 | "prettier/prettier": "error",
10 | "import/extensions": 0,
11 | "quotes": ["error", "double", "avoid-escape"],
12 | // "no-unused-vars": [
13 | // "error",
14 | // {
15 | // "args": "none"
16 | // }
17 | // ],
18 | "no-unused-vars": "off",
19 | "@typescript-eslint/no-unused-vars": "error",
20 | "no-shadow": "off",
21 | "@typescript-eslint/no-shadow": "error",
22 | "import/prefer-default-export": 0,
23 | "react/jsx-filename-extension": 0,
24 | "react/prop-types": 0,
25 | "react/react-in-jsx-scope": 0,
26 | "jsx-a11y/click-events-have-key-events": 0,
27 | "jsx-a11y/interactive-supports-focus": 0,
28 | "jsx-a11y/accessible-emoji": 0,
29 | "jsx-ally/anchor-is-valid": 0,
30 | "react/require-default-props": 0,
31 | "react/jsx-boolean-value": 0,
32 | "react/destructuring-assignment": 0,
33 | "react/function-component-definition": 0,
34 | "no-bitwise": "off",
35 | "no-await-in-loop": "warn",
36 | "no-else-return": 0,
37 | "global-require": 0,
38 | "arrow-body-style": "off",
39 | "jsx-a11y/label-has-associated-control": [
40 | "error",
41 | {
42 | "labelComponents": [],
43 | "labelAttributes": [],
44 | "controlComponents": [
45 | "AsyncSelect",
46 | "Form.Group",
47 | "Form.Radio",
48 | "Form.Input",
49 | "Form.Dropdown",
50 | "TextField",
51 | "Form.TextArea"
52 | ],
53 | "assert": "either"
54 | }
55 | ]
56 | },
57 | "settings": {
58 | "import/resolver": {
59 | "node": {
60 | "extensions": [".js", ".jsx", ".ts", ".tsx"]
61 | }
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/backend/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | db:
5 | image: postgres
6 | environment:
7 | - POSTGRES_DB=postgres
8 | - POSTGRES_USER=postgres
9 | - POSTGRES_PASSWORD=postgres
10 | ports:
11 | - "5432:5432"
12 | volumes:
13 | - ./postgres:/var/lib/postgresql/data
14 | redis:
15 | image: redis:4.0
16 | ports:
17 | - "6379:6379"
18 | # api, dashboard, and admin requests for proddev
19 | proddev-backend-wsgi:
20 | depends_on:
21 | - db
22 | - redis
23 | profiles:
24 | - proddev
25 | build:
26 | context: .
27 | dockerfile: Dockerfile
28 | ports:
29 | - "8001:80"
30 | environment:
31 | - DATABASE_URL=postgres://postgres:postgres@db:5432/postgres
32 | - DJANGO_SETTINGS_MODULE=officehoursqueue.settings.proddev
33 | command: sh -c "python manage.py migrate && { DJANGO_SUPERUSER_PASSWORD=root python manage.py createsuperuser --no-input --username root --email root@pennlabs.org; /usr/local/bin/django-run; }"
34 | # Web socket requests for proddev
35 | proddev-backend-asgi:
36 | depends_on:
37 | - db
38 | - redis
39 | profiles:
40 | - proddev
41 | build:
42 | context: .
43 | dockerfile: Dockerfile
44 | ports:
45 | - "8002:80"
46 | environment:
47 | - DATABASE_URL=postgres://postgres:postgres@db:5432/postgres
48 | - DJANGO_SETTINGS_MODULE=officehoursqueue.settings.proddev
49 | command: sh -c "python manage.py migrate && { DJANGO_SUPERUSER_PASSWORD=root python manage.py createsuperuser --no-input --username root --email root@pennlabs.org; /usr/local/bin/asgi-run; }"
50 | # frontend for proddev
51 | proddev-frontend:
52 | profiles:
53 | - proddev
54 | build:
55 | context: ../frontend
56 | dockerfile: ../frontend/Dockerfile
57 | ports:
58 | - "8003:80"
59 | # Reverse proxy for routing requests to the various proddev servers based on the path
60 | nginx:
61 | image: nginx:latest
62 | depends_on:
63 | - proddev-backend-wsgi
64 | - proddev-backend-asgi
65 | - proddev-frontend
66 | profiles:
67 | - proddev
68 | ports:
69 | - "8000:80"
70 | volumes:
71 | - ./nginx-proddev.conf:/etc/nginx/nginx.conf:ro
72 |
--------------------------------------------------------------------------------
/backend/ohq/migrations/0019_auto_20211114_1800.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.7 on 2021-11-14 18:00
2 |
3 | import django.db.models.deletion
4 | from django.conf import settings
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 | ("ohq", "0018_auto_20220125_0344"),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name="CourseStatistic",
18 | fields=[
19 | (
20 | "id",
21 | models.AutoField(
22 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
23 | ),
24 | ),
25 | (
26 | "metric",
27 | models.CharField(
28 | choices=[
29 | ("STUDENT_QUESTIONS_ASKED", "Student: Questions asked"),
30 | ("STUDENT_TIME_BEING_HELPED", "Student: Time being helped"),
31 | ("INSTR_QUESTIONS_ANSWERED", "Instructor: Questions answered"),
32 | ("INSTR_TIME_ANSWERING", "Instructor: Time answering questions"),
33 | ],
34 | max_length=256,
35 | ),
36 | ),
37 | ("value", models.DecimalField(decimal_places=8, max_digits=16)),
38 | ("date", models.DateField(blank=True, null=True)),
39 | (
40 | "course",
41 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="ohq.course"),
42 | ),
43 | (
44 | "user",
45 | models.ForeignKey(
46 | on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
47 | ),
48 | ),
49 | ],
50 | ),
51 | migrations.AddConstraint(
52 | model_name="coursestatistic",
53 | constraint=models.UniqueConstraint(
54 | fields=("user", "course", "metric", "date"), name="course_statistic"
55 | ),
56 | ),
57 | ]
58 |
--------------------------------------------------------------------------------
/frontend/components/Auth/AuthPrompt.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Grid, Button } from "semantic-ui-react";
3 | import { useRouter, NextRouter } from "next/router";
4 | import AboutModal from "../common/AboutModal";
5 | import styles from "../../styles/landingpage.module.css";
6 |
7 | const AuthPrompt = (): JSX.Element => {
8 | const [showAboutModal, setShowAboutModal] = useState(false);
9 | const router: NextRouter = useRouter();
10 | return (
11 |
20 |
21 |
22 |
28 |
29 |
30 |
36 |
37 |
38 |
44 |
45 |
53 | setShowAboutModal(false)}
56 | />
57 |
58 |
59 | );
60 | };
61 | export default AuthPrompt;
62 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/course_stat.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from django.utils import timezone
3 |
4 | from ohq.models import Course, Question
5 | from ohq.statistics import (
6 | course_calculate_instructor_most_questions_answered,
7 | course_calculate_instructor_most_time_helping,
8 | course_calculate_student_most_questions_asked,
9 | course_calculate_student_most_time_being_helped,
10 | )
11 |
12 |
13 | class Command(BaseCommand):
14 | def add_arguments(self, parser):
15 | parser.add_argument("--hist", action="store_true", help="Calculate all historic statistics")
16 |
17 | def calculate_statistics(self, courses, earliest_date):
18 | yesterday = timezone.datetime.today().date() - timezone.timedelta(days=1)
19 |
20 | for course in courses:
21 | if earliest_date:
22 | iter_date = earliest_date
23 | else:
24 | course_questions = Question.objects.filter(queue__course=course)
25 | iter_date = (
26 | timezone.template_localtime(
27 | course_questions.earliest("time_asked").time_asked
28 | ).date()
29 | if course_questions
30 | else yesterday
31 | )
32 |
33 | # weekday() - monday is 0, sunday is 6 => we want last sunday
34 | iter_date = iter_date - timezone.timedelta(days=(iter_date.weekday() + 1) % 7)
35 |
36 | while iter_date <= yesterday:
37 | course_calculate_student_most_questions_asked(course, iter_date)
38 | course_calculate_student_most_time_being_helped(course, iter_date)
39 | course_calculate_instructor_most_questions_answered(course, iter_date)
40 | course_calculate_instructor_most_time_helping(course, iter_date)
41 |
42 | iter_date += timezone.timedelta(days=7)
43 |
44 | def handle(self, *args, **kwargs):
45 | if kwargs["hist"]:
46 | courses = Course.objects.all()
47 | earliest_date = None
48 | else:
49 | courses = Course.objects.filter(archived=False)
50 | earliest_date = timezone.datetime.today().date() - timezone.timedelta(days=1)
51 |
52 | self.calculate_statistics(courses, earliest_date)
53 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/ClearQueueModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Modal, Button } from "semantic-ui-react";
3 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
4 | import { Queue, Question } from "../../../types";
5 | import { clearQueue } from "../../../hooks/data-fetching/course";
6 | import { logException } from "../../../utils/sentry";
7 |
8 | interface ClearQueueModalProps {
9 | queue: Queue;
10 | courseId: number;
11 | queueId: number;
12 | mutate: mutateResourceListFunction;
13 | closeFunc: () => void;
14 | open: boolean;
15 | }
16 | const ClearQueueModal = (props: ClearQueueModalProps) => {
17 | const { courseId, queueId, mutate, closeFunc, open, queue } = props;
18 | const [mutateLoading, setRefetchLoading] = useState(false);
19 | const loading = false;
20 |
21 | const onSubmit = async () => {
22 | try {
23 | setRefetchLoading(true);
24 | await clearQueue(courseId, queueId);
25 | setRefetchLoading(false);
26 | mutate(-1, null);
27 | closeFunc();
28 | } catch (e) {
29 | logException(e);
30 | setRefetchLoading(false);
31 | }
32 | };
33 |
34 | return (
35 |
36 |
37 |
38 |
39 | You are about to clear all remaining questions on{" "}
40 | {queue.name}.
41 |
42 | Doing so will reject all pending questions.
43 |
44 |
45 |
46 |
59 |
60 | );
61 | };
62 |
63 | export default ClearQueueModal;
64 |
--------------------------------------------------------------------------------
/frontend/components/Course/Roster/RemoveButton.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Icon, Popup, Button } from "semantic-ui-react";
3 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
4 | import { Membership, MembershipInvite } from "../../../types";
5 |
6 | interface RemoveButtonProps {
7 | disabled?: boolean;
8 | id: number;
9 | userName?: string;
10 | isInvited: boolean;
11 | successFunc: (userName?: string) => void;
12 | mutateInvites: mutateResourceListFunction;
13 | mutateMemberships: mutateResourceListFunction;
14 | }
15 | const RemoveButton = (props: RemoveButtonProps) => {
16 | const {
17 | disabled,
18 | id,
19 | userName,
20 | isInvited,
21 | successFunc,
22 | mutateInvites,
23 | mutateMemberships,
24 | } = props;
25 | const [loading, setLoading] = useState(false);
26 | const [open, setOpen] = useState(false);
27 |
28 | const onSubmit = async () => {
29 | setLoading(true);
30 | if (isInvited) {
31 | await mutateInvites(id, null, { method: "DELETE" });
32 | } else {
33 | await mutateMemberships(id, null, { method: "DELETE" });
34 | }
35 | setLoading(false);
36 | setOpen(false);
37 | successFunc(userName);
38 | };
39 |
40 | const removeContent = (
41 |
47 | );
48 | const disabledContent = "Cannot remove only user in leadership role";
49 |
50 | return (
51 |
59 | }
60 | content={disabled ? disabledContent : removeContent}
61 | on={disabled ? "hover" : "click"}
62 | onClose={() => {
63 | setOpen(false);
64 | }}
65 | onOpen={() => {
66 | setOpen(true);
67 | }}
68 | open={open}
69 | position={disabled ? "left center" : "top center"}
70 | />
71 | );
72 | };
73 |
74 | export default RemoveButton;
75 |
--------------------------------------------------------------------------------
/frontend/components/Course/StudentQueuePage/StudentQueuePage.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { Grid, Message } from "semantic-ui-react";
3 | import { WSContext } from "@pennlabs/rest-live-hooks";
4 | import StudentQueues from "./StudentQueues";
5 | import Announcements from "../Announcements";
6 |
7 | import {
8 | useQueues,
9 | useCourse,
10 | useTags,
11 | } from "../../../hooks/data-fetching/course";
12 | import {
13 | Announcement,
14 | Course,
15 | Queue,
16 | QuestionMap,
17 | Tag,
18 | NotificationProps,
19 | } from "../../../types";
20 |
21 | interface StudentQueuePageProps {
22 | course: Course;
23 | queues: Queue[];
24 | questionmap: QuestionMap;
25 | play: NotificationProps;
26 | tags: Tag[];
27 | announcements: Announcement[];
28 | }
29 | const StudentQueuePage = (props: StudentQueuePageProps) => {
30 | const {
31 | course: rawCourse,
32 | queues: rawQueues,
33 | questionmap,
34 | play,
35 | tags: rawTags,
36 | announcements,
37 | } = props;
38 | const { data: course } = useCourse(rawCourse.id, rawCourse);
39 | const { data: queues, mutate } = useQueues(course!.id, rawQueues);
40 | const { data: tags } = useTags(rawCourse.id, rawTags);
41 |
42 | const { isConnected } = useContext(WSContext);
43 |
44 | return (
45 | <>
46 | {!isConnected && (
47 |
48 |
49 | You are not currently connected to OHQ. Reconnecting...
50 |
51 |
52 | )}
53 |
59 |
60 |
70 |
71 | >
72 | );
73 | };
74 |
75 | export default StudentQueuePage;
76 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/Questions.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, Message } from "semantic-ui-react";
2 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
3 | import _ from "lodash";
4 | import QuestionCard from "./QuestionCard";
5 | import { Question, Queue } from "../../../types";
6 |
7 | interface QuestionsProps {
8 | questions: Question[];
9 | queue: Queue;
10 | active: boolean;
11 | mutate: mutateResourceListFunction;
12 | notifs: boolean;
13 | setNotifs: (boolean) => void;
14 | }
15 | const Questions = (props: QuestionsProps) => {
16 | const { questions, queue, mutate, active, notifs, setNotifs } = props;
17 | return (
18 | <>
19 | {questions &&
20 | questions.length !== 0 &&
21 | _.sortBy(questions, "orderKey").map((question) => (
22 |
23 |
31 |
32 | ))}
33 | {active && questions && questions.length === 0 && (
34 |
35 |
36 |
41 |
42 |
43 | )}
44 | {!active && questions.length === 0 && (
45 |
46 |
47 |
53 |
54 |
55 | )}
56 | >
57 | );
58 | };
59 |
60 | export default Questions;
61 |
--------------------------------------------------------------------------------
/frontend/components/Calendar/calendarUtils.ts:
--------------------------------------------------------------------------------
1 | import { SemanticCOLORS } from "semantic-ui-react";
2 | import { Kind, UserMembership } from "../../types";
3 |
4 | export const eventColors: SemanticCOLORS[] = [
5 | "red",
6 | "olive",
7 | "teal",
8 | "pink",
9 | "orange",
10 | "green",
11 | "violet",
12 | "brown",
13 | "yellow",
14 | "purple",
15 | "grey",
16 | ];
17 |
18 | // SemanticCOLORS in hex
19 | export const eventColorsHex = {
20 | red: "#DB2828",
21 | olive: "#B5CC18",
22 | teal: "#00B5AD",
23 | pink: "#E03997",
24 | orange: "#F2711C",
25 | green: "#21BA45",
26 | violet: "#6435C9",
27 | brown: "#A5673F",
28 | yellow: "#FBBD08",
29 | purple: "#A333C8",
30 | grey: "#767676",
31 | blue: "#2185D0",
32 | };
33 |
34 | export const dayNames = [
35 | "Sunday",
36 | "Monday",
37 | "Tuesday",
38 | "Wednesday",
39 | "Thursday",
40 | "Friday",
41 | "Saturday",
42 | ];
43 |
44 | export const filterSortMemberships = (memberships: UserMembership[]) =>
45 | memberships
46 | .filter((m) => !m.course.archived)
47 | .sort((a, b) => {
48 | if (a.kind === Kind.STUDENT && b.kind !== Kind.STUDENT) return -1;
49 | else if (a.kind !== Kind.STUDENT && b.kind === Kind.STUDENT)
50 | return 1;
51 | return 0;
52 | });
53 |
54 | export const getMembershipIndex = (
55 | memberships: UserMembership[],
56 | courseId: number
57 | ) => memberships.findIndex((membership) => membership.course.id === courseId);
58 |
59 | // Note backend expects Monday=0.
60 | export const daysToParams = (days: number[], offset: number) =>
61 | days.length > 0
62 | ? days
63 | .sort()
64 | .map((day) => (day + 6 + offset) % 7)
65 | .reduce((acc, cur) => `${acc}${cur},`, "byweekday:")
66 | .slice(0, -1)
67 | : "";
68 |
69 | export const paramsToDays = (params: string, offset: number) =>
70 | params
71 | .substring(params.indexOf(":") + 1)
72 | .split(",")
73 | .map((s) => parseInt(s, 10))
74 | .map((day) => (day + 1 - offset) % 7);
75 |
76 | export const readSelectedCourses = () => {
77 | const stored = localStorage.getItem("studentCalendarSelectedCourses");
78 | if (stored === null) return null;
79 | const parsed = JSON.parse(stored);
80 | if (Array.isArray(parsed)) {
81 | return parsed as number[];
82 | } else {
83 | return null;
84 | }
85 | };
86 |
--------------------------------------------------------------------------------
/frontend/components/Course/CourseSidebarInstructorList.tsx:
--------------------------------------------------------------------------------
1 | import { Header, Icon, List, Segment } from "semantic-ui-react";
2 | import { useMediaQuery } from "@material-ui/core";
3 | import { useLeadership } from "../../hooks/data-fetching/course";
4 | import { Membership } from "../../types";
5 | import { leadershipSortFunc } from "../../utils";
6 | import { prettifyRole } from "../../utils/enums";
7 | import { MOBILE_BP } from "../../constants";
8 | import Footer from "../common/Footer";
9 |
10 | interface CourseSidebarInstructorListProps {
11 | courseId: number;
12 | leadership: Membership[];
13 | }
14 |
15 | const CourseSidebarInstructorList = ({
16 | courseId,
17 | leadership: leadershipRaw,
18 | }: CourseSidebarInstructorListProps) => {
19 | const { leadership: leadershipUnsorted } = useLeadership(
20 | courseId,
21 | leadershipRaw
22 | );
23 | const leadership = leadershipUnsorted.sort(leadershipSortFunc);
24 | const isMobile = useMediaQuery(`(max-width: ${MOBILE_BP})`);
25 |
26 | return (
27 | <>
28 |
29 |
30 |
31 | {leadership.map((membership) => {
32 | return (
33 |
37 |
38 |
39 |
44 | {`${membership.user.firstName} ${membership.user.lastName}`}
45 |
46 |
47 | {prettifyRole(membership.kind)}
48 |
49 |
50 |
51 | );
52 | })}
53 |
54 |
55 | {isMobile && }
56 | >
57 | );
58 | };
59 | export default CourseSidebarInstructorList;
60 |
--------------------------------------------------------------------------------
/backend/ohq/management/commands/createcourse.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand, CommandError
2 |
3 | from ohq.invite import parse_and_send_invites
4 | from ohq.models import Course, Membership, Semester
5 |
6 |
7 | class Command(BaseCommand):
8 | help = "Creates a course with default settings and invites users to course"
9 |
10 | def add_arguments(self, parser):
11 | parser.add_argument("department", type=str)
12 | parser.add_argument("course_code", type=str)
13 | parser.add_argument("course_title", type=str)
14 | parser.add_argument(
15 | "term", type=str, choices=[choice[0] for choice in Semester.TERM_CHOICES]
16 | )
17 | parser.add_argument("year", type=int)
18 | parser.add_argument("--emails", nargs="+", type=str)
19 | parser.add_argument(
20 | "--roles",
21 | nargs="+",
22 | choices=[Membership.KIND_PROFESSOR, Membership.KIND_HEAD_TA],
23 | )
24 |
25 | def handle(self, *args, **kwargs):
26 | course_code = kwargs["course_code"]
27 | department = kwargs["department"]
28 | course_title = kwargs["course_title"]
29 | term = kwargs["term"]
30 | year = kwargs["year"]
31 | emails = kwargs["emails"]
32 | roles = kwargs["roles"]
33 |
34 | if len(emails) != len(roles):
35 | raise CommandError("Length of emails and roles do not match")
36 |
37 | semester = Semester.objects.get(year=year, term=term)
38 | new_course = Course.objects.create(
39 | course_code=course_code,
40 | department=department,
41 | course_title=course_title,
42 | semester=semester,
43 | )
44 |
45 | self.stdout.write(f"Created new course '{new_course}'")
46 |
47 | role_map = {email: role for role, email in zip(roles, emails)}
48 |
49 | groups = {Membership.KIND_PROFESSOR: [], Membership.KIND_HEAD_TA: []}
50 | for email in emails:
51 | groups[role_map[email]].append(email)
52 |
53 | added, invited = parse_and_send_invites(
54 | new_course, groups[Membership.KIND_PROFESSOR], Membership.KIND_PROFESSOR
55 | )
56 | self.stdout.write(f"Added {added} professor(s) and invited {invited} professor(s)")
57 | added, invited = parse_and_send_invites(
58 | new_course, groups[Membership.KIND_HEAD_TA], Membership.KIND_HEAD_TA
59 | )
60 | self.stdout.write(f"Added {added} Head TA(s) and invited {invited} Head TA(s)")
61 |
--------------------------------------------------------------------------------
/frontend/pages/courses/[course]/calendar.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { Grid } from "semantic-ui-react";
3 | import { NextPageContext } from "next";
4 | import CourseWrapper from "../../../components/Course/CourseWrapper";
5 | import { doMultipleSuccessRequests } from "../../../utils/fetch";
6 | import { isLeadershipRole } from "../../../utils/enums";
7 | import { withAuth } from "../../../context/auth";
8 | import { CoursePageProps, Course, Membership } from "../../../types";
9 | import nextRedirect from "../../../utils/redirect";
10 | import InstructorCalendar from "../../../components/Calendar/InstructorCalendar/InstructorCalendar";
11 |
12 | const InstructorCalendarPage = (props: CoursePageProps) => {
13 | const { course, leadership } = props;
14 |
15 | return (
16 | <>
17 |
18 | {`OHQ | ${course.department} ${course.courseCode}`}
19 |
20 |
21 | {
25 | return ;
26 | }}
27 | />
28 |
29 | >
30 | );
31 | };
32 |
33 | InstructorCalendarPage.getInitialProps = async (
34 | context: NextPageContext
35 | ): Promise => {
36 | const { query, req } = context;
37 | const data = {
38 | headers: req ? { cookie: req.headers.cookie } : undefined,
39 | };
40 |
41 | let course: Course;
42 | let leadership: Membership[];
43 | const response = await doMultipleSuccessRequests([
44 | { path: `/api/courses/${query.course}/`, data },
45 | { path: `/api/courses/${query.course}/members/`, data },
46 | ]);
47 |
48 | if (response.success) {
49 | [course, leadership] = response.data;
50 | if (course.archived) {
51 | nextRedirect(
52 | context,
53 | () => true,
54 | `/courses/${query.course}/roster`
55 | );
56 | }
57 | } else {
58 | nextRedirect(context, () => true, "/404");
59 | throw new Error("Next should redirect: unreachable");
60 | }
61 |
62 | return {
63 | course,
64 | leadership: leadership.filter((m) => isLeadershipRole(m.kind)),
65 | };
66 | };
67 |
68 | export default withAuth(InstructorCalendarPage);
69 |
--------------------------------------------------------------------------------
/frontend/components/Course/StudentQueuePage/DeleteQuestionModal.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Modal, Segment, Button } from "semantic-ui-react";
3 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
4 | import { Question, Queue, QuestionStatus } from "../../../types";
5 | import { logException } from "../../../utils/sentry";
6 |
7 | interface DeleteQuestionModalProps {
8 | question: Question;
9 | queue: Queue;
10 | queueMutate: mutateResourceListFunction;
11 | lastQuestionsMutate: mutateResourceListFunction;
12 | open: boolean;
13 | setOpen: React.Dispatch>;
14 | mutate: mutateResourceListFunction;
15 | toastFunc: (success: string | null, error: any) => void;
16 | }
17 |
18 | const DeleteQuestionModal = (props: DeleteQuestionModalProps) => {
19 | const {
20 | question,
21 | queue,
22 | queueMutate,
23 | open,
24 | setOpen,
25 | mutate,
26 | toastFunc,
27 | lastQuestionsMutate,
28 | } = props;
29 |
30 | const onDelete = async () => {
31 | try {
32 | await mutate(question.id, {
33 | status: QuestionStatus.WITHDRAWN,
34 | });
35 | queueMutate(-1, null);
36 | lastQuestionsMutate(-1, null);
37 | setOpen(false);
38 | toastFunc("Question withdrawn!", null);
39 | } catch (e) {
40 | logException(e);
41 | setOpen(false);
42 | toastFunc(null, e);
43 | }
44 | };
45 |
46 | return (
47 |
48 | Withdraw Question
49 |
50 | You are about to withdraw your question from {queue.name}
51 | :
52 | {`"${question.text}"`}
53 |
54 | Once you withdraw from the queue, you cannot regain your
55 | spot!
56 |
57 |
58 |
59 | {
62 | setOpen(false);
63 | }}
64 | />
65 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default DeleteQuestionModal;
72 |
--------------------------------------------------------------------------------
/frontend/components/Course/StudentQueuePage/QueueMenuItem.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { Menu, Label } from "semantic-ui-react";
3 | import { useQuestions } from "../../../hooks/data-fetching/course";
4 | import {
5 | Question,
6 | Queue,
7 | QuestionStatus,
8 | NotificationProps,
9 | } from "../../../types";
10 |
11 | interface QueueMenuItemProps {
12 | queue: Queue;
13 | courseId: number;
14 | initialQuestions?: Question[];
15 | active: boolean;
16 | setActiveQueue: (id: number) => void;
17 | play: NotificationProps;
18 | }
19 |
20 | const QuestionNotifier = ({
21 | question,
22 | play,
23 | }: {
24 | question: Question;
25 | play: NotificationProps;
26 | }) => {
27 | useEffect(() => {
28 | if (
29 | question.status === QuestionStatus.ACTIVE ||
30 | !question.resolvedNote
31 | ) {
32 | play.current("Your question is being answered");
33 | }
34 | }, [question.status, play, question.resolvedNote]);
35 |
36 | useEffect(() => {
37 | if (question.position === 2) {
38 | play.current("Your question will be answered soon");
39 | }
40 | }, [question.position, play]);
41 |
42 | return null;
43 | };
44 |
45 | export const QueueMenuItem = (props: QueueMenuItemProps) => {
46 | const { queue, courseId, initialQuestions, active, setActiveQueue, play } =
47 | props;
48 |
49 | const { data: questions } = useQuestions(
50 | courseId,
51 | queue.id,
52 | initialQuestions || []
53 | );
54 |
55 | return (
56 | <>
57 | setActiveQueue(queue.id)}
61 | >
62 |
63 | {
64 | questions!.filter(
65 | (q) =>
66 | q.status === QuestionStatus.ACTIVE ||
67 | !q.resolvedNote
68 | ).length
69 | }
70 |
71 |
72 | {queue.active ? "Open" : "Closed"}
73 |
74 | {queue.name}
75 |
76 | {questions!.map((q) => (
77 |
78 | ))}
79 | >
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/frontend/components/Course/InstructorQueuePage/QueueSettings/QueueSettings.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Grid,
3 | Segment,
4 | Header,
5 | Button,
6 | Dimmer,
7 | Loader,
8 | } from "semantic-ui-react";
9 | import { mutateResourceListFunction } from "@pennlabs/rest-hooks/dist/types";
10 | import UpdateQueueForm from "./QueueSettingsForm";
11 | import { Queue } from "../../../../types";
12 |
13 | interface QueueSettingsProps {
14 | queue?: Queue;
15 | mutate: mutateResourceListFunction;
16 | backFunc: () => void;
17 | }
18 | const QueueSettings = (props: QueueSettingsProps) => {
19 | const { queue, mutate, backFunc } = props;
20 |
21 | return queue ? (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | {queue.name}
30 |
31 | {/* */}
32 | {queue.description}
33 | {/* */}
34 |
35 |
36 |
37 |
38 |
39 | props.backFunc()}
42 | />
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
56 |
57 |
58 |
59 | ) : (
60 |
61 |
62 |
63 | );
64 | };
65 |
66 | export default QueueSettings;
67 |
--------------------------------------------------------------------------------
/frontend/components/Course/Analytics/Heatmaps/Averages.tsx:
--------------------------------------------------------------------------------
1 | import { Tab, Segment, Header } from "semantic-ui-react";
2 | import { useHeatmapData } from "../../../../hooks/data-fetching/analytics";
3 | import { Metric } from "../../../../types";
4 | import Heatmap from "./Heatmap";
5 |
6 | interface AveragesProps {
7 | courseId: number;
8 | queueId: number;
9 | }
10 |
11 | export default function Averages({ courseId, queueId }: AveragesProps) {
12 | const { data: questionsData, isValidating: questionsValidating } =
13 | useHeatmapData(courseId, queueId, Metric.HEATMAP_QUESTIONS);
14 | const { data: waitTimesData, isValidating: waitValidating } =
15 | useHeatmapData(courseId, queueId, Metric.HEATMAP_WAIT);
16 |
17 | return (
18 |
19 |
20 | {
26 | if (questionsData) {
27 | return (
28 |
32 | );
33 | }
34 | if (questionsValidating) {
35 | return Loading...
;
36 | }
37 | return Error loading data
;
38 | },
39 | },
40 | {
41 | menuItem: "Student Wait Times",
42 | render: () => {
43 | if (waitTimesData) {
44 | return (
45 |
49 | );
50 | }
51 | if (waitValidating) return Loading...
;
52 | return Error loading data
;
53 | },
54 | },
55 | ]}
56 | />
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/backend/ohq/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from rest_framework_nested import routers
3 | from rest_live.routers import RealtimeRouter
4 |
5 | from ohq.views import (
6 | AnnouncementViewSet,
7 | CourseStatisticView,
8 | CourseViewSet,
9 | EventViewSet,
10 | MassInviteView,
11 | MembershipInviteViewSet,
12 | MembershipViewSet,
13 | OccurrenceViewSet,
14 | QuestionSearchView,
15 | QuestionViewSet,
16 | QueueStatisticView,
17 | QueueViewSet,
18 | ResendNotificationView,
19 | SemesterViewSet,
20 | TagViewSet,
21 | UserView,
22 | HealthView,
23 | )
24 |
25 |
26 | app_name = "ohq"
27 |
28 | router = routers.SimpleRouter()
29 | router.register("semesters", SemesterViewSet, basename="semester")
30 | router.register("courses", CourseViewSet, basename="course")
31 | router.register("events", EventViewSet, basename="event")
32 | router.register("occurrences", OccurrenceViewSet, basename="occurrence")
33 |
34 | course_router = routers.NestedSimpleRouter(router, "courses", lookup="course")
35 | course_router.register("queues", QueueViewSet, basename="queue")
36 | course_router.register("members", MembershipViewSet, basename="member")
37 | course_router.register("invites", MembershipInviteViewSet, basename="invite")
38 | course_router.register("announcements", AnnouncementViewSet, basename="announcement")
39 | course_router.register("tags", TagViewSet, basename="tag")
40 |
41 | queue_router = routers.NestedSimpleRouter(course_router, "queues", lookup="queue")
42 | queue_router.register("questions", QuestionViewSet, basename="question")
43 |
44 | realtime_router = RealtimeRouter()
45 | realtime_router.register(QuestionViewSet)
46 | realtime_router.register(AnnouncementViewSet)
47 |
48 | additional_urls = [
49 | path("health/", HealthView.as_view(), name="health"),
50 | path("accounts/me/", UserView.as_view(), name="me"),
51 | path("accounts/me/resend/", ResendNotificationView.as_view(), name="resend"),
52 | path("courses//mass-invite/", MassInviteView.as_view(), name="mass-invite"),
53 | path(
54 | "courses//questions/", QuestionSearchView.as_view(), name="questionsearch"
55 | ),
56 | path(
57 | "courses//queues//statistics/",
58 | QueueStatisticView.as_view(),
59 | name="queue-statistic",
60 | ),
61 | path(
62 | "courses//course-statistics/",
63 | CourseStatisticView.as_view(),
64 | name="course-statistic",
65 | ),
66 | ]
67 |
68 | urlpatterns = router.urls + course_router.urls + queue_router.urls + additional_urls
69 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Forms/AddStudentForm.tsx:
--------------------------------------------------------------------------------
1 | import { Form } from "semantic-ui-react";
2 |
3 | import AsyncSelect from "react-select/async";
4 | import { Course } from "../../../../types";
5 | import { getCourses } from "../../../../hooks/data-fetching/dashboard";
6 |
7 | interface AddStudentFormProps {
8 | changeFunc: (a: any, b: any) => void; // TODO: restrict this
9 | }
10 | const AddStudentForm = (props: AddStudentFormProps) => {
11 | const { changeFunc } = props;
12 | const promiseOptions = async (inputValue: string) => {
13 | if (inputValue.length === 0) {
14 | return [];
15 | }
16 | const courses: Course[] = await getCourses(inputValue);
17 |
18 | return courses
19 | .filter((course) => !course.archived)
20 | .map((course) => {
21 | const suffix = course.isMember ? " - Already Enrolled" : "";
22 | return {
23 | label: `${course.department} ${course.courseCode} (${course.semesterPretty}): ${course.courseTitle}${suffix}`,
24 | value: course.id,
25 | disabled: course.isMember,
26 | };
27 | });
28 | };
29 |
30 | return (
31 |
33 |
34 | Course Name or Course Code
35 |
36 |
42 | inputValue.length === 0
43 | ? "Search for a course"
44 | : "No courses found"
45 | }
46 | isMulti
47 | placeholder="Search..."
48 | isOptionDisabled={(option) => option.disabled}
49 | onChange={(items) => {
50 | changeFunc(undefined, {
51 | name: "courseIds",
52 | value:
53 | items === null
54 | ? []
55 | : // sound because of previous check
56 | items!.map((item) => item.value),
57 | });
58 | }}
59 | />
60 |
61 |
62 | );
63 | };
64 |
65 | export default AddStudentForm;
66 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Cards/ArchivedCourseCard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import Link from "next/link";
3 | import { Segment, Header } from "semantic-ui-react";
4 | import { Course } from "../../../../types";
5 |
6 | interface ArchivedCourseCardProps {
7 | course: Course;
8 | }
9 | const ArchivedCourseCard = (props: ArchivedCourseCardProps) => {
10 | const { course } = props;
11 | const [hover, setHover] = useState(false);
12 | return (
13 |
14 |
19 | setHover(true)}
23 | onMouseLeave={() => setHover(false)}
24 | >
25 |
26 |
33 | {`${course.department} ${course.courseCode}`}
34 |
41 | {course.courseTitle}
42 |
43 |
44 |
45 |
46 |
54 | {course.semesterPretty}
55 |
56 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default ArchivedCourseCard;
64 |
--------------------------------------------------------------------------------
/frontend/components/Home/Dashboard/Modals/ModalAddStudentCourse.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Modal, Button } from "semantic-ui-react";
3 | import { mutateResourceFunction } from "@pennlabs/rest-hooks/dist/types";
4 | import AddStudentForm from "../Forms/AddStudentForm";
5 | import { joinCourse } from "../../../../hooks/data-fetching/dashboard";
6 | import { Toast, UserMembership } from "../../../../types";
7 | import { logException } from "../../../../utils/sentry";
8 |
9 | interface ModalAddStudentCourseProps {
10 | open: boolean;
11 | closeFunc: () => void;
12 | mutate: mutateResourceFunction;
13 | toastFunc: (toast: Toast) => void;
14 | }
15 | const ModalAddStudentCourse = (props: ModalAddStudentCourseProps) => {
16 | const { open, closeFunc, mutate, toastFunc } = props;
17 | const [loading, setLoading] = useState(false);
18 | const [input, setInput] = useState({ courseIds: [] });
19 | const [disabled, setDisabled] = useState(true);
20 |
21 | const handleInputChange = (e, { name, value }) => {
22 | input[name] = value;
23 | setInput(input);
24 | setDisabled(input.courseIds.length === 0);
25 | };
26 |
27 | const joinFunc = async () => {
28 | if (input.courseIds.length === 0) {
29 | return;
30 | }
31 | setLoading(true);
32 | try {
33 | const joinCoursesPromise = input.courseIds.map((id) => {
34 | return joinCourse(id);
35 | });
36 | await Promise.all(joinCoursesPromise);
37 | toastFunc({ message: "Added course!", success: true });
38 | } catch (e) {
39 | logException(e);
40 | toastFunc({ message: "Something went wrong!", success: false });
41 | }
42 | setLoading(false);
43 | mutate();
44 | closeFunc();
45 | };
46 |
47 | return (
48 |
49 | Join Courses
50 |
51 |
52 |
53 |
54 |
59 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default ModalAddStudentCourse;
72 |
--------------------------------------------------------------------------------
/frontend/components/Calendar/DashboardCalendar/EventCard.tsx:
--------------------------------------------------------------------------------
1 | import { Grid, Header, Segment, SemanticCOLORS } from "semantic-ui-react";
2 | import { useState } from "react";
3 | import { Course, Occurrence } from "../../../types";
4 |
5 | interface EventCardProps {
6 | occurrence: Occurrence;
7 | course: Course;
8 | color: SemanticCOLORS;
9 | onClick: () => void;
10 | }
11 |
12 | const EventCard = (props: EventCardProps) => {
13 | const { occurrence, course, color, onClick } = props;
14 |
15 | const startDate = new Date(occurrence.start);
16 | const endDate = new Date(occurrence.end);
17 |
18 | const [hover, setHover] = useState(false);
19 |
20 | const formatDate = (date: Date) =>
21 | date.toLocaleString("en-US", {
22 | hour: "numeric",
23 | minute: "numeric",
24 | hour12: true,
25 | });
26 |
27 | return (
28 | setHover(true)}
36 | onMouseLeave={() => setHover(false)}
37 | onClick={onClick}
38 | >
39 | {/* TODO: get rid of hardcoded width */}
40 |
41 |
42 |
50 | {`${course.department} ${course.courseCode}`}
51 |
58 | {occurrence.title}
59 |
60 |
61 |
62 |
63 |
64 | {formatDate(startDate)}
65 |
66 | {formatDate(endDate)}
67 |
68 |
69 |
70 |
71 | );
72 | };
73 |
74 | export default EventCard;
75 |
--------------------------------------------------------------------------------
/frontend/pages/courses/[course]/analytics.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { Grid } from "semantic-ui-react";
3 | import { NextPageContext } from "next";
4 | import CourseWrapper from "../../../components/Course/CourseWrapper";
5 | import { withAuth } from "../../../context/auth";
6 | import staffCheck from "../../../utils/staffcheck";
7 | import { withProtectPage } from "../../../utils/protectpage";
8 | import { doMultipleSuccessRequests } from "../../../utils/fetch";
9 | import { isLeadershipRole } from "../../../utils/enums";
10 | import nextRedirect from "../../../utils/redirect";
11 | import { Course, Membership, Queue } from "../../../types";
12 | import Analytics from "../../../components/Course/Analytics/Analytics";
13 |
14 | interface AnalyticsPageProps {
15 | course: Course;
16 | leadership: Membership[];
17 | queues: Queue[];
18 | }
19 |
20 | const AnalyticsPage = (props: AnalyticsPageProps) => {
21 | const { course, leadership, queues } = props;
22 | return (
23 | <>
24 |
25 | {`OHQ | ${course.department} ${course.courseCode}`}
26 |
27 |
28 | {
32 | return ;
33 | }}
34 | />
35 |
36 | >
37 | );
38 | };
39 |
40 | AnalyticsPage.getInitialProps = async (
41 | context: NextPageContext
42 | ): Promise => {
43 | const { query, req } = context;
44 | const data = {
45 | headers: req ? { cookie: req.headers.cookie } : undefined,
46 | };
47 |
48 | let course: Course;
49 | let leadership: Membership[];
50 | let queues: Queue[];
51 |
52 | const response = await doMultipleSuccessRequests([
53 | { path: `/api/courses/${query.course}/`, data },
54 | { path: `/api/courses/${query.course}/members/`, data },
55 | { path: `/api/courses/${query.course}/queues/`, data },
56 | ]);
57 |
58 | if (response.success) {
59 | [course, leadership, queues] = response.data;
60 | } else {
61 | nextRedirect(context, () => true, "/404");
62 | throw new Error("Next should redirect: unreachable");
63 | }
64 |
65 | return {
66 | course,
67 | leadership: leadership.filter((m) => isLeadershipRole(m.kind)),
68 | queues,
69 | };
70 | };
71 |
72 | export default withProtectPage(withAuth(AnalyticsPage), (user, ctx) => {
73 | return staffCheck(user, ctx);
74 | });
75 |
--------------------------------------------------------------------------------
/frontend/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from "react";
2 | import Head from "next/head";
3 | import { Snackbar } from "@material-ui/core";
4 | import { Alert } from "@material-ui/lab";
5 | import { NextPageContext } from "next";
6 | import { AuthUserContext, withAuth } from "../context/auth";
7 | import Home from "../components/Home/Home";
8 | import AuthPrompt from "../components/Auth/AuthPrompt";
9 | import Dashboard from "../components/Home/Dashboard/Dashboard";
10 | import ModalRedirectAddCourse from "../components/Home/Dashboard/Modals/ModalRedirectAddCourse";
11 | import { Course, Toast } from "../types";
12 | import { doApiRequest } from "../utils/fetch";
13 |
14 | interface LandingPageProps {
15 | signUpCourse?: Course;
16 | }
17 |
18 | const LandingPage = ({ signUpCourse }: LandingPageProps) => {
19 | const { user } = useContext(AuthUserContext);
20 |
21 | const [toast, setToast] = useState({ message: "", success: true });
22 | const [toastOpen, setToastOpen] = useState(false);
23 |
24 | return (
25 | <>
26 | {signUpCourse && signUpCourse.id && !signUpCourse.isMember && (
27 | {
30 | setToast(newToast);
31 | setToastOpen(true);
32 | }}
33 | />
34 | )}
35 |
36 | OHQ
37 |
38 | {user ? (
39 |
40 |
41 |
42 | ) : (
43 |
44 | )}
45 | setToastOpen(false)}
49 | >
50 | setToastOpen(false)}
53 | >
54 | {toast.message}
55 |
56 |
57 | >
58 | );
59 | };
60 |
61 | LandingPage.getInitialProps = async (
62 | context: NextPageContext
63 | ): Promise => {
64 | const { query, req } = context;
65 | const data = {
66 | headers: req ? { cookie: req.headers.cookie } : undefined,
67 | };
68 |
69 | const signUpId = Number(query.signup);
70 | let signUpCourse: Course | undefined;
71 | if (signUpId) {
72 | signUpCourse = await doApiRequest(
73 | `/api/courses/${signUpId}`,
74 | data
75 | ).then((res) => res.json());
76 | }
77 | return { signUpCourse };
78 | };
79 |
80 | export default withAuth(LandingPage);
81 |
--------------------------------------------------------------------------------
/frontend/components/Course/Analytics/Heatmaps/Heatmap.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 | import { HeatmapSeries } from "../../../../types";
3 |
4 | interface HeatmapProps {
5 | series: HeatmapSeries[];
6 | chartTitle: string;
7 | }
8 |
9 | // Dynamic import because this library can only run on the browser and causes error when importing server side
10 | const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
11 |
12 | const toDisplayHour = (hourString: string) => {
13 | const hourDecimal = Number(hourString);
14 | const hour = Math.trunc(hourDecimal);
15 | const minutes = (hourDecimal % 1) * 60;
16 |
17 | const hourDisplay = hour % 12 !== 0 ? hour % 12 : 12;
18 | const minuteDisplay = minutes !== 0 ? `:${minutes}` : "";
19 | const amOrPmDisplay = hour < 12 ? "AM" : "PM";
20 |
21 | return `${hourDisplay}${minuteDisplay} ${amOrPmDisplay}`;
22 | };
23 |
24 | export default function Heatmap({ series, chartTitle }: HeatmapProps) {
25 | const timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone;
26 |
27 | const options = {
28 | dataLabels: {
29 | enabled: false,
30 | },
31 | colors: ["#2185d0"],
32 | shadeIntensity: 1,
33 | title: {
34 | text: chartTitle,
35 | },
36 | chart: {
37 | toolbar: {
38 | tools: {
39 | zoom: false,
40 | zoomin: false,
41 | zoomout: false,
42 | pan: false,
43 | reset: false,
44 | download: false,
45 | },
46 | export: {
47 | csv: {
48 | // TODO: adjust csv export settings to make sure this doesn't break and set download: true
49 | },
50 | },
51 | },
52 | foreColor: "#1B1C1D",
53 | fontFamily: "Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif",
54 | },
55 | xaxis: {
56 | type: "category" as "category" | "datetime" | "numeric" | undefined,
57 | labels: {
58 | formatter: toDisplayHour,
59 | },
60 | title: {
61 | text: `Hour (${timeZoneName})`,
62 | },
63 | },
64 | responsive: [
65 | {
66 | breakpoint: 600,
67 | options: {},
68 | },
69 | ],
70 | plotOptions: {
71 | heatmap: {
72 | radius: 0,
73 | },
74 | },
75 | stroke: {
76 | colors: ["#E5E5E5"],
77 | },
78 | };
79 |
80 | return series.length !== 0 ? (
81 |
82 | ) : (
83 | No data available
84 | );
85 | }
86 |
--------------------------------------------------------------------------------
/frontend/components/Course/Roster/RosterForm.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Form, Button } from "semantic-ui-react";
3 | import { roleOptions } from "../../../utils/enums";
4 |
5 | interface RosterFormProps {
6 | showInviteButton: boolean;
7 | showShowInvitedButton: boolean;
8 | invitedShown: boolean;
9 | inviteFunc: () => void;
10 | showInvitedFunc: () => void;
11 | filterFunc: (any) => void; // TODO: restrict this
12 | }
13 | const RosterForm = (props: RosterFormProps) => {
14 | const {
15 | showInviteButton,
16 | showShowInvitedButton,
17 | invitedShown,
18 | inviteFunc,
19 | showInvitedFunc,
20 | filterFunc,
21 | } = props;
22 | const [input, setInput] = useState({
23 | search: "",
24 | role: "",
25 | });
26 |
27 | const handleInputChange = (e, { name, value }) => {
28 | input[name] = value.toLowerCase();
29 | setInput(input);
30 | filterFunc(input);
31 | };
32 |
33 | return (
34 |
41 |
43 |
44 |
52 |
53 |
54 |
60 |
61 |
62 |
63 |
64 | {showInviteButton && (
65 |
71 | )}
72 | {showShowInvitedButton && (
73 |
78 | )}
79 |
80 |
81 | );
82 | };
83 |
84 | export default RosterForm;
85 |
--------------------------------------------------------------------------------
/frontend/pages/courses/[course]/settings.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import { Grid } from "semantic-ui-react";
3 | import { NextPageContext } from "next";
4 | import CourseWrapper from "../../../components/Course/CourseWrapper";
5 | import { withAuth } from "../../../context/auth";
6 | import staffCheck from "../../../utils/staffcheck";
7 | import { withProtectPage } from "../../../utils/protectpage";
8 | import { doMultipleSuccessRequests } from "../../../utils/fetch";
9 | import { isLeadershipRole } from "../../../utils/enums";
10 | import CourseSettings from "../../../components/Course/CourseSettings/CourseSettings";
11 | import { CoursePageProps, Course, Membership, Tag } from "../../../types";
12 | import nextRedirect from "../../../utils/redirect";
13 |
14 | interface SettingsPageProps extends CoursePageProps {
15 | tags: Tag[];
16 | }
17 |
18 | const SettingsPage = (props: SettingsPageProps) => {
19 | const { course, leadership, tags } = props;
20 | return (
21 | <>
22 |
23 | {`OHQ | ${course.department} ${course.courseCode}`}
24 |
25 |
26 | {
30 | return ;
31 | }}
32 | />
33 |
34 | >
35 | );
36 | };
37 |
38 | SettingsPage.getInitialProps = async (
39 | context: NextPageContext
40 | ): Promise => {
41 | const { query, req } = context;
42 | const data = {
43 | headers: req ? { cookie: req.headers.cookie } : undefined,
44 | };
45 |
46 | let course: Course;
47 | let leadership: Membership[];
48 | let tags: Tag[];
49 |
50 | const response = await doMultipleSuccessRequests([
51 | { path: `/api/courses/${query.course}/`, data },
52 | { path: `/api/courses/${query.course}/members/`, data },
53 | { path: `/api/courses/${query.course}/tags/`, data },
54 | ]);
55 |
56 | if (response.success) {
57 | [course, leadership, tags] = response.data;
58 |
59 | if (course.archived) {
60 | nextRedirect(
61 | context,
62 | () => true,
63 | `/courses/${query.course}/roster`
64 | );
65 | }
66 | } else {
67 | nextRedirect(context, () => true, "/404");
68 | throw new Error("Next should redirect: unreachable");
69 | }
70 |
71 | return {
72 | course,
73 | leadership: leadership.filter((m) => isLeadershipRole(m.kind)),
74 | tags,
75 | };
76 | };
77 |
78 | export default withProtectPage(withAuth(SettingsPage), (user, ctx) => {
79 | return staffCheck(user, ctx);
80 | });
81 |
--------------------------------------------------------------------------------
/frontend/components/Course/Roster/Invites/InviteModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import * as React from "react";
3 | import { Modal, Button } from "semantic-ui-react";
4 | import AddForm from "./AddForm";
5 | import { sendMassInvites } from "../../../../hooks/data-fetching/course";
6 |
7 | interface InviteModalProps {
8 | courseId: number;
9 | open: boolean;
10 | closeFunc: () => void;
11 | successFunc: () => void;
12 | setToast: React.Dispatch>; // TODO: restrict this
13 | }
14 |
15 | enum InviteKind {
16 | PROFESSOR = "PROFESSOR",
17 | HEADTA = "HEAD_TA",
18 | TA = "TA",
19 | STUDENT = "STUDENT",
20 | }
21 |
22 | interface InviteState {
23 | emails: string;
24 | kind?: InviteKind;
25 | }
26 |
27 | const InviteModal = (props: InviteModalProps) => {
28 | const { courseId, open, closeFunc, successFunc, setToast } = props;
29 | const [loading, setLoading] = useState(false);
30 | const [input, setInput] = useState({
31 | emails: "",
32 | });
33 | const [disabled, setDisabled] = useState(true);
34 |
35 | const handleInputChange = (e, { name, value }) => {
36 | input[name] = value;
37 | setInput(input);
38 | setDisabled(input.emails.length === 0 || input.kind === undefined);
39 | };
40 |
41 | const inviteFunc = async () => {
42 | if (input.emails.length > 0 && input.kind !== undefined) {
43 | try {
44 | setLoading(true);
45 | await sendMassInvites(courseId, input.emails, input.kind);
46 | setLoading(false);
47 | closeFunc();
48 | successFunc();
49 | } catch (e) {
50 | setLoading(false);
51 | setToast({
52 | open: true,
53 | success: false,
54 | message: e.message.includes("Course cannot have more than")
55 | ? "Course cannot have more than 1000 users"
56 | : e.message,
57 | });
58 | }
59 | }
60 | };
61 |
62 | return (
63 |
64 | Invite User
65 |
66 |
67 |
68 |
69 |
74 |
81 |
82 |
83 | );
84 | };
85 |
86 | export default InviteModal;
87 |
--------------------------------------------------------------------------------
/backend/tests/ohq/test_invite.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.core.exceptions import ValidationError
3 | from django.test import TestCase
4 |
5 | from ohq.invite import parse_and_send_invites
6 | from ohq.models import Course, Membership, MembershipInvite, Semester
7 |
8 |
9 | User = get_user_model()
10 |
11 |
12 | class ParseAndSendInvitesTestCase(TestCase):
13 | def setUp(self):
14 | self.professor = User.objects.create(username="professor", email="professor@seas.upenn.edu")
15 | self.semester = Semester.objects.create(year=2020, term=Semester.TERM_SUMMER)
16 | self.course = Course.objects.create(
17 | course_code="000", department="TEST", course_title="Title", semester=self.semester
18 | )
19 | Membership.objects.create(
20 | course=self.course, user=self.professor, kind=Membership.KIND_PROFESSOR
21 | )
22 | self.user2 = User.objects.create(username="user2", email="user2@sas.upenn.edu")
23 |
24 | MembershipInvite.objects.create(course=self.course, email="user3@wharton.upenn.edu")
25 |
26 | def test_invalid_email(self):
27 | with self.assertRaises(ValidationError):
28 | parse_and_send_invites(self.course, ["not an email"], Membership.KIND_TA)
29 |
30 | def test_valid_emails(self):
31 | """
32 | Test situations where
33 | * the user is already a member under a different email
34 | * the user is not a member of the course and has different email
35 | * the email has already been sent an invite
36 | """
37 | members_added, invites_sent = parse_and_send_invites(
38 | self.course,
39 | [
40 | "professor@sas.upenn.edu",
41 | "user2@seas.upenn.edu",
42 | "user3@wharton.upenn.edu",
43 | "user4@nursing.upenn.edu",
44 | ],
45 | Membership.KIND_TA,
46 | )
47 |
48 | # # Correct number of invites and memberships created
49 | self.assertEqual(1, members_added)
50 | self.assertEqual(1, invites_sent)
51 |
52 | # Membership is created for user2
53 | self.assertEqual(
54 | 1,
55 | Membership.objects.filter(
56 | user=self.user2, course=self.course, kind=Membership.KIND_TA
57 | ).count(),
58 | )
59 |
60 | # Duplicate membership for user 1 isn't created
61 | self.assertEqual(2, Membership.objects.all().count())
62 |
63 | # Invite is sent to 4@nursing.upenn.edu
64 | self.assertEqual(
65 | 1,
66 | MembershipInvite.objects.filter(
67 | email="user4@nursing.upenn.edu", course=self.course, kind=Membership.KIND_TA
68 | ).count(),
69 | )
70 |
71 | # Duplicate membership invite for 3@example.com isn't created
72 | self.assertEqual(2, MembershipInvite.objects.all().count())
73 |
--------------------------------------------------------------------------------
/frontend/utils/fetch.tsx:
--------------------------------------------------------------------------------
1 | import getCsrf from "../csrf";
2 |
3 | export const SITE_ORIGIN =
4 | process.env.NODE_ENV === "production"
5 | ? `https://${process.env.DOMAIN || "ohq.io"}`
6 | : `http://127.0.0.1:${process.env.PORT || 3000}`;
7 |
8 | export const API_BASE_URL = `${SITE_ORIGIN}`;
9 |
10 | export function getApiUrl(path: string): string {
11 | if (/^https?:\/\//.test(path)) {
12 | const url = new URL(path);
13 | return url.pathname + url.search;
14 | }
15 | return API_BASE_URL + path;
16 | }
17 |
18 | export function doApiRequest(path: string, data?: any): Promise {
19 | let formattedData = data;
20 | if (!formattedData) {
21 | formattedData = {};
22 | }
23 | formattedData.credentials = "include";
24 | formattedData.mode = "same-origin";
25 | if (typeof document !== "undefined") {
26 | formattedData.headers = formattedData.headers || {};
27 | if (!(formattedData.body instanceof FormData)) {
28 | formattedData.headers.Accept = "application/json";
29 | formattedData.headers["Content-Type"] = "application/json";
30 | }
31 | formattedData.headers["X-CSRFToken"] = getCsrf();
32 | }
33 | if (formattedData.body && !(formattedData.body instanceof FormData)) {
34 | formattedData.body = JSON.stringify(formattedData.body);
35 | }
36 | return fetch(getApiUrl(path), formattedData);
37 | }
38 |
39 | interface Success {
40 | data: any;
41 | success: true;
42 | }
43 |
44 | interface Failure {
45 | success: false;
46 | }
47 |
48 | type VerifiedResponse = Success | Failure;
49 |
50 | // wraps doApiRequest to return a discriminated union
51 | // this allows us to distinguish between an error due to
52 | // accessing incorrect resources or an internet issue
53 |
54 | export async function doSuccessRequest(
55 | path: string,
56 | data?: any
57 | ): Promise {
58 | const res = await doApiRequest(path, data);
59 | if (!res.ok) {
60 | return {
61 | success: false,
62 | };
63 | }
64 | return {
65 | success: true,
66 | data: await res.json(),
67 | };
68 | }
69 |
70 | interface SuccessRequest {
71 | path: string;
72 | data?: any;
73 | }
74 |
75 | export async function doMultipleSuccessRequests(
76 | reqs: SuccessRequest[]
77 | ): Promise {
78 | const res = await Promise.all(
79 | reqs.map((req) => doSuccessRequest(req.path, req.data))
80 | );
81 | if (res.reduce((acc, curr) => acc && curr.success, true)) {
82 | return {
83 | success: true,
84 | // data-flow analysis isn't good enough to figure out
85 | // reduce condition makes sure all responses have succeeded
86 | data: res.map((succ) => (succ as Success).data),
87 | };
88 | } else {
89 | return {
90 | success: false,
91 | };
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/frontend/hooks/data-fetching/questionsummary.ts:
--------------------------------------------------------------------------------
1 | import { useFilteredResource, FilteredResourceResponse } from "./resources";
2 | import { QuestionStatus, Question } from "../../types";
3 |
4 | export enum SummaryOrderBy {
5 | TimeAskedAsc = "time_asked",
6 | TimeAskedDesc = "-time_asked",
7 | TimeRespondedToAsc = "time_responded_to",
8 | TimeRespondedToDesc = "-time_responded_to",
9 | }
10 |
11 | export interface QuestionSummaryFilters {
12 | page: number;
13 | timeAskedGt: string;
14 | timeAskedLt: string;
15 | status: QuestionStatus;
16 | search: string;
17 | orderBy: SummaryOrderBy;
18 | }
19 |
20 | export interface QuestionListResult {
21 | count: number;
22 | next: string;
23 | previous: string;
24 | results: Question[];
25 | }
26 |
27 | const summaryFilterToQuery = (
28 | filter: Partial
29 | ): string => {
30 | return (
31 | // eslint-disable-next-line
32 | "?" +
33 | Object.keys(filter)
34 | .map((key) => {
35 | let renamedKey: string;
36 | if (key === "timeAskedGt") {
37 | renamedKey = "time_asked__gt";
38 | } else if (key === "timeAskedLt") {
39 | renamedKey = "time_asked__lt";
40 | } else if (key === "orderBy") {
41 | renamedKey = "order_by";
42 | } else {
43 | renamedKey = key;
44 | }
45 | return `${encodeURIComponent(renamedKey)}=${encodeURIComponent(
46 | filter[key]
47 | )}`;
48 | })
49 | .join("&")
50 | );
51 | };
52 |
53 | interface QuestionsFilterResponse
54 | extends FilteredResourceResponse<
55 | QuestionListResult,
56 | QuestionSummaryFilters
57 | > {
58 | downloadUrl: string;
59 | }
60 |
61 | export const useQuestions = (
62 | courseId: number,
63 | initialQuestions: QuestionListResult
64 | ): QuestionsFilterResponse => {
65 | const baseUrl = `/api/courses/${courseId}/questions/`;
66 |
67 | const { data, error, isValidating, filters, updateFilter } =
68 | useFilteredResource(baseUrl, summaryFilterToQuery, initialQuestions, {
69 | page: 1,
70 | orderBy: SummaryOrderBy.TimeAskedDesc,
71 | });
72 |
73 | const filterCopy = { ...filters };
74 | delete filterCopy.page;
75 | const filterString = summaryFilterToQuery(filterCopy);
76 |
77 | // get rid of trailing slash
78 | let downloadUrl = baseUrl.slice(0, -1);
79 |
80 | // filter is empty will return "?"
81 | if (filterString.length === 1) {
82 | downloadUrl += "?format=xlsx";
83 | } else {
84 | downloadUrl += `${filterString}&format=xlsx`;
85 | }
86 |
87 | return {
88 | data,
89 | error,
90 | isValidating,
91 | filters,
92 | downloadUrl,
93 | updateFilter,
94 | };
95 | };
96 |
--------------------------------------------------------------------------------