├── .github └── workflows │ └── shared-build-deploy.yaml ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── Dockerfile ├── docker-compose.yaml ├── manage.py ├── nginx-proddev.conf ├── officehoursqueue │ ├── __init__.py │ ├── asgi.py │ ├── celery.py │ ├── routing.py │ ├── settings │ │ ├── __init__.py │ │ ├── base.py │ │ ├── ci.py │ │ ├── development.py │ │ ├── proddev.py │ │ ├── production.py │ │ └── staging.py │ ├── templates │ │ ├── emails │ │ │ ├── base.html │ │ │ ├── course_added.html │ │ │ └── course_invitation.html │ │ └── redoc.html │ ├── urls.py │ └── wsgi.py ├── ohq │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── backends.py │ ├── filters.py │ ├── invite.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── archive.py │ │ │ ├── calculatewaittimes.py │ │ │ ├── course_stat.py │ │ │ ├── createcourse.py │ │ │ ├── populate.py │ │ │ ├── queue_daily_stat.py │ │ │ ├── queue_heatmap_stat.py │ │ │ └── user_stat.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200816_1727.py │ │ ├── 0003_auto_20200822_1116.py │ │ ├── 0004_auto_20200825_1344.py │ │ ├── 0005_auto_20201016_1702.py │ │ ├── 0006_auto_20210105_2000.py │ │ ├── 0007_announcement.py │ │ ├── 0008_auto_20210119_2218.py │ │ ├── 0009_auto_20210201_2224.py │ │ ├── 0010_auto_20210405_1720.py │ │ ├── 0010_auto_20210407_0145.py │ │ ├── 0011_merge_20210415_2110.py │ │ ├── 0012_queue_require_video_chat_url_on_questions.py │ │ ├── 0013_auto_20210924_2056.py │ │ ├── 0014_question_student_descriptor.py │ │ ├── 0015_question_templates.py │ │ ├── 0016_auto_20211008_2136.py │ │ ├── 0017_auto_20211031_1615.py │ │ ├── 0018_auto_20220125_0344.py │ │ ├── 0019_auto_20211114_1800.py │ │ ├── 0020_auto_20240326_0226.py │ │ ├── 0021_queue_question_timer_enabled_and_more.py │ │ └── __init__.py │ ├── models.py │ ├── pagination.py │ ├── permissions.py │ ├── queues.py │ ├── routing.py │ ├── schemas.py │ ├── serializers.py │ ├── sms.py │ ├── statistics.py │ ├── tasks.py │ ├── urls.py │ └── views.py ├── pyproject.toml ├── scripts │ └── asgi-run ├── setup.cfg ├── tests │ ├── __init__.py │ └── ohq │ │ ├── __init__.py │ │ ├── test_backends.py │ │ ├── test_commands.py │ │ ├── test_filters.py │ │ ├── test_health.py │ │ ├── test_invite.py │ │ ├── test_live.py │ │ ├── test_migrations.py │ │ ├── test_models.py │ │ ├── test_permissions.py │ │ ├── test_queues.py │ │ ├── test_serializers.py │ │ ├── test_sms.py │ │ ├── test_tasks.py │ │ └── test_views.py └── uv.lock ├── frontend ├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .prettierrc.json ├── .projectile ├── Dockerfile ├── components │ ├── Auth │ │ └── AuthPrompt.tsx │ ├── Calendar │ │ ├── CalendarCommon.tsx │ │ ├── DashboardCalendar │ │ │ ├── EventCard.tsx │ │ │ └── EventSidebar.tsx │ │ ├── InstructorCalendar │ │ │ ├── InstructorCalendar.tsx │ │ │ └── InstructorCalendarModals.tsx │ │ ├── StudentCalendar │ │ │ └── StudentCalendar.tsx │ │ └── calendarUtils.ts │ ├── Changelog │ │ ├── changelogfile.md │ │ └── index.tsx │ ├── Course │ │ ├── Analytics │ │ │ ├── Analytics.tsx │ │ │ ├── Cards │ │ │ │ ├── AnalyticsCard.tsx │ │ │ │ └── SummaryCards.tsx │ │ │ ├── Heatmaps │ │ │ │ ├── Averages.tsx │ │ │ │ └── Heatmap.tsx │ │ │ └── mockData.tsx │ │ ├── Announcements.tsx │ │ ├── CourseSettings │ │ │ ├── CourseForm.module.css │ │ │ ├── CourseForm.tsx │ │ │ └── CourseSettings.tsx │ │ ├── CourseSidebarInstructorList.tsx │ │ ├── CourseSidebarNav.tsx │ │ ├── CourseWrapper.tsx │ │ ├── InstructorQueuePage │ │ │ ├── ClearQueueModal.tsx │ │ │ ├── FinishConfirmModal.tsx │ │ │ ├── InstructorQueuePage.tsx │ │ │ ├── InstructorQueues.tsx │ │ │ ├── MessageQuestionModal.tsx │ │ │ ├── QuestionCard.tsx │ │ │ ├── QuestionTimer.tsx │ │ │ ├── Questions.tsx │ │ │ ├── Queue.tsx │ │ │ ├── QueueCreate │ │ │ │ └── QueueCreate.tsx │ │ │ ├── QueueFormFields.tsx │ │ │ ├── QueueMenuItem.tsx │ │ │ ├── QueuePin.tsx │ │ │ ├── QueueSettings │ │ │ │ ├── QueueSettings.tsx │ │ │ │ └── QueueSettingsForm.tsx │ │ │ ├── RejectQuestionModal.tsx │ │ │ ├── Tags.tsx │ │ │ ├── aol.mp3 │ │ │ ├── notification.mp3 │ │ │ └── timeupsound.mp3 │ │ ├── Roster │ │ │ ├── ChangeRoleDropdown.tsx │ │ │ ├── Invites │ │ │ │ ├── AddForm.tsx │ │ │ │ └── InviteModal.tsx │ │ │ ├── RemoveButton.tsx │ │ │ ├── Roster.tsx │ │ │ └── RosterForm.tsx │ │ ├── StudentQueuePage │ │ │ ├── DeleteQuestionModal.tsx │ │ │ ├── EditQuestionModal.tsx │ │ │ ├── LastQuestionCard.tsx │ │ │ ├── QuestionCard.tsx │ │ │ ├── QuestionForm.tsx │ │ │ ├── QueueMenuItem.tsx │ │ │ ├── StudentQueue.tsx │ │ │ ├── StudentQueuePage.tsx │ │ │ └── StudentQueues.tsx │ │ └── Summary │ │ │ ├── Summary.tsx │ │ │ └── SummaryForm.tsx │ ├── Guide │ │ ├── InstructorGuide.tsx │ │ ├── InstructorGuideContent.tsx │ │ ├── StudentGuide.tsx │ │ ├── StudentGuideContent.tsx │ │ ├── index.tsx │ │ └── utils.tsx │ ├── Home │ │ ├── AccountSettings │ │ │ ├── AccountForm.tsx │ │ │ ├── AccountSettings.tsx │ │ │ ├── VerificationForm.tsx │ │ │ ├── VerificationModal.module.css │ │ │ └── VerificationModal.tsx │ │ ├── Dashboard │ │ │ ├── Cards │ │ │ │ ├── AddCard.tsx │ │ │ │ ├── ArchivedCourseCard.tsx │ │ │ │ └── CourseCard.tsx │ │ │ ├── Dashboard.tsx │ │ │ ├── Forms │ │ │ │ ├── AddStudentForm.tsx │ │ │ │ └── CreateCourseForm.tsx │ │ │ ├── InstructorCourses.tsx │ │ │ ├── Messages │ │ │ │ └── tips.json │ │ │ ├── Modals │ │ │ │ ├── ModalAddInstructorCourse.tsx │ │ │ │ ├── ModalAddStudentCourse.tsx │ │ │ │ ├── ModalLeaveStudentCourse.tsx │ │ │ │ ├── ModalRedirectAddCourse.tsx │ │ │ │ └── ModalShowNewChanges.tsx │ │ │ └── StudentCourses.tsx │ │ ├── Home.tsx │ │ └── HomeSidebar.tsx │ ├── SignOut │ │ └── index.tsx │ └── common │ │ ├── AboutModal.tsx │ │ ├── Footer.tsx │ │ └── ui │ │ ├── LinkedText.tsx │ │ └── ResponsiveIconButton.tsx ├── constants.ts ├── context │ └── auth.tsx ├── csrf.ts ├── global.d.ts ├── hooks │ ├── data-fetching │ │ ├── account.ts │ │ ├── analytics.ts │ │ ├── calendar.ts │ │ ├── course.ts │ │ ├── dashboard.ts │ │ ├── questionsummary.ts │ │ └── resources.ts │ ├── debounce.ts │ └── player.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.tsx │ ├── calendar.tsx │ ├── changelog.tsx │ ├── courses │ │ └── [course] │ │ │ ├── analytics.tsx │ │ │ ├── calendar.tsx │ │ │ ├── index.tsx │ │ │ ├── roster.tsx │ │ │ ├── settings.tsx │ │ │ └── summary.tsx │ ├── faq.tsx │ ├── health.tsx │ ├── index.tsx │ └── settings.tsx ├── public │ ├── answer-queue-1.png │ ├── create-course-1.png │ ├── create-course-2.png │ ├── create-course-3.png │ ├── favicon.ico │ ├── invite-members-1.png │ ├── join-course-1.png │ ├── join-course-2.png │ ├── joining-oh-1.png │ ├── notifications-1.png │ ├── ohq-login.png │ ├── ohq.png │ ├── open-queue-1.png │ ├── open-queue-2.png │ ├── put-queue-1.png │ ├── remove-queue-1.png │ ├── vercel.svg │ └── while-in-queue-1.png ├── server.js ├── styles │ ├── index.css │ └── landingpage.module.css ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── types.tsx ├── utils │ ├── enums.tsx │ ├── fetch.tsx │ ├── ga │ │ ├── googleAnalytics.ts │ │ └── withGA.tsx │ ├── gippage.ts │ ├── index.tsx │ ├── notifications.ts │ ├── protectpage.tsx │ ├── redirect.ts │ ├── sentry.tsx │ └── staffcheck.ts └── yarn.lock ├── k8s ├── .gitignore ├── cdk8s.yaml ├── main.ts ├── package.json ├── tsconfig.json └── yarn.lock └── ohq.code-workspace /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /backend/officehoursqueue/__init__.py: -------------------------------------------------------------------------------- 1 | from officehoursqueue.celery import app as celery_app 2 | 3 | 4 | __all__ = ("celery_app",) 5 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /backend/officehoursqueue/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/officehoursqueue/settings/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/settings/staging.py: -------------------------------------------------------------------------------- 1 | from officehoursqueue.settings.base import * # noqa: F401, F403 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/templates/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReDoc 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/ohq/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/ohq/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OhqConfig(AppConfig): 5 | name = "ohq" 6 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /backend/ohq/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/ohq/management/__init__.py -------------------------------------------------------------------------------- /backend/ohq/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/ohq/management/commands/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /backend/ohq/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/ohq/migrations/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/ohq/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/backend/tests/ohq/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/.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 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{js, jsx, ts, tsx}] 4 | indent_style = space 5 | indent_size = 4 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.projectile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/office-hours-queue/7fdcdaeba0b83600950edfbdfb155ba62cd2febd/frontend/.projectile -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | logo 28 | 29 | 30 | logo-mini 36 | 37 | 38 | 44 | 45 |
setShowAboutModal(true)} 50 | > 51 |

About

52 |
53 | setShowAboutModal(false)} 56 | /> 57 |
58 |
59 | ); 60 | }; 61 | export default AuthPrompt; 62 | -------------------------------------------------------------------------------- /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/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/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/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/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 |
{label}
26 |
27 | {isValidating ? "..." : content} 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /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 |
Semester Averages
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 | -------------------------------------------------------------------------------- /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/CourseSettings/CourseForm.module.css: -------------------------------------------------------------------------------- 1 | .department-input input { 2 | text-transform: uppercase; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /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 |
Course Settings
24 |
25 |
26 | 27 | 28 | 33 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default CourseSettings; 40 | -------------------------------------------------------------------------------- /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 |
Instructors
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 &&