diff --git a/.env.example b/.env.example index 0dfe9ea2c..8e01f02ae 100644 --- a/.env.example +++ b/.env.example @@ -1,49 +1,39 @@ -# Django -DJANGO_SETTINGS_MODULE=? -DEBUG=? -SECRET_KEY=? - -ENVIRONMENT=? - -# review drive account -REVIEW_DRIVE_ID=? -REVIEW_DRIVE_EMAIL=? -REVIEW_DRIVE_PASSWORD=? - -# local database -DB_NAME=? -DB_USER=? -DB_PASSWORD=? -DB_HOST=? -DB_PORT=? - -# prod database -PROD_DB_NAME=? -PROD_DB_USER=? -PROD_DB_PASSWORD=? -PROD_DB_HOST=? -PROD_DB_PORT=? - -# feedback -EMAIL_HOST_USER=? -EMAIL_HOST_PASSWORD=? - -# aws -AWS_ACCESS_KEY_ID=? -AWS_SECRET_ACCESS_KEY=? - -# s3 -AWS_STORAGE_BUCKET_NAME=? -AWS_S3_REGION_NAME=? - -# cognito -COGNITO_USER_POOL_ID=? -COGNITO_APP_CLIENT_ID=? -COGNITO_APP_CLIENT_SECRET=? -COGNITO_DOMAIN=? -COGNITO_REGION_NAME=? - -# ec2 -EC2_USER=? -EC2_HOST=? -PEM_KEY=? \ No newline at end of file +# ============================================================================= +# REQUIRED FOR ALL ENVIRONMENTS +# ============================================================================= +SECRET_KEY=any-random-string + +# ============================================================================= +# LOCAL DEVELOPMENT (docker-compose, local_dump.sh, reset-db.sh) +# ============================================================================= +DB_NAME=tcf_db +DB_USER=tcf_django +DB_PASSWORD=s3kr1t +DB_HOST=tcf_db +DB_PORT=5432 + +# ============================================================================= +# OPTIONAL - For auth features (login/logout/profile) +# ============================================================================= +# COGNITO_USER_POOL_ID= +# COGNITO_APP_CLIENT_ID= +# COGNITO_APP_CLIENT_SECRET= +# COGNITO_DOMAIN= +# COGNITO_REGION_NAME=us-east-1 + +# ============================================================================= +# OPTIONAL - For prod_dump.sh script (must activate EC2 instances & get pem key) +# ============================================================================= +# EC2_HOST= +# EC2_USER= +# PEM_KEY=path/to/your/key.pem +# PROD_DB_HOST= +# PROD_DB_USER= +# PROD_DB_PASSWORD= + +# ============================================================================= +# OPTIONAL - For load_review_drive management command +# ============================================================================= +# REVIEW_DRIVE_ID= +# REVIEW_DRIVE_EMAIL= +# REVIEW_DRIVE_PASSWORD= \ No newline at end of file diff --git a/.github/workflows/aws.yml b/.github/workflows/aws.yml index b9ed0db1c..d1d302447 100644 --- a/.github/workflows/aws.yml +++ b/.github/workflows/aws.yml @@ -1,8 +1,7 @@ -# Based on https://github.com/actions/starter-workflows/blob/main/ci/django.yml +# AWS ECS Deployment Workflow name: AWS Deployment on: - # CI must pass on the master branch workflow_run: workflows: ["Continuous Integration"] branches: [master] @@ -10,67 +9,22 @@ on: - completed env: - PYTHON_TARGET: 3.11 - # Django - DJANGO_SETTINGS_MODULE: tcf_core.settings.prod - SECRET_KEY: ${{ secrets.SECRET_KEY }} - DEBUG: 0 - ENVIRONMENT: production - # database - AWS_RDS_NAME: ${{ secrets.AWS_RDS_NAME }} - AWS_RDS_USER: ${{ secrets.AWS_RDS_USER }} - AWS_RDS_PASSWORD: ${{ secrets.AWS_DS_PASSWORD }} - AWS_RDS_HOST: ${{ secrets.AWS_RDS_HOST }} - AWS_RDS_PORT: ${{ secrets.AWS_RDS_PORT }} - # AWS Cognito - COGNITO_USER_POOL_ID: ${{ secrets.COGNITO_USER_POOL_ID }} - COGNITO_APP_CLIENT_ID: ${{ secrets.COGNITO_APP_CLIENT_ID }} - COGNITO_APP_CLIENT_SECRET: ${{ secrets.COGNITO_APP_CLIENT_SECRET }} - COGNITO_DOMAIN: ${{ secrets.COGNITO_DOMAIN }} - COGNITO_REGION_NAME: ${{ secrets.COGNITO_REGION_NAME }} - # email for account verification - EMAIL_HOST_USER: ${{ secrets.EMAIL_HOST_USER }} - EMAIL_HOST_PASSWORD: ${{ secrets.EMAIL_HOST_PASSWORD }} - # review drive account information - REVIEW_DRIVE_ID: ${{ secrets.REVIEW_DRIVE_ID }} - REVIEW_DRIVE_EMAIL: ${{ secrets.REVIEW_DRIVE_EMAIL }} - REVIEW_DRIVE_PASSWORD: ${{ secrets.REVIEW_DRIVE_PASSWORD }} - # aws ecs image AWS_REGION: us-east-1 ECS_SERVICE: barrett-fogle-love-v1 ECS_CLUSTER: tcf-fargate-cluster ECR_REPO: tcf/thecourseforum2 ECR_CONTAINER_NAME: tcf-container - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - AWS_STORAGE_BUCKET_NAME: ${{ secrets.AWS_STORAGE_BUCKET_NAME }} - AWS_S3_CUSTOM_DOMAIN: ${{ secrets.AWS_S3_CUSTOM_DOMAIN }} jobs: deploy: - # if: ${{ github.event.workflow_run.conclusion == 'success' }} + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - - name: Checkout `master` + - name: Checkout master uses: actions/checkout@v4 with: ref: ${{ github.event.workflow_run.head_sha }} - - name: Set up Python ${{ env.PYTHON_TARGET }} - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_TARGET }} - cache: 'pip' - - - name: Install Python packages - run: | - python -m pip install --upgrade pip - pip install django django-environ django-storages boto3 django-cachalot djangorestframework django-filter psycopg2-binary - - - name: Collect static files - run: python manage.py collectstatic --no-input - - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: @@ -79,38 +33,37 @@ jobs: aws-region: ${{ env.AWS_REGION }} - name: Login to Amazon ECR - id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build and push + - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true - tags: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:${{github.sha}} + tags: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:${{ github.sha }} cache-from: type=registry,ref=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:buildcache cache-to: type=registry,ref=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:buildcache,mode=max provenance: false - - name: Get latest task definition + - name: Get current task definition run: | aws ecs describe-task-definition \ --task-definition tcf-prod-task \ --query taskDefinition > task-definition.json - - name: Add image in ECS task definition + - name: Update task definition with new image id: task-def uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json container-name: ${{ env.ECR_CONTAINER_NAME }} - image: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:${{github.sha}} + image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/${{ env.ECR_REPO }}:${{ github.sha }} - - name: Deploy Amazon ECS task definition + - name: Deploy to Amazon ECS uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: task-definition: ${{ steps.task-def.outputs.task-definition }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8246fa8ed..2186da080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,40 +5,19 @@ on: pull_request: branches: - "*" - # The following is needed to run tests upon direct push to dev or master push: branches: [dev, master] + env: PYTHON_TARGET: 3.11 - # Django DJANGO_SETTINGS_MODULE: tcf_core.settings.ci - SECRET_KEY: ${{ secrets.SECRET_KEY }} - DEBUG: 1 - ENVIRONMENT: dev - # database - DB_NAME: tcf_db # arbitrary string - DB_USER: postgres # default user - DB_PASSWORD: postgres # default password - DB_HOST: localhost # required for GitHub Actions - DB_PORT: 5432 # default port - # AWS Cognito - COGNITO_USER_POOL_ID: ${{ secrets.COGNITO_USER_POOL_ID }} - COGNITO_APP_CLIENT_ID: ${{ secrets.COGNITO_APP_CLIENT_ID }} - COGNITO_APP_CLIENT_SECRET: ${{ secrets.COGNITO_APP_CLIENT_SECRET }} - COGNITO_DOMAIN: ${{ secrets.COGNITO_DOMAIN }} - COGNITO_REGION_NAME: ${{ secrets.COGNITO_REGION_NAME }} - # email for account verification - EMAIL_HOST_USER: ${{ secrets.EMAIL_HOST_USER }} - EMAIL_HOST_PASSWORD: ${{ secrets.EMAIL_HOST_PASSWORD }} - # review drive account information - REVIEW_DRIVE_ID: ${{ secrets.REVIEW_DRIVE_ID }} - REVIEW_DRIVE_EMAIL: ${{ secrets.REVIEW_DRIVE_EMAIL }} - REVIEW_DRIVE_PASSWORD: ${{ secrets.REVIEW_DRIVE_PASSWORD }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} - AWS_STORAGE_BUCKET_NAME: ${{ secrets.AWS_STORAGE_BUCKET_NAME }} - AWS_S3_CUSTOM_DOMAIN: ${{ secrets.AWS_S3_CUSTOM_DOMAIN }} + SECRET_KEY: ci-secret-key-not-for-production + # CI database (GitHub Actions postgres service) + DB_NAME: tcf_db + DB_USER: postgres + DB_PASSWORD: postgres + DB_HOST: localhost + DB_PORT: 5432 jobs: pylint: @@ -51,12 +30,12 @@ jobs: with: python-version: ${{ env.PYTHON_TARGET }} cache: 'pip' + cache-dependency-path: 'requirements/*.txt' - - name: Install Python packages, excluding the unnecessary ones + - name: Install dependencies run: | python -m pip install --upgrade pip - sed -i '/\(coverage\|types\-tqdm\|black\|isort\|gunicorn\|django\-heroku\|uwsgi\)/d' requirements.txt - pip install -r requirements.txt + pip install -r requirements/ci.txt - name: Run pylint run: | @@ -71,12 +50,11 @@ jobs: postgres: image: postgres:15.4 env: - POSTGRES_USER: ${{ env.DB_USER}} + POSTGRES_USER: ${{ env.DB_USER }} POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }} POSTGRES_DB: ${{ env.DB_NAME }} ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 @@ -86,16 +64,15 @@ jobs: with: python-version: ${{ env.PYTHON_TARGET }} cache: 'pip' + cache-dependency-path: 'requirements/*.txt' - - name: Install Python packages, excluding the unnecessary ones + - name: Install dependencies run: | python -m pip install --upgrade pip - sed -i '/\(lint\|mypy\|types\-tqdm\|black\|isort\|gunicorn\|django\-stubs\|uwsgi\)/d' requirements.txt - pip install -r requirements.txt + pip install -r requirements/ci.txt - name: Migrations & Tests run: | - envsubst < .env.example > .env python manage.py migrate coverage run manage.py test diff --git a/Dockerfile b/Dockerfile index c613f0ea4..d2c85cc03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,10 @@ RUN apt-get update && \ WORKDIR /app -COPY requirements.txt /app - -RUN pip3 install -r requirements.txt --disable-pip-version-check --no-cache-dir +# Copy requirements and install (use dev.txt for local, base.txt for prod) +ARG REQUIREMENTS=requirements/base.txt +COPY requirements/ /app/requirements/ +RUN pip3 install -r /app/${REQUIREMENTS} --disable-pip-version-check --no-cache-dir COPY . /app/ diff --git a/docker-compose.yml b/docker-compose.yml index b3d519c63..db062dde8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,19 @@ version: "3" services: web: - build: . + build: + context: . + args: + REQUIREMENTS: requirements/dev.txt command: - bash -c "if [ \"${ENVIRONMENT}\" = 'dev' ]; then /wait-for-it.sh tcf_db:${DB_PORT} || exit 1; fi && \ + bash -c "/wait-for-it.sh tcf_db:${DB_PORT} -- \ python manage.py migrate && \ python manage.py collectstatic --noinput && \ python manage.py invalidate_cachalot tcf_website && \ echo 'Starting Django Server...' && \ python manage.py runserver 0.0.0.0:8000" + environment: + - DJANGO_SETTINGS_MODULE=tcf_core.settings.dev volumes: - .:/app - /app/db/ # exclude the subfolder to prevent potential interference diff --git a/manage.py b/manage.py index 0fca6b613..b9c894470 100644 --- a/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tcf_core.settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tcf_core.settings.dev") try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/requirements.txt b/requirements/base.txt similarity index 61% rename from requirements.txt rename to requirements/base.txt index effde2574..057c2c54d 100644 --- a/requirements.txt +++ b/requirements/base.txt @@ -1,27 +1,17 @@ +# Core dependencies for all environments Django~=4.2.8 asgiref~=3.6.0 backoff~=2.2.1 -black~=24.1.1 boto3~=1.37.4 -coverage~=7.3.3 django-cachalot~=2.6.1 django-environ~=0.11.2 django-filter~=23.5 django-storages~=1.14.5 -django-stubs~=4.2.7 djangorestframework~=3.14.0 gunicorn~=21.2.0 -html-linter~=0.4.0 -isort~=5.13.2 -mypy-extensions~=1.0.0 -mypy~=1.7.1 numpy~=1.26.2 pandas~=2.1.4 psycopg[binary]>=3.2.12 -pylint-django~=2.5.5 -pylint~=3.0.3 +python-jose~=3.4.0 requests~=2.31.0 tqdm~=4.66.1 -types-tqdm~=4.66.0 -uWSGI~=2.0.28 -python-jose~=3.4.0 diff --git a/requirements/ci.txt b/requirements/ci.txt new file mode 100644 index 000000000..172ef6ab0 --- /dev/null +++ b/requirements/ci.txt @@ -0,0 +1,10 @@ +# CI dependencies (includes base + testing/linting tools) +-r base.txt + +# Linting (for pylint job) +pylint~=3.0.3 +pylint-django~=2.5.5 +django-stubs~=4.2.7 + +# Testing (for django job) +coverage~=7.3.3 diff --git a/requirements/dev.txt b/requirements/dev.txt new file mode 100644 index 000000000..d316915e1 --- /dev/null +++ b/requirements/dev.txt @@ -0,0 +1,24 @@ +# Development dependencies (includes base) +-r base.txt + +# Debug toolbar +django-debug-toolbar~=4.4.0 + +# Linting & formatting +black~=24.1.1 +isort~=5.13.2 +pylint~=3.0.3 +pylint-django~=2.5.5 +html-linter~=0.4.0 + +# Type checking +mypy~=1.7.1 +mypy-extensions~=1.0.0 +django-stubs~=4.2.7 +types-tqdm~=4.66.0 + +# Testing +coverage~=7.3.3 + +# WSGI server (for local testing) +uWSGI~=2.0.28 diff --git a/tcf_core/settings/__init__.py b/tcf_core/settings/__init__.py index cd7ecb09a..ed3a974c9 100644 --- a/tcf_core/settings/__init__.py +++ b/tcf_core/settings/__init__.py @@ -1,7 +1,12 @@ -# pylint: disable=unused-wildcard-import,wildcard-import +# pylint: disable=missing-module-docstring """ -Django settings module for local development environment. This is the default -Django settings file that will be used in case the environment variable -`DJANGO_SETTINGS_MODULE` is not set. +Settings package for tcf_core. + +Environment settings are loaded via DJANGO_SETTINGS_MODULE: + - Local dev: tcf_core.settings.dev (manage.py default) + - CI: tcf_core.settings.ci (set in ci.yml) + - Production: tcf_core.settings.prod (wsgi.py default) + +This file intentionally does NOT import any settings to prevent +import side effects that can cause unexpected behavior across environments. """ -from .base import * diff --git a/tcf_core/settings/base.py b/tcf_core/settings/base.py index 8f78d03ef..31ed5d863 100644 --- a/tcf_core/settings/base.py +++ b/tcf_core/settings/base.py @@ -20,9 +20,6 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = env.str("SECRET_KEY") -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool("DEBUG") # default value set on the top - ALLOWED_HOSTS = [] CORS_ALLOWED_ORIGINS = [ @@ -49,64 +46,6 @@ "tcf_website", ] -# Dev does not use S3 buckets -if env.str("ENVIRONMENT") == "dev": - STATIC_URL = "/static/" - STATIC_ROOT = os.path.join(BASE_DIR, "static") - - ALLOWED_HOSTS.extend(["localhost", ".grok.io", "127.0.0.1"]) - - DATABASES = { - "default": { - "NAME": env.str("DB_NAME"), - "ENGINE": "django.db.backends.postgresql", - "USER": env.str("DB_USER"), - "PASSWORD": env.str("DB_PASSWORD"), - "HOST": env.str("DB_HOST"), - "PORT": env.int("DB_PORT"), - } - } -else: - AWS_ACCESS_KEY_ID = env.str("AWS_ACCESS_KEY_ID") - AWS_SECRET_ACCESS_KEY = env.str("AWS_SECRET_ACCESS_KEY") - AWS_STORAGE_BUCKET_NAME = env.str("AWS_STORAGE_BUCKET_NAME") - AWS_S3_REGION_NAME = env.str("AWS_S3_REGION_NAME", default="us-east-1") - AWS_S3_CUSTOM_DOMAIN = env.str( - "AWS_S3_CUSTOM_DOMAIN", default=f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" - ) - AWS_DEFAULT_ACL = None - AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} - - ALLOWED_HOSTS.extend( - [ - "tcf-load-balancer-1374896025.us-east-1.elb.amazonaws.com", - "thecourseforum.com", - "thecourseforumtest.com", - "d1gr9vmyo0mkxv.cloudfront.net", - ] - ) - - STORAGES = { - "default": { - "BACKEND": "storages.backends.s3.S3Storage", - "OPTIONS": {}, - }, - "staticfiles": { - "BACKEND": "storages.backends.s3.S3Storage", - }, - } - - DATABASES = { - "default": { - "NAME": env.str("AWS_RDS_NAME"), - "ENGINE": "django.db.backends.postgresql", - "USER": env.str("AWS_RDS_USER"), - "PASSWORD": env.str("AWS_RDS_PASSWORD"), - "HOST": env.str("AWS_RDS_HOST"), - "PORT": env.int("AWS_RDS_PORT"), - } - } - MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.middleware.gzip.GZipMiddleware", @@ -177,16 +116,12 @@ USE_TZ = True -# social-auth-app-django settings. - -# AWS Cognito Configuration -COGNITO_USER_POOL_ID = env.str("COGNITO_USER_POOL_ID") -COGNITO_APP_CLIENT_ID = env.str("COGNITO_APP_CLIENT_ID") -COGNITO_APP_CLIENT_SECRET = env.str("COGNITO_APP_CLIENT_SECRET") -COGNITO_DOMAIN = env.str("COGNITO_DOMAIN") -COGNITO_REGION_NAME = env.str("COGNITO_REGION_NAME") - -# These should match exactly what you configured in Cognito +# AWS Cognito Configuration (optional - only needed for auth features) +COGNITO_USER_POOL_ID = env.str("COGNITO_USER_POOL_ID", default="") +COGNITO_APP_CLIENT_ID = env.str("COGNITO_APP_CLIENT_ID", default="") +COGNITO_APP_CLIENT_SECRET = env.str("COGNITO_APP_CLIENT_SECRET", default="") +COGNITO_DOMAIN = env.str("COGNITO_DOMAIN", default="") +COGNITO_REGION_NAME = env.str("COGNITO_REGION_NAME", default="us-east-1") COGNITO_REDIRECT_URI = "/cognito-callback" COGNITO_LOGOUT_URI = "/" @@ -228,15 +163,7 @@ "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), } -# Automated email settings -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_USE_TLS = True -EMAIL_PORT = 587 -EMAIL_HOST_USER = env.str("EMAIL_HOST_USER") -EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD") - -# Import review drive settings +# Review drive settings (optional, for load_review_drive command) REVIEW_DRIVE_ID = env.str("REVIEW_DRIVE_ID", default=None) REVIEW_DRIVE_EMAIL = env.str("REVIEW_DRIVE_EMAIL", default=None) REVIEW_DRIVE_PASSWORD = env.str("REVIEW_DRIVE_PASSWORD", default=None) diff --git a/tcf_core/settings/ci.py b/tcf_core/settings/ci.py index 4651aa35d..f9653687c 100644 --- a/tcf_core/settings/ci.py +++ b/tcf_core/settings/ci.py @@ -1,13 +1,24 @@ -# pylint: disable=unused-wildcard-import,wildcard-import,fixme -""" -Django settings module for CI services such as Travis or GitHub Actions - -Unlike other files where we use environment variables for everything, it is -okay to use some strings constants here and in .travis.yml or python-app.yml -""" +# pylint: disable=unused-wildcard-import,wildcard-import,duplicate-code +"""Django settings for CI (GitHub Actions).""" from .base import * -# The following if statement is needed to prevent overwriting global variables when -# this settings file is interpreted -if os.environ.get("DJANGO_SETTINGS_MODULE") == "tcf_core.settings.ci": - pass +# CI should mirror production: DEBUG=False catches issues early +DEBUG = False + +ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + +# CI PostgreSQL database (GitHub Actions service container) +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env.str("DB_NAME"), + "USER": env.str("DB_USER"), + "PASSWORD": env.str("DB_PASSWORD"), + "HOST": env.str("DB_HOST"), + "PORT": env.int("DB_PORT"), + } +} + +# Local static files +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") diff --git a/tcf_core/settings/dev.py b/tcf_core/settings/dev.py new file mode 100644 index 000000000..9bd99e15d --- /dev/null +++ b/tcf_core/settings/dev.py @@ -0,0 +1,32 @@ +# pylint: disable=unused-wildcard-import,wildcard-import,duplicate-code +"""Django settings for local development.""" +from .base import * + +DEBUG = True + +ALLOWED_HOSTS = ["localhost", "127.0.0.1", ".grok.io"] + +# Local PostgreSQL database +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env.str("DB_NAME"), + "USER": env.str("DB_USER"), + "PASSWORD": env.str("DB_PASSWORD"), + "HOST": env.str("DB_HOST"), + "PORT": env.int("DB_PORT"), + } +} + +# Local static files +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static") + +# Django Debug Toolbar +INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"] +MIDDLEWARE = ( + MIDDLEWARE[:2] + + ["debug_toolbar.middleware.DebugToolbarMiddleware"] + + MIDDLEWARE[2:] +) +DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda r: True} diff --git a/tcf_core/settings/prod.py b/tcf_core/settings/prod.py index 98095197f..ed8b6f1ed 100644 --- a/tcf_core/settings/prod.py +++ b/tcf_core/settings/prod.py @@ -1,26 +1,49 @@ # pylint: disable=unused-wildcard-import,wildcard-import -"""Django settings module for production environment like Google App Engine""" +"""Django settings for AWS production.""" from .base import * -# The following if statement is needed to prevent overwriting global variables when -# this settings file is interpreted -if os.environ.get("DJANGO_SETTINGS_MODULE") == "tcf_core.settings.prod": - # SECURITY WARNING: App Engine's security features ensure that it is safe to - # have ALLOWED_HOSTS = ['*'] when the app is deployed. If you deploy a Django - # app not on App Engine, make sure to set an appropriate host here. - # See https://docs.djangoproject.com/en/dev/ref/settings/ (from GCP - # documentation) - # Alex: "When I was trying to do manual GAE deployment when Travis was down, - # I had to add thecourseforum.com to ALLOWED_HOSTS or the deployment wouldn't - # work (see 40cac033ca14b5c379e5845f9c3870605cdac62d)." - ALLOWED_HOSTS = ["*", "thecourseforum.com"] +DEBUG = False - # CSRF settings to fix form submission issues in production - CSRF_TRUSTED_ORIGINS = [ - "https://thecourseforum.com", - "https://thecourseforumtest.com", - ] - SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +ALLOWED_HOSTS = [ + "*", + "thecourseforum.com", + "thecourseforumtest.com", + "tcf-load-balancer-1374896025.us-east-1.elb.amazonaws.com", + "d1gr9vmyo0mkxv.cloudfront.net", +] - # Use secure connection for database access - DATABASES["default"]["OPTIONS"] = {"sslmode": "require"} +# AWS S3 for static files +AWS_ACCESS_KEY_ID = env.str("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = env.str("AWS_SECRET_ACCESS_KEY") +AWS_STORAGE_BUCKET_NAME = env.str("AWS_STORAGE_BUCKET_NAME") +AWS_S3_REGION_NAME = env.str("AWS_S3_REGION_NAME", default="us-east-1") +AWS_S3_CUSTOM_DOMAIN = env.str( + "AWS_S3_CUSTOM_DOMAIN", default=f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" +) +AWS_DEFAULT_ACL = None +AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} + +STORAGES = { + "default": {"BACKEND": "storages.backends.s3.S3Storage", "OPTIONS": {}}, + "staticfiles": {"BACKEND": "storages.backends.s3.S3Storage"}, +} + +# AWS RDS PostgreSQL +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env.str("AWS_RDS_NAME"), + "USER": env.str("AWS_RDS_USER"), + "PASSWORD": env.str("AWS_RDS_PASSWORD"), + "HOST": env.str("AWS_RDS_HOST"), + "PORT": env.int("AWS_RDS_PORT"), + "OPTIONS": {"sslmode": "require"}, + } +} + +# Security +CSRF_TRUSTED_ORIGINS = [ + "https://thecourseforum.com", + "https://thecourseforumtest.com", +] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/tcf_core/urls.py b/tcf_core/urls.py index fb51bcaed..9001f2415 100644 --- a/tcf_core/urls.py +++ b/tcf_core/urls.py @@ -14,6 +14,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin from django.urls import include, path @@ -21,3 +22,8 @@ path("", include("tcf_website.urls")), path("admin/", admin.site.urls), ] + +if "debug_toolbar" in settings.INSTALLED_APPS: + import debug_toolbar # pylint: disable=import-error + + urlpatterns.insert(0, path("__debug__/", include(debug_toolbar.urls))) diff --git a/tcf_core/wsgi.py b/tcf_core/wsgi.py index c9f152b5d..490fb408b 100644 --- a/tcf_core/wsgi.py +++ b/tcf_core/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tcf_core.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tcf_core.settings.prod") application = get_wsgi_application() diff --git a/tcf_website/admin.py b/tcf_website/admin.py index 1209aad51..506b995a5 100644 --- a/tcf_website/admin.py +++ b/tcf_website/admin.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-class-docstring, wildcard-import +# pylint: disable=missing-class-docstring, wildcard-import, unused-wildcard-import """TCF Django Admin.""" diff --git a/tcf_website/migrations/0024_reply_votereply_and_more.py b/tcf_website/migrations/0024_reply_votereply_and_more.py new file mode 100644 index 000000000..bb133d6ae --- /dev/null +++ b/tcf_website/migrations/0024_reply_votereply_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 4.2.26 on 2025-11-29 19:40 + +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("tcf_website", "0023_remove_sectionenrollment_section_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Reply", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "review", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="tcf_website.review", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="VoteReply", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "value", + models.IntegerField( + validators=[ + django.core.validators.MinValueValidator(-1), + django.core.validators.MaxValueValidator(1), + ] + ), + ), + ( + "reply", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="tcf_website.reply", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index( + fields=["reply"], name="tcf_website_reply_i_f31693_idx" + ) + ], + }, + ), + migrations.AddConstraint( + model_name="votereply", + constraint=models.UniqueConstraint( + fields=("user", "reply"), name="unique vote per user and reply" + ), + ), + migrations.AddConstraint( + model_name="reply", + constraint=models.UniqueConstraint( + fields=("user", "review"), name="unique reply per user and review" + ), + ), + ] diff --git a/tcf_website/models/__init__.py b/tcf_website/models/__init__.py index c6e44d132..101bb59ee 100644 --- a/tcf_website/models/__init__.py +++ b/tcf_website/models/__init__.py @@ -17,6 +17,7 @@ Instructor, Question, Review, + Reply, Schedule, ScheduledCourse, School, diff --git a/tcf_website/models/models.py b/tcf_website/models/models.py index 73f4fcee3..808404ba1 100644 --- a/tcf_website/models/models.py +++ b/tcf_website/models/models.py @@ -20,6 +20,7 @@ FloatField, IntegerField, OuterRef, + Prefetch, Q, QuerySet, Subquery, @@ -275,15 +276,21 @@ def full_name(self): def reviews(self): """Return user reviews sorted by creation date.""" - return self.review_set.annotate( - sum_votes=models.functions.Coalesce( - models.Sum("vote__value"), models.Value(0) - ), - user_vote=models.functions.Coalesce( - models.Sum("vote__value", filter=models.Q(vote__user=self)), - models.Value(0), - ), - ).order_by("-created") + return ( + self.review_set.annotate( + sum_votes=models.functions.Coalesce( + models.Sum("vote__value"), models.Value(0) + ), + user_vote=models.functions.Coalesce( + models.Sum("vote__value", filter=models.Q(vote__user=self)), + models.Value(0), + ), + ) + .order_by("-created") + .prefetch_related( + Prefetch("replies", queryset=Reply.with_user_vote(self)) + ) + ) def schedules(self): """Return user schedules""" @@ -1360,6 +1367,8 @@ def get_paginated_reviews( ) -> "Page[Review]": """Generate sorted, paginated reviews""" reviews = Review.get_sorted_reviews(course_id, instructor_id, user, method) + replies_prefetch = Prefetch("replies", queryset=Reply.with_user_vote(user)) + reviews = reviews.prefetch_related(replies_prefetch) return Review.paginate(reviews, page_number) def __str__(self): @@ -1385,6 +1394,128 @@ class Meta: # ) # ] +class Reply(models.Model): + """Reply model. + Belongs to a user + Has a review + """ + + text = models.TextField(max_length=5000) + review = models.ForeignKey(Review, on_delete=models.CASCADE, related_name="replies") + user = models.ForeignKey(User, on_delete=models.CASCADE) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "review"], + name="unique reply per user and review", + ) + ] + + def __str__(self): + return f"Reply by {self.user.first_name} ({self.user.email}) to {self.review}" + + @property + def count_votes(self): + """Sum votes for reply for template consumption.""" + return self.votereply_set.aggregate( + upvotes=Coalesce(models.Sum("value", filter=models.Q(value=1)), 0), + downvotes=Coalesce(Abs(models.Sum("value", filter=models.Q(value=-1))), 0), + ) + + @staticmethod + def with_user_vote(user): + """Return replies annotated with the current user's vote.""" + queryset = Reply.objects.select_related("user").order_by("created") + if getattr(user, "is_authenticated", False): + user_vote_annotation = Coalesce( + Sum("votereply__value", filter=Q(votereply__user=user)), + Value(0), + ) + else: + user_vote_annotation = Value(0) + return queryset.annotate(user_vote=user_vote_annotation) + + def upvote(self, user): + """Create an upvote.""" + + # Check if already upvoted. + upvoted = VoteReply.objects.filter( + user=user, + reply=self, + value=1, + ).exists() + + # Delete all prior votes. + VoteReply.objects.filter( + user=user, + reply=self, + ).delete() + + # Don't upvote again if previously upvoted. + if upvoted: + return + + VoteReply.objects.create( + value=1, + user=user, + reply=self, + ) + + def downvote(self, user): + """Create a downvote.""" + + # Check if already downvoted. + downvoted = VoteReply.objects.filter( + user=user, + reply=self, + value=-1, + ).exists() + + # Delete all prior votes. + VoteReply.objects.filter( + user=user, + reply=self, + ).delete() + + # Don't downvote again if previously downvoted. + if downvoted: + return + + VoteReply.objects.create( + value=-1, + user=user, + reply=self, + ) + +class VoteReply(models.Model): + """VoteReply model. + Belongs to a User. + Has a reply. + """ + + # Vote value. Required. + value = models.IntegerField(choices=[(-1, 'Downvote'), (1, 'Upvote')]) + # Vote user foreign key. Required. + user = models.ForeignKey(User, on_delete=models.CASCADE) + # Vote review foreign key. Required. + reply = models.ForeignKey(Reply, on_delete=models.CASCADE) + + def __str__(self): + return f"Vote of value {self.value} for {self.reply} by {self.user}" + + class Meta: + indexes = [ + models.Index(fields=["reply"]), + ] + + constraints = [ + models.UniqueConstraint( + fields=["user", "reply"], + name="unique vote per user and reply", + ) + ] class Vote(models.Model): """Vote model. diff --git a/tcf_website/static/about/team-pfps/DM_Carissa_Chen.jpg b/tcf_website/static/about/alum-pfps/DM_Carissa_Chen.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/DM_Carissa_Chen.jpg rename to tcf_website/static/about/alum-pfps/DM_Carissa_Chen.jpg diff --git a/tcf_website/static/about/team-pfps/DM_Chai_Zhang.JPG b/tcf_website/static/about/alum-pfps/DM_Chai_Zhang.JPG similarity index 100% rename from tcf_website/static/about/team-pfps/DM_Chai_Zhang.JPG rename to tcf_website/static/about/alum-pfps/DM_Chai_Zhang.JPG diff --git a/tcf_website/static/about/team-pfps/ENG_AJ_Nye.jpg b/tcf_website/static/about/alum-pfps/ENG_AJ_Nye.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_AJ_Nye.jpg rename to tcf_website/static/about/alum-pfps/ENG_AJ_Nye.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Barrett_Ruth.webp b/tcf_website/static/about/alum-pfps/ENG_Barrett_Ruth.webp similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Barrett_Ruth.webp rename to tcf_website/static/about/alum-pfps/ENG_Barrett_Ruth.webp diff --git a/tcf_website/static/about/team-pfps/ENG_Collin_Togher.jpg b/tcf_website/static/about/alum-pfps/ENG_Collin_Togher.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Collin_Togher.jpg rename to tcf_website/static/about/alum-pfps/ENG_Collin_Togher.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Hari_Gajjala.JPG b/tcf_website/static/about/alum-pfps/ENG_Hari_Gajjala.JPG similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Hari_Gajjala.JPG rename to tcf_website/static/about/alum-pfps/ENG_Hari_Gajjala.JPG diff --git a/tcf_website/static/about/team-pfps/ENG_Ja-Zhua_Cheng.jpeg b/tcf_website/static/about/alum-pfps/ENG_Ja-Zhua_Cheng.jpeg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Ja-Zhua_Cheng.jpeg rename to tcf_website/static/about/alum-pfps/ENG_Ja-Zhua_Cheng.jpeg diff --git a/tcf_website/static/about/team-pfps/ENG_Junho_Lee.jpg b/tcf_website/static/about/alum-pfps/ENG_Junho_Lee.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Junho_Lee.jpg rename to tcf_website/static/about/alum-pfps/ENG_Junho_Lee.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Justin_Park.jpg b/tcf_website/static/about/alum-pfps/ENG_Justin_Park.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Justin_Park.jpg rename to tcf_website/static/about/alum-pfps/ENG_Justin_Park.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Kingsley_Kim.jpg b/tcf_website/static/about/alum-pfps/ENG_Kingsley_Kim.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Kingsley_Kim.jpg rename to tcf_website/static/about/alum-pfps/ENG_Kingsley_Kim.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Kyle_Durrer.jpg b/tcf_website/static/about/alum-pfps/ENG_Kyle_Durrer.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Kyle_Durrer.jpg rename to tcf_website/static/about/alum-pfps/ENG_Kyle_Durrer.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Neha_Bagalkot.jpg b/tcf_website/static/about/alum-pfps/ENG_Neha_Bagalkot.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Neha_Bagalkot.jpg rename to tcf_website/static/about/alum-pfps/ENG_Neha_Bagalkot.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Olivia_Seto.jpg b/tcf_website/static/about/alum-pfps/ENG_Olivia_Seto.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Olivia_Seto.jpg rename to tcf_website/static/about/alum-pfps/ENG_Olivia_Seto.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Vivian_Gao.jpg b/tcf_website/static/about/alum-pfps/ENG_Vivian_Gao.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Vivian_Gao.jpg rename to tcf_website/static/about/alum-pfps/ENG_Vivian_Gao.jpg diff --git a/tcf_website/static/about/team-pfps/ENG_Zohaib_Khalid.jpg b/tcf_website/static/about/alum-pfps/ENG_Zohaib_Khalid.jpg similarity index 100% rename from tcf_website/static/about/team-pfps/ENG_Zohaib_Khalid.jpg rename to tcf_website/static/about/alum-pfps/ENG_Zohaib_Khalid.jpg diff --git a/tcf_website/static/about/team-pfps/DM_Bree_Bonner.JPG b/tcf_website/static/about/team-pfps/DM_Bree_Bonner.JPG new file mode 100644 index 000000000..14428ed9a Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Bree_Bonner.JPG differ diff --git a/tcf_website/static/about/team-pfps/DM_Dylan_Klein.jpg b/tcf_website/static/about/team-pfps/DM_Dylan_Klein.jpg new file mode 100644 index 000000000..ede596c32 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Dylan_Klein.jpg differ diff --git a/tcf_website/static/about/team-pfps/DM_Ivy_Kim.jpg b/tcf_website/static/about/team-pfps/DM_Ivy_Kim.jpg new file mode 100644 index 000000000..7d022ceb0 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Ivy_Kim.jpg differ diff --git a/tcf_website/static/about/team-pfps/DM_Kate_Dunkle.jpg b/tcf_website/static/about/team-pfps/DM_Kate_Dunkle.jpg new file mode 100644 index 000000000..32db21462 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Kate_Dunkle.jpg differ diff --git a/tcf_website/static/about/team-pfps/DM_Kelly_Tran.jpg b/tcf_website/static/about/team-pfps/DM_Kelly_Tran.jpg new file mode 100644 index 000000000..a5638573b Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Kelly_Tran.jpg differ diff --git a/tcf_website/static/about/team-pfps/DM_Kelly_Yue.JPG b/tcf_website/static/about/team-pfps/DM_Kelly_Yue.JPG new file mode 100644 index 000000000..947acd633 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Kelly_Yue.JPG differ diff --git a/tcf_website/static/about/team-pfps/DM_Maya_Degafe.png b/tcf_website/static/about/team-pfps/DM_Maya_Degafe.png new file mode 100644 index 000000000..8c8b956a9 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Maya_Degafe.png differ diff --git a/tcf_website/static/about/team-pfps/DM_Tiffany_Park.jpg b/tcf_website/static/about/team-pfps/DM_Tiffany_Park.jpg new file mode 100644 index 000000000..47f2fdb31 Binary files /dev/null and b/tcf_website/static/about/team-pfps/DM_Tiffany_Park.jpg differ diff --git a/tcf_website/static/about/team-pfps/ENG_Blaire_Zhao.jpg b/tcf_website/static/about/team-pfps/ENG_Blaire_Zhao.jpg index b9cf58ed4..facdeb1f8 100644 Binary files a/tcf_website/static/about/team-pfps/ENG_Blaire_Zhao.jpg and b/tcf_website/static/about/team-pfps/ENG_Blaire_Zhao.jpg differ diff --git a/tcf_website/static/about/team-pfps/ENG_Julia_Chen.jpg b/tcf_website/static/about/team-pfps/ENG_Julia_Chen.jpg new file mode 100644 index 000000000..0728d6308 Binary files /dev/null and b/tcf_website/static/about/team-pfps/ENG_Julia_Chen.jpg differ diff --git a/tcf_website/static/about/team-pfps/ENG_Shrikar_Sirobhushanam.jpg b/tcf_website/static/about/team-pfps/ENG_Shrikar_Sirobhushanam.jpg new file mode 100644 index 000000000..d889d48d6 Binary files /dev/null and b/tcf_website/static/about/team-pfps/ENG_Shrikar_Sirobhushanam.jpg differ diff --git a/tcf_website/static/about/team-pfps/MAD_Rachita_Pathipati.jpg b/tcf_website/static/about/team-pfps/MAD_Rachita_Pathipati.jpg deleted file mode 100644 index 2939e3a11..000000000 Binary files a/tcf_website/static/about/team-pfps/MAD_Rachita_Pathipati.jpg and /dev/null differ diff --git a/tcf_website/static/about/team-pfps/t2084.jpeg b/tcf_website/static/about/team-pfps/t2084.jpeg deleted file mode 100644 index a7d3a54f9..000000000 Binary files a/tcf_website/static/about/team-pfps/t2084.jpeg and /dev/null differ diff --git a/tcf_website/static/reviews/reply.js b/tcf_website/static/reviews/reply.js new file mode 100644 index 000000000..4c289705d --- /dev/null +++ b/tcf_website/static/reviews/reply.js @@ -0,0 +1,58 @@ +/* For reply upvote/downvote functionality */ +function handleReplyVote(replyID, isUpvote) { + const upvoteCountElem = $(`#reply${replyID} .reply-upvote-count`); + const downvoteCountElem = $(`#reply${replyID} .reply-downvote-count`); + const upvoteCount = parseInt(upvoteCountElem.text()); + const downvoteCount = parseInt(downvoteCountElem.text()); + + let elem; + let otherElem; + let endpoint; + let newUpvoteCount = upvoteCount; + let newDownvoteCount = downvoteCount; + + if (isUpvote) { + elem = $(`#reply${replyID} .reply-upvote`); + otherElem = $(`#reply${replyID} .reply-downvote`); + endpoint = `/reviews/reply/${replyID}/upvote/`; + + if (elem.hasClass("active")) { + newUpvoteCount = upvoteCount - 1; + } else if (otherElem.hasClass("active")) { + newUpvoteCount = upvoteCount + 1; + newDownvoteCount = downvoteCount - 1; + } else { + newUpvoteCount = upvoteCount + 1; + } + } else { + elem = $(`#reply${replyID} .reply-downvote`); + otherElem = $(`#reply${replyID} .reply-upvote`); + endpoint = `/reviews/reply/${replyID}/downvote/`; + + if (elem.hasClass("active")) { + newDownvoteCount = downvoteCount - 1; + } else if (otherElem.hasClass("active")) { + newDownvoteCount = downvoteCount + 1; + newUpvoteCount = upvoteCount - 1; + } else { + newDownvoteCount = downvoteCount + 1; + } + } + + fetch(endpoint, { + method: "post", + headers: { "X-CSRFToken": getCookie("csrftoken") }, + }); + + upvoteCountElem.text(newUpvoteCount); + downvoteCountElem.text(newDownvoteCount); + + if (elem.hasClass("active")) { + elem.removeClass("active"); + } else { + elem.addClass("active"); + otherElem.removeClass("active"); + } +} + +export { handleReplyVote }; diff --git a/tcf_website/static/reviews/review.css b/tcf_website/static/reviews/review.css index 43566f1bd..275cc9fb4 100644 --- a/tcf_website/static/reviews/review.css +++ b/tcf_website/static/reviews/review.css @@ -41,3 +41,11 @@ .tooltip-inner { margin: 0; } + +.longtext { + min-width:0; + white-space:normal; + word-break:break-word; + overflow-wrap:break-word; + word-wrap:break-word; +} diff --git a/tcf_website/templates/common/toolbar.html b/tcf_website/templates/common/toolbar.html index 1930017eb..42d7bdde4 100644 --- a/tcf_website/templates/common/toolbar.html +++ b/tcf_website/templates/common/toolbar.html @@ -36,7 +36,7 @@