├── .dockerignore ├── .github ├── cdk │ ├── .gitignore │ ├── cdkactions.yaml │ ├── main.ts │ ├── package.json │ ├── tsconfig.json │ └── yarn.lock └── workflows │ ├── cdkactions_build-and-deploy.yaml │ └── codeql-analysis.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── backend ├── Dockerfile ├── Dockerfile.dev ├── Pipfile ├── Pipfile.lock ├── Platform │ ├── __init__.py │ ├── middleware.py │ ├── settings │ │ ├── __init__.py │ │ ├── base.py │ │ ├── ci.py │ │ ├── development.py │ │ ├── production.py │ │ └── staging.py │ ├── templates │ │ ├── emails │ │ │ ├── base.html │ │ │ └── email_verification.html │ │ └── redoc.html │ ├── urls.py │ └── wsgi.py ├── README.md ├── accounts │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── backends.py │ ├── data │ │ └── users.json │ ├── management │ │ └── commands │ │ │ ├── populate_users.py │ │ │ └── update_academics.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200213_1711.py │ │ ├── 0003_auto_20210918_2041.py │ │ ├── 0004_user_profile_pic.py │ │ ├── 0005_privacyresource_privacysetting.py │ │ ├── 0006_alter_major_name.py │ │ └── __init__.py │ ├── mixins.py │ ├── models.py │ ├── oauth2_validator.py │ ├── serializers.py │ ├── static │ │ └── js │ │ │ └── devlogin.js │ ├── templates │ │ └── accounts │ │ │ └── devlogin.html │ ├── update_majors.py │ ├── update_schools.py │ ├── urls.py │ ├── verification.py │ └── views.py ├── announcements │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── management │ │ └── commands │ │ │ └── populate_audiences.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── permissions.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── docker │ ├── mime.types │ ├── nginx-default.conf │ ├── platform-run │ ├── shib_clear_headers │ ├── shibboleth │ │ ├── attribute-map.xml │ │ ├── metadata.xml │ │ └── shibboleth2.xml │ └── supervisord.conf ├── health │ ├── __init__.py │ ├── apps.py │ ├── urls.py │ └── views.py ├── identity │ ├── __init__.py │ ├── apps.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── manage.py ├── setup.cfg └── tests │ ├── __init__.py │ ├── accounts │ ├── PennCoursePrograms.html │ ├── __init__.py │ ├── test_admin.py │ ├── test_backends.py │ ├── test_commands.py │ ├── test_models.py │ ├── test_pfp.jpg │ ├── test_pfp_large.png │ ├── test_serializers.py │ ├── test_update_majors.py │ ├── test_update_schools.py │ ├── test_verification.py │ └── test_views.py │ ├── announcements │ ├── test_models.py │ ├── test_populate_audiences.py │ ├── test_serializers.py │ └── test_views.py │ └── identity │ ├── __init__.py │ └── test_views.py ├── frontend ├── .babelrc ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── README.md ├── components │ ├── accounts │ │ ├── forms │ │ │ ├── contact-info-form.tsx │ │ │ ├── contact-input.tsx │ │ │ ├── generic-info-form.tsx │ │ │ └── multi-select.tsx │ │ ├── index.tsx │ │ ├── modals │ │ │ ├── delete.tsx │ │ │ └── verification.tsx │ │ └── ui.ts │ └── useOnClickOutside.ts ├── data-fetching │ └── accounts.ts ├── next-env.d.ts ├── package.json ├── pages │ ├── _app.js │ ├── _document.tsx │ ├── api │ │ └── hello.js │ ├── health.tsx │ └── index.tsx ├── public │ ├── beaker.png │ ├── favicon.ico │ ├── greentick.png │ ├── more.svg │ ├── vercel.svg │ └── x-circle.svg ├── server.js ├── styles │ ├── Home.module.css │ ├── Verification.module.css │ └── globals.css ├── tsconfig.json ├── types.ts ├── utils │ ├── auth.tsx │ ├── csrf.tsx │ ├── fetch.tsx │ └── sentry.tsx └── yarn.lock └── k8s ├── .gitignore ├── cdk8s.yaml ├── main.ts ├── package.json ├── tsconfig.json └── yarn.lock /.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 | -------------------------------------------------------------------------------- /.github/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | main.js 3 | main.d.ts 4 | -------------------------------------------------------------------------------- /.github/cdk/cdkactions.yaml: -------------------------------------------------------------------------------- 1 | language: typescript 2 | app: node main.js 3 | -------------------------------------------------------------------------------- /.github/cdk/main.ts: -------------------------------------------------------------------------------- 1 | import { App, Stack, Workflow } from "cdkactions"; 2 | import { DeployJob, DjangoProject, DockerPublishJob, ReactProject } from "@pennlabs/kraken"; 3 | import { Construct } from "constructs"; 4 | 5 | const app = new App(); 6 | class PlatformStack extends Stack { 7 | public constructor(scope: Construct, name: string) { 8 | super(scope, name); 9 | 10 | const workflow = new Workflow(this, "build-and-deploy", { 11 | name: "Build and Deploy", 12 | on: "push", 13 | }); 14 | 15 | const backend = new DjangoProject(workflow, { 16 | projectName: "Platform", 17 | path: "backend", 18 | imageName: "platform-backend", 19 | }); 20 | 21 | const publishPlatformDev = new DockerPublishJob(workflow, 'platform-dev', { 22 | imageName: "platform-dev", 23 | path: "backend", 24 | dockerfile: "Dockerfile.dev" 25 | }, 26 | { 27 | needs: "django-check" 28 | }); 29 | 30 | const frontend = new ReactProject(workflow, { 31 | path: "frontend", 32 | imageName: "platform-frontend", 33 | }); 34 | 35 | new DeployJob( 36 | workflow, 37 | {}, 38 | { 39 | needs: [ 40 | backend.publishJobId, 41 | frontend.publishJobId, 42 | publishPlatformDev.id, 43 | ], 44 | } 45 | ); 46 | 47 | } 48 | } 49 | 50 | new PlatformStack(app, 'platform') 51 | app.synth(); 52 | -------------------------------------------------------------------------------- /.github/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "main": "main.js", 5 | "types": "main.ts", 6 | "license": "Apache-2.0", 7 | "private": true, 8 | "scripts": { 9 | "synth": "cdkactions synth", 10 | "compile": "tsc", 11 | "watch": "tsc -w", 12 | "build": "yarn compile && yarn synth", 13 | "upgrade-cdk": "yarn upgrade cdkactions@latest cdkactions-cli@latest" 14 | }, 15 | "dependencies": { 16 | "@pennlabs/kraken": "^0.8.6", 17 | "cdkactions": "^0.2.3", 18 | "constructs": "^3.2.109" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^17.0.23", 22 | "cdkactions-cli": "^0.2.3", 23 | "typescript": "^4.6.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "charset": "utf8", 5 | "declaration": true, 6 | "experimentalDecorators": true, 7 | "inlineSourceMap": true, 8 | "inlineSources": true, 9 | "lib": [ 10 | "es2018" 11 | ], 12 | "module": "CommonJS", 13 | "noEmitOnError": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitAny": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "resolveJsonModule": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "strictPropertyInitialization": true, 24 | "stripInternal": true, 25 | "target": "ES2018" 26 | }, 27 | "include": [ 28 | "**/*.ts" 29 | ], 30 | "exclude": [ 31 | "node_modules" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/cdkactions_build-and-deploy.yaml: -------------------------------------------------------------------------------- 1 | # Generated by cdkactions. Do not modify 2 | # Generated as part of the 'platform' stack. 3 | name: Build and Deploy 4 | on: push 5 | jobs: 6 | django-check: 7 | name: Django Check 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Cache 12 | uses: actions/cache@v2 13 | with: 14 | path: ~/.local/share/virtualenvs 15 | key: v0-${{ hashFiles('backend/Pipfile.lock') }} 16 | - name: Install Dependencies 17 | run: |- 18 | cd backend 19 | pip install pipenv 20 | pipenv install --deploy --dev 21 | - name: Lint (flake8) 22 | run: |- 23 | cd backend 24 | pipenv run flake8 . 25 | - name: Lint (black) 26 | run: |- 27 | cd backend 28 | pipenv run black --check . 29 | - name: Test (run in parallel) 30 | run: |- 31 | cd backend 32 | pipenv run python -m coverage run --concurrency=multiprocessing manage.py test --settings=Platform.settings.ci --parallel 33 | pipenv run python -m coverage combine 34 | container: 35 | image: python:3.10-buster 36 | env: 37 | DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres 38 | services: 39 | postgres: 40 | image: postgres:12 41 | env: 42 | POSTGRES_USER: postgres 43 | POSTGRES_DB: postgres 44 | POSTGRES_PASSWORD: postgres 45 | options: "--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5" 46 | publish-backend: 47 | name: Publish backend 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v2 51 | - uses: docker/setup-qemu-action@v1 52 | - uses: docker/setup-buildx-action@v1 53 | - name: Cache Docker layers 54 | uses: actions/cache@v2 55 | with: 56 | path: /tmp/.buildx-cache 57 | key: buildx-publish-backend 58 | - uses: docker/login-action@v1 59 | with: 60 | username: ${{ secrets.DOCKER_USERNAME }} 61 | password: ${{ secrets.DOCKER_PASSWORD }} 62 | - name: Build/Publish 63 | uses: docker/build-push-action@v2 64 | with: 65 | context: backend 66 | file: backend/Dockerfile 67 | push: ${{ github.ref == 'refs/heads/master' }} 68 | cache-from: type=local,src=/tmp/.buildx-cache,type=registry,ref=pennlabs/platform-backend:latest 69 | cache-to: type=local,dest=/tmp/.buildx-cache 70 | tags: pennlabs/platform-backend:latest,pennlabs/platform-backend:${{ github.sha }} 71 | needs: django-check 72 | publish-platform-dev: 73 | name: Publish platform-dev 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@v2 77 | - uses: docker/setup-qemu-action@v1 78 | - uses: docker/setup-buildx-action@v1 79 | - name: Cache Docker layers 80 | uses: actions/cache@v2 81 | with: 82 | path: /tmp/.buildx-cache 83 | key: buildx-publish-platform-dev 84 | - uses: docker/login-action@v1 85 | with: 86 | username: ${{ secrets.DOCKER_USERNAME }} 87 | password: ${{ secrets.DOCKER_PASSWORD }} 88 | - name: Build/Publish 89 | uses: docker/build-push-action@v2 90 | with: 91 | context: backend 92 | file: backend/Dockerfile.dev 93 | push: ${{ github.ref == 'refs/heads/master' }} 94 | cache-from: type=local,src=/tmp/.buildx-cache,type=registry,ref=pennlabs/platform-dev:latest 95 | cache-to: type=local,dest=/tmp/.buildx-cache 96 | tags: pennlabs/platform-dev:latest,pennlabs/platform-dev:${{ github.sha }} 97 | needs: django-check 98 | react-check: 99 | name: React Check 100 | runs-on: ubuntu-latest 101 | steps: 102 | - uses: actions/checkout@v2 103 | - name: Cache 104 | uses: actions/cache@v2 105 | with: 106 | path: "**/node_modules" 107 | key: v0-${{ hashFiles('frontend/yarn.lock') }} 108 | - name: Install Dependencies 109 | run: |- 110 | cd frontend 111 | yarn install --frozen-lockfile 112 | - name: Lint 113 | run: |- 114 | cd frontend 115 | yarn lint 116 | - name: Test 117 | run: |- 118 | cd frontend 119 | yarn test 120 | - name: Upload Code Coverage 121 | run: |- 122 | ROOT=$(pwd) 123 | cd frontend 124 | yarn run codecov -p $ROOT -F frontend 125 | container: 126 | image: node:14 127 | publish-frontend: 128 | name: Publish frontend 129 | runs-on: ubuntu-latest 130 | steps: 131 | - uses: actions/checkout@v2 132 | - uses: docker/setup-qemu-action@v1 133 | - uses: docker/setup-buildx-action@v1 134 | - name: Cache Docker layers 135 | uses: actions/cache@v2 136 | with: 137 | path: /tmp/.buildx-cache 138 | key: buildx-publish-frontend 139 | - uses: docker/login-action@v1 140 | with: 141 | username: ${{ secrets.DOCKER_USERNAME }} 142 | password: ${{ secrets.DOCKER_PASSWORD }} 143 | - name: Build/Publish 144 | uses: docker/build-push-action@v2 145 | with: 146 | context: frontend 147 | file: frontend/Dockerfile 148 | push: ${{ github.ref == 'refs/heads/master' }} 149 | cache-from: type=local,src=/tmp/.buildx-cache,type=registry,ref=pennlabs/platform-frontend:latest 150 | cache-to: type=local,dest=/tmp/.buildx-cache 151 | tags: pennlabs/platform-frontend:latest,pennlabs/platform-frontend:${{ github.sha }} 152 | needs: react-check 153 | deploy: 154 | runs-on: ubuntu-latest 155 | if: github.ref == 'refs/heads/master' 156 | steps: 157 | - uses: actions/checkout@v2 158 | - id: synth 159 | name: Synth cdk8s manifests 160 | run: |- 161 | cd k8s 162 | yarn install --frozen-lockfile 163 | 164 | # get repo name (by removing owner/organization) 165 | export RELEASE_NAME=${REPOSITORY#*/} 166 | 167 | # Export RELEASE_NAME as an output 168 | echo "::set-output name=RELEASE_NAME::$RELEASE_NAME" 169 | 170 | yarn build 171 | env: 172 | GIT_SHA: ${{ github.sha }} 173 | REPOSITORY: ${{ github.repository }} 174 | AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} 175 | - name: Deploy 176 | run: |- 177 | aws eks --region us-east-1 update-kubeconfig --name production --role-arn arn:aws:iam::${AWS_ACCOUNT_ID}:role/kubectl 178 | 179 | # get repo name from synth step 180 | RELEASE_NAME=${{ steps.synth.outputs.RELEASE_NAME }} 181 | 182 | # Deploy 183 | kubectl apply -f k8s/dist/ --prune -l app.kubernetes.io/part-of=$RELEASE_NAME 184 | env: 185 | AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} 186 | AWS_ACCESS_KEY_ID: ${{ secrets.GH_AWS_ACCESS_KEY_ID }} 187 | AWS_SECRET_ACCESS_KEY: ${{ secrets.GH_AWS_SECRET_ACCESS_KEY }} 188 | needs: 189 | - publish-backend 190 | - publish-frontend 191 | - publish-platform-dev 192 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "Code scanning - action" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 16 * * 6' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v2 17 | with: 18 | # We must fetch at least the immediate parents so that if this is 19 | # a pull request then we can checkout the head. 20 | fetch-depth: 2 21 | 22 | # If this run was triggered by a pull request event, then checkout 23 | # the head of the pull request instead of the merge commit. 24 | - run: git checkout HEAD^2 25 | if: ${{ github.event_name == 'pull_request' }} 26 | 27 | # Initializes the CodeQL tools for scanning. 28 | - name: Initialize CodeQL 29 | uses: github/codeql-action/init@v1 30 | # Override language selection by uncommenting this and choosing your languages 31 | # with: 32 | # languages: go, javascript, csharp, python, cpp, java 33 | 34 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 35 | # If this step fails, then you should remove it and run the build manually (see below) 36 | - name: Autobuild 37 | uses: github/codeql-action/autobuild@v1 38 | 39 | # ℹ️ Command-line programs to run using the OS shell. 40 | # 📚 https://git.io/JvXDl 41 | 42 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 43 | # and modify them (or add more) to build your code if your project 44 | # uses a compiled language 45 | 46 | #- run: | 47 | # make bootstrap 48 | # make release 49 | 50 | - name: Perform CodeQL Analysis 51 | uses: github/codeql-action/analyze@v1 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .vscode/ 3 | .idea/ 4 | 5 | # Python files 6 | __pycache__/ 7 | *.pyc 8 | 9 | # Distribution 10 | build/ 11 | dist/ 12 | *.egg-info/ 13 | 14 | # Code testing/coverage 15 | .tox 16 | test-results/ 17 | .coverage 18 | htmlcov/ 19 | 20 | # Test database 21 | db.sqlite3 22 | 23 | # Mac 24 | .DS_Store 25 | 26 | # Docker 27 | docker-compose.yml 28 | 29 | # Uploaded media files 30 | backend/accounts/mediafiles 31 | 32 | .env -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pennlabs/pre-commit-hooks 3 | rev: stable 4 | hooks: 5 | - id: black 6 | - id: isort 7 | - id: flake8 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Platform 2 | 3 | [![CircleCI](https://circleci.com/gh/pennlabs/platform.svg?style=shield)](https://circleci.com/gh/pennlabs/platform) 4 | [![Coverage Status](https://codecov.io/gh/pennlabs/platform/branch/master/graph/badge.svg)](https://codecov.io/gh/pennlabs/platform) 5 | 6 | The Labs Platform is the back-end interface to the ecosystem that facilitates the organization's: 7 | 8 | 1. Accounts Engine 9 | 2. Cross-Product Resources 10 | 3. Organizational Information 11 | 12 | ## Installation 13 | 14 | 0. Configure environment variables (e.g. `.env`) containing: 15 | 16 | ```bash 17 | DATABASE_URL=mysql://USER:PASSWORD@HOST:PORT/NAME 18 | SECRET_KEY=secret 19 | DJANGO_SETTINGS_MODULE=Platform.settings.production 20 | SENTRY_URL=https://pub@sentry.example.com/product 21 | AWS_ACCESS_KEY_ID 22 | AWS_SECRET_ACCESS_KEY 23 | AWS_STORAGE_BUCKET_NAME 24 | ``` 25 | 26 | 1. Run using docker: `docker run -d pennlabs/platform` 27 | 28 | ## Documentation 29 | 30 | Routes are defined in `/pennlabs/urls.py` and subsequent app folders in the form of `*/urls.py`. Account/authorization related scripts are located in `accounts/` and Penn Labs related scripts are located in `org/`. 31 | 32 | Documentation about individual endpoints is available through the `documentation/` route when the Django app is running. 33 | 34 | ## Installation 35 | You will need to start both the backend and the frontend to do Platform development. 36 | 37 | ### Backend 38 | 39 | Running the backend requires [Python 3](https://www.python.org/downloads/). 40 | 41 | To run the server, `cd` to the folder where you cloned `platform`. Then run: 42 | - `cd backend` 43 | 44 | Setting up `psycopg2` (this is necessary if you want to be able to modify 45 | dependencies, you can revisit later if not) 46 | 47 | - Mac 48 | - `$ brew install postgresql` 49 | - `$ brew install openssl` 50 | - `$ brew unlink openssl && brew link openssl --force` 51 | - `$ echo 'export PATH="/usr/local/opt/openssl@3/bin:$PATH"' >> ~/.zshrc` 52 | - `$ export LDFLAGS="-L/usr/local/opt/openssl@3/lib"` 53 | - `$ export CPPFLAGS="-I/usr/local/opt/openssl@3/include"` 54 | - Windows 55 | - `$ apt-get install gcc python3-dev libpq-dev` 56 | 57 | Now, you can run 58 | 59 | - `$ pipenv install` to install Python dependencies. This may take a few 60 | minutes. Optionally include the `--dev` argument if you are installing locally 61 | for development. If you skipped installing `psycopg2` earlier, you might see 62 | an error with locking -- this is expected! 63 | - `$ pipenv shell` 64 | - `$ ./manage.py migrate` OR `$ python3 manage.py migrate` 65 | - `$ ./manage.py populate_users` OR `$ python3 manage.py populate_users` (in development, 66 | to populate the database with dummy data) 67 | - `$ ./manage.py runserver` OR `$ python3 manage.py runserver` 68 | 69 | ### Frontend 70 | 71 | Running the frontend requires [Node.js](https://nodejs.org/en/) and [Yarn](https://yarnpkg.com/getting-started/install). 72 | 73 | 1. Enter the `frontend` directory with a **new terminal window**. Don't kill your backend server! 74 | 2. Install dependencies using `yarn install` in the project directory. 75 | 3. Run application using `yarn dev`. 76 | 4. Access application at [http://localhost:3000](http://localhost:3000). 77 | 78 | ### Development 79 | 80 | Click `Login` to log in as a test user. The `./manage.py populate_users` command creates a test user for you with username `bfranklin` and password `test`. Go to `/api/admin` to login to this account. 81 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pennlabs/shibboleth-sp-nginx:3.0.4 2 | 3 | LABEL maintainer="Penn Labs" 4 | 5 | ENV LC_ALL C.UTF-8 6 | ENV LANG C.UTF-8 7 | 8 | WORKDIR /app/ 9 | 10 | # Update PGP key for NGINX 11 | # https://blog.nginx.org/blog/updating-pgp-key-for-nginx-software 12 | RUN wget -O/etc/apt/trusted.gpg.d/nginx.asc https://nginx.org/keys/nginx_signing.key 13 | 14 | # Install dependencies 15 | RUN apt-get update \ 16 | && apt-get install --no-install-recommends -y python3.11-dev pipenv python3-distutils libpq-dev gcc \ 17 | && apt-get clean \ 18 | && rm -rf /var/lib/apt/lists/* 19 | 20 | # Copy config files 21 | COPY docker/shibboleth/ /etc/shibboleth/ 22 | COPY docker/nginx-default.conf /etc/nginx/conf.d/default.conf 23 | COPY docker/shib_clear_headers /etc/nginx/ 24 | COPY docker/supervisord.conf /etc/supervisor/ 25 | 26 | # Copy project dependencies 27 | COPY Pipfile* /app/ 28 | 29 | # Install project dependencies 30 | RUN pipenv install --deploy --system 31 | 32 | # Copy project files 33 | COPY . /app/ 34 | 35 | ENV DJANGO_SETTINGS_MODULE Platform.settings.production 36 | ENV SECRET_KEY 'temporary key just to build the docker image' 37 | ENV IDENTITY_RSA_PRIVATE_KEY 'temporary private key just to build the docker image' 38 | ENV OIDC_RSA_PRIVATE_KEY 'temporary private key just to build the docker image' 39 | 40 | # Collect static files 41 | RUN python3 /app/manage.py collectstatic --noinput 42 | 43 | # Copy mime definitions 44 | COPY docker/mime.types /etc/mime.types 45 | 46 | # Copy start script 47 | COPY docker/platform-run /usr/local/bin/ 48 | 49 | CMD ["platform-run"] 50 | -------------------------------------------------------------------------------- /backend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM pennlabs/django-base:b269ea1613686b1ac6370154debbb741b012de1a-3.11 2 | 3 | LABEL maintainer="Penn Labs" 4 | 5 | # Copy project dependencies 6 | COPY Pipfile* /app/ 7 | 8 | # Install project dependencies 9 | RUN pipenv install --system 10 | 11 | # Copy project files 12 | COPY . /app/ 13 | 14 | ENV DJANGO_SETTINGS_MODULE Platform.settings.staging 15 | ENV SECRET_KEY 'temporary key just to build the docker image' 16 | 17 | # Collect static files 18 | RUN python3 /app/manage.py collectstatic --noinput -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | black = "==23.10.1" 8 | unittest-xml-reporting = "*" 9 | flake8 = "*" 10 | flake8-isort = "*" 11 | isort = "<5" 12 | flake8-quotes = "*" 13 | django-debug-toolbar = "*" 14 | django-extensions = "*" 15 | flake8-absolute-import = "*" 16 | tblib = "*" 17 | coverage = "*" 18 | 19 | [packages] 20 | dj-database-url = "*" 21 | djangorestframework = "*" 22 | djangorestframework-api-key = "*" 23 | psycopg2 = "*" 24 | sentry-sdk = "*" 25 | django-oauth-toolkit = "*" 26 | django-cors-headers = "*" 27 | django = "*" 28 | pyyaml = "*" 29 | uwsgi = "*" 30 | uritemplate = "*" 31 | shortener = "*" 32 | django-runtime-options = "*" 33 | jwcrypto = "*" 34 | django-phonenumber-field = {extras = ["phonenumbers"],version = "*"} 35 | django-email-tools = "*" 36 | twilio = "*" 37 | lxml = "*" 38 | requests = "*" 39 | pillow = "*" 40 | boto3 = "*" 41 | django-storages = "*" 42 | 43 | [requires] 44 | python_version = "3" 45 | -------------------------------------------------------------------------------- /backend/Platform/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/Platform/__init__.py -------------------------------------------------------------------------------- /backend/Platform/middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | class HealthCheckMiddleware: 5 | def __init__(self, get_response): 6 | self.get_response = get_response 7 | 8 | def __call__(self, request): 9 | if request.path == "/health": 10 | return HttpResponse("ok") 11 | return self.get_response(request) 12 | -------------------------------------------------------------------------------- /backend/Platform/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/Platform/settings/__init__.py -------------------------------------------------------------------------------- /backend/Platform/settings/ci.py: -------------------------------------------------------------------------------- 1 | from Platform.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/Platform/settings/development.py: -------------------------------------------------------------------------------- 1 | from Platform.settings.base import * # noqa 2 | from Platform.settings.base import INSTALLED_APPS, MIDDLEWARE 3 | 4 | 5 | # Development extensions 6 | INSTALLED_APPS += ["django_extensions", "debug_toolbar"] 7 | 8 | MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE 9 | INTERNAL_IPS = ["127.0.0.1"] 10 | 11 | # Disable admin login through shibboleth 12 | SHIB_ADMIN = False 13 | 14 | # Use the console for email in development 15 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 16 | -------------------------------------------------------------------------------- /backend/Platform/settings/production.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import sentry_sdk 4 | from django.core.exceptions import ImproperlyConfigured 5 | from sentry_sdk.integrations.django import DjangoIntegration 6 | 7 | from Platform.settings.base import * # noqa 8 | from Platform.settings.base import DOMAINS 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 | # Make sure IDENTITY_RSA_PRIVATE_KEY is set to a secret in production 23 | IDENTITY_RSA_PRIVATE_KEY = os.environ.get("IDENTITY_RSA_PRIVATE_KEY", None) 24 | if IDENTITY_RSA_PRIVATE_KEY is None: 25 | raise ImproperlyConfigured( 26 | "Please provide environment variable IDENTITY_RSA_PRIVATE_KEY in production" 27 | ) 28 | 29 | 30 | OIDC_RSA_PRIVATE_KEY = os.environ.get("OIDC_RSA_PRIVATE_KEY", None) 31 | if OIDC_RSA_PRIVATE_KEY is None: 32 | raise ImproperlyConfigured( 33 | "Please provide environment variable OIDC_RSA_PRIVATE_KEY in production" 34 | ) 35 | 36 | # Sentry settings 37 | SENTRY_URL = os.environ.get("SENTRY_URL", "") 38 | sentry_sdk.init(dsn=SENTRY_URL, integrations=[DjangoIntegration()]) 39 | 40 | # CORS settings 41 | CORS_ALLOW_ALL_ORIGINS = True 42 | CORS_ALLOW_METHODS = ["GET", "POST"] 43 | CORS_URLS_REGEX = r"^(/options/)|(/accounts/token/)$" 44 | 45 | # Email client settings 46 | EMAIL_HOST = os.getenv("SMTP_HOST") 47 | EMAIL_PORT = int(os.getenv("SMTP_PORT", 587)) 48 | EMAIL_HOST_USER = os.getenv("SMTP_USERNAME") 49 | EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD") 50 | EMAIL_USE_TLS = True 51 | 52 | IS_DEV_LOGIN = os.environ.get("DEV_LOGIN", "False") in ["True", "TRUE", "true"] 53 | 54 | # AWS S3 55 | DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" 56 | AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") 57 | AWS_ACCESS_SECRET_ID = os.getenv("AWS_SECRET_ACCESS_KEY") 58 | AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") 59 | AWS_QUERYSTRING_AUTH = False 60 | -------------------------------------------------------------------------------- /backend/Platform/settings/staging.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import sentry_sdk 4 | from sentry_sdk.integrations.django import DjangoIntegration 5 | 6 | from Platform.settings.base import * # noqa 7 | from Platform.settings.base import DOMAINS 8 | 9 | 10 | DEBUG = False 11 | 12 | # Honour the 'X-Forwarded-Proto' header for request.is_secure() 13 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 14 | 15 | # Allow production host headers 16 | ALLOWED_HOSTS = DOMAINS 17 | 18 | # SECRET_KEY = os.environ.get("SECRET_KEY", None) 19 | 20 | # IDENTITY_RSA_PRIVATE_KEY = os.environ.get("IDENTITY_RSA_PRIVATE_KEY", None) 21 | 22 | # OIDC_RSA_PRIVATE_KEY = os.environ.get("OIDC_RSA_PRIVATE_KEY", None) 23 | 24 | # Sentry settings 25 | SENTRY_URL = os.environ.get("SENTRY_URL", "") 26 | sentry_sdk.init(dsn=SENTRY_URL, integrations=[DjangoIntegration()]) 27 | 28 | # CORS settings 29 | CORS_ALLOW_ALL_ORIGINS = True 30 | CORS_ALLOW_METHODS = ["GET", "POST"] 31 | CORS_URLS_REGEX = r"^(/options/)|(/accounts/token/)$" 32 | 33 | # Email client settings 34 | EMAIL_HOST = os.getenv("SMTP_HOST") 35 | EMAIL_PORT = int(os.getenv("SMTP_PORT", 587)) 36 | EMAIL_HOST_USER = os.getenv("SMTP_USERNAME") 37 | EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD") 38 | EMAIL_USE_TLS = True 39 | 40 | IS_DEV_LOGIN = os.environ.get("DEV_LOGIN", "False") in ["True", "TRUE", "true"] 41 | 42 | # AWS S3 43 | DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" 44 | AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") 45 | AWS_ACCESS_SECRET_ID = os.getenv("AWS_SECRET_ACCESS_KEY") 46 | AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") 47 | AWS_QUERYSTRING_AUTH = False 48 | -------------------------------------------------------------------------------- /backend/Platform/templates/emails/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 59 | 60 | 61 | 62 | 63 | 65 | 66 | 67 |
68 | {% block content %}{% endblock %} 69 |
70 | 75 | 76 | -------------------------------------------------------------------------------- /backend/Platform/templates/emails/email_verification.html: -------------------------------------------------------------------------------- 1 | {% extends 'emails/base.html' %} 2 | 3 | {% block content %} 4 | 5 |

Hey {{ first_name }}!

6 | 7 |

Your Penn Labs verification code is:

8 | 9 |
10 | {% for num in verification_code %} 11 | 12 | {{ num }} 13 | 14 | {% endfor %} 15 |
16 | 17 |

Do not share this verification code with anyone.

18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /backend/Platform/templates/redoc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ReDoc 5 | 6 | 7 | 8 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /backend/Platform/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 rest_framework.schemas import get_schema_view 6 | 7 | 8 | admin.site.site_header = "Platform Admin" 9 | 10 | urlpatterns = [ 11 | path("admin/", admin.site.urls), 12 | path("announcements/", include("announcements.urls", namespace="announcements")), 13 | path("accounts/", include("accounts.urls", namespace="oauth2_provider")), 14 | path("options/", include("options.urls", namespace="options")), 15 | path("identity/", include("identity.urls", namespace="identity")), 16 | path("healthcheck/", include("health.urls", namespace="healthcheck")), 17 | path("s/", include("shortener.urls", namespace="shortener")), 18 | path( 19 | "openapi/", 20 | get_schema_view(title="Platform Documentation", public=True), 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 | if settings.DEBUG: # pragma: no cover 33 | import debug_toolbar 34 | 35 | urlpatterns = [ 36 | path("__debug__/", include(debug_toolbar.urls)), 37 | path("emailpreview/", include("email_tools.urls", namespace="email_tools")), 38 | ] + urlpatterns 39 | -------------------------------------------------------------------------------- /backend/Platform/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for pennlabs 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/2.1/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", "Platform.settings.production") 16 | 17 | application = get_wsgi_application() 18 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ## Runbook 2 | This section is to collect thoughts/learnings from the codebase that have been hard-won, so we don't lose a record of it if and when the information proves useful again 3 | 4 | ### Dependency Installation on MacOS Ventura 5 | There's a couple of issues at play here. The first is that the current `Pipfile` and `Pipfile.lock` are out of sync, most significantly with regard to Django--the `Pipfile` would generate a lockfile with Django 4, however, we are using Django 3 in the current lockfile and our tests. For now, use the current lockfile and use `--keep-outdated` when adding a new package with `pipenv`. 6 | 7 | However, there seems to be an issue with the way that lockfiles are interacting with MacOS Ventura. In that case, we have to ignore the lockfile entirely, using `pipenv install --dev --python 3.8 --skip-lock` to install dependenices. From what I can tell, this is the only way to actually install the dependencies without it failing, but it means that some tests might fail due to issues with Django 4. 8 | 9 | The long term solution is to make sure that the `Pipfile` and lockfile are in sync, either by forcing Django 3 or by updating our code to support Django 4. 10 | -------------------------------------------------------------------------------- /backend/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/accounts/__init__.py -------------------------------------------------------------------------------- /backend/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.auth.models import Permission 4 | from django.shortcuts import redirect 5 | from django.urls import reverse 6 | 7 | from accounts.models import ( 8 | Email, 9 | Major, 10 | PhoneNumber, 11 | PrivacyResource, 12 | PrivacySetting, 13 | School, 14 | Student, 15 | User, 16 | ) 17 | 18 | 19 | class EmailAdmin(admin.ModelAdmin): 20 | search_fields = ("value",) 21 | readonly_fields = ("value",) 22 | list_display = ("value", "user", "primary", "verified") 23 | list_filter = ("primary", "verified") 24 | 25 | 26 | class StudentAdmin(admin.ModelAdmin): 27 | readonly_fields = ("user",) 28 | search_fields = ("user__username", "user__first_name", "user__last_name") 29 | list_display = ("username", "first_name", "last_name") 30 | list_filter = ("school", "major") 31 | 32 | def username(self, obj): 33 | return obj.user.username 34 | 35 | def first_name(self, obj): 36 | return obj.user.first_name 37 | 38 | def last_name(self, obj): 39 | return obj.user.last_name 40 | 41 | 42 | class UserAdmin(admin.ModelAdmin): 43 | readonly_fields = ("username", "pennid", "last_login", "date_joined") 44 | search_fields = ("username", "first_name", "last_name") 45 | list_display = ("username", "first_name", "last_name", "is_staff") 46 | list_filter = ("is_staff", "is_superuser", "is_active", "groups") 47 | filter_horizontal = ("groups", "user_permissions") 48 | ordering = ("username",) 49 | fieldsets = ( 50 | (None, {"fields": ("username", "pennid")}), 51 | ( 52 | ("Personal info"), 53 | {"fields": ("first_name", "preferred_name", "last_name", "email")}, 54 | ), 55 | ( 56 | ("Permissions"), 57 | { 58 | "fields": ( 59 | "is_active", 60 | "is_staff", 61 | "is_superuser", 62 | "groups", 63 | "user_permissions", 64 | ) 65 | }, 66 | ), 67 | (("Important dates"), {"fields": ("last_login", "date_joined")}), 68 | ) 69 | 70 | 71 | class MajorAdmin(admin.ModelAdmin): 72 | readonly_fields = ("id",) 73 | list_filter = ("is_active", "degree_type") 74 | list_display = ("name",) 75 | 76 | 77 | class SchoolAdmin(admin.ModelAdmin): 78 | readonly_fields = ("id",) 79 | list_display = ("name",) 80 | 81 | 82 | admin.site.register(Permission) 83 | admin.site.register(Student, StudentAdmin) 84 | admin.site.register(User, UserAdmin) 85 | admin.site.register(PhoneNumber) 86 | admin.site.register(Email, EmailAdmin) 87 | admin.site.register(Major, MajorAdmin) 88 | admin.site.register(School, SchoolAdmin) 89 | admin.site.register(PrivacySetting) 90 | admin.site.register(PrivacyResource) 91 | 92 | 93 | class LabsAdminSite(admin.AdminSite): 94 | """ 95 | Custom admin site that redirects users to log in through shibboleth 96 | instead of logging in with a username and password 97 | """ 98 | 99 | def login(self, request, extra_context=None): 100 | if not request.user.is_authenticated: 101 | return redirect( 102 | reverse("accounts:login") + "?next=" + request.GET.get("next") 103 | ) 104 | return super().login(request, extra_context) 105 | 106 | 107 | if settings.SHIB_ADMIN: 108 | """ 109 | Replace the default admin site with a custom one to log in users through shibboleth. 110 | Also copy all registered models from the default admin site 111 | """ 112 | labs_admin = LabsAdminSite() 113 | labs_admin._registry = admin.site._registry 114 | admin.site = labs_admin 115 | -------------------------------------------------------------------------------- /backend/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = "accounts" 6 | -------------------------------------------------------------------------------- /backend/accounts/backends.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | from django.conf import settings 5 | from django.contrib.auth import get_user_model 6 | from django.contrib.auth.backends import RemoteUserBackend 7 | from django.contrib.auth.models import Group 8 | from requests.auth import HTTPBasicAuth 9 | 10 | from accounts.models import Email 11 | 12 | 13 | SETTINGS_MODULE = os.getenv("DJANGO_SETTINGS_MODULE", None) 14 | 15 | 16 | class ShibbolethRemoteUserBackend(RemoteUserBackend): 17 | """ 18 | Authenticate users from Shibboleth headers. 19 | Code based on https://github.com/Brown-University-Library/django-shibboleth-remoteuser 20 | """ 21 | 22 | def get_email(self, pennid): 23 | # Skip if in debug mode or staging 24 | if settings.DEBUG or SETTINGS_MODULE == "Platform.settings.staging": 25 | return None 26 | """ 27 | Use Penn Directory API with OAuth2 to get the email of a user given their Penn ID. 28 | This is necessary to ensure that we have the correct domain (@seas vs. @wharton, etc.) 29 | for various services like Clubs emails. 30 | """ 31 | auth = HTTPBasicAuth( 32 | settings.EMAIL_OAUTH_CLIENT_ID, settings.EMAIL_OAUTH_CLIENT_SECRET 33 | ) 34 | token_response = requests.post( 35 | settings.EMAIL_OAUTH_TOKEN_URL, 36 | auth=auth, 37 | data={"grant_type": "client_credentials"}, 38 | ) 39 | 40 | if token_response.status_code != 200: 41 | return None 42 | 43 | tokens = token_response.json() 44 | access_token = tokens["access_token"] 45 | 46 | headers = { 47 | "Authorization": f"Bearer {access_token}", 48 | "Content-Type": "application/json", 49 | } 50 | 51 | api_url = settings.EMAIL_OAUTH_API_URL_BASE + str(pennid) 52 | response = requests.get(api_url, headers=headers) 53 | 54 | if response.status_code == 200: 55 | data = response.json()["result_data"] 56 | if not data: 57 | return None 58 | return data[0]["email"] 59 | else: 60 | return None 61 | 62 | def authenticate(self, request, remote_user, shibboleth_attributes): 63 | if not remote_user or remote_user == -1: 64 | return 65 | User = get_user_model() 66 | user, created = User.objects.get_or_create( 67 | pennid=remote_user, defaults={"username": shibboleth_attributes["username"]} 68 | ) 69 | 70 | # Add initial attributes on first log in 71 | 72 | if created: 73 | user.set_unusable_password() 74 | user.save() 75 | user = self.configure_user(request, user) 76 | 77 | # Always update email 78 | 79 | email = self.get_email(remote_user) 80 | if email is None or email == "": 81 | email = f"{shibboleth_attributes['username']}@upenn.edu" 82 | 83 | old_email = Email.objects.filter(user=user, primary=True).first() 84 | 85 | if old_email and old_email.value != email: 86 | old_email.value = email 87 | old_email.save() 88 | elif not old_email: 89 | Email.objects.create(user=user, value=email, primary=True, verified=True) 90 | 91 | # Update fields if changed 92 | for key, value in shibboleth_attributes.items(): 93 | if key != "affiliation" and getattr(user, key) is not value: 94 | setattr(user, key, value) 95 | 96 | # Update groups with every log in 97 | user.groups.clear() 98 | for affiliation_name in shibboleth_attributes["affiliation"]: 99 | if ( 100 | affiliation_name 101 | ): # Some users don't have any affiliation somehow ¯\_(ツ)_/¯ 102 | group, _ = Group.objects.get_or_create(name=affiliation_name) 103 | user.groups.add(group) 104 | 105 | user.save() 106 | 107 | return user if self.user_can_authenticate(user) else None 108 | -------------------------------------------------------------------------------- /backend/accounts/data/users.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "group": [ 5 | "member", 6 | "student" 7 | ], 8 | "first_name": "Engineering", 9 | "last_name": "Student", 10 | "student": { 11 | "degree": "B", 12 | "major": [ 13 | "Finance, BS", 14 | "Computer Science, BSE" 15 | ], 16 | "school": [ 17 | "The Wharton School", 18 | "School of Engineering and Applied Science" 19 | ], 20 | "graduation_year": 2022 21 | }, 22 | "email": { 23 | "verified": true 24 | }, 25 | "phone": { 26 | "verified": true 27 | } 28 | }, 29 | { 30 | "group": [ 31 | "member", 32 | "student" 33 | ], 34 | "first_name": "Wharton", 35 | "last_name": "Student", 36 | "student": { 37 | "degree": "B", 38 | "major": [ 39 | "Finance, BS" 40 | ], 41 | "school": [ 42 | "The Wharton School" 43 | ], 44 | "graduation_year": 2024 45 | }, 46 | "email": { 47 | "verified": true 48 | }, 49 | "phone": { 50 | "verified": true 51 | } 52 | }, 53 | { 54 | "group": [ 55 | "member", 56 | "student" 57 | ], 58 | "first_name": "Mathematician", 59 | "last_name": "Student", 60 | "student": { 61 | "degree": "B", 62 | "major": [ 63 | "Mathematics: General Mathematics, BA" 64 | ], 65 | "school": [ 66 | "School of Arts & Sciences" 67 | ], 68 | "graduation_year": 2023 69 | }, 70 | "email": { 71 | "verified": false 72 | }, 73 | "phone": { 74 | "verified": true 75 | } 76 | }, 77 | { 78 | "group": [ 79 | "member", 80 | "student", 81 | "employee" 82 | ], 83 | "first_name": "Nursing", 84 | "last_name": "Student", 85 | "student": { 86 | "degree": "B", 87 | "major": [ 88 | "Nursing, BSN" 89 | ], 90 | "school": [ 91 | "School of Nursing" 92 | ], 93 | "graduation_year": 2025 94 | }, 95 | "email": { 96 | "verified": false 97 | }, 98 | "phone": { 99 | "verified": false 100 | } 101 | }, 102 | { 103 | "group": [ 104 | "member", 105 | "student" 106 | ], 107 | "first_name": "Masters", 108 | "last_name": "Student", 109 | "student": { 110 | "degree": "M", 111 | "major": [ 112 | "Mathematics, MA" 113 | ], 114 | "school": [ 115 | "School of Arts & Sciences" 116 | ], 117 | "graduation_year": 2021 118 | }, 119 | "email": { 120 | "verified": false, 121 | "multiple": true 122 | }, 123 | "phone": { 124 | "verified": true, 125 | "invalid": true 126 | } 127 | }, 128 | { 129 | "group": [ 130 | "member", 131 | "student", 132 | "employee" 133 | ], 134 | "first_name": "MBA", 135 | "last_name": "Student", 136 | "student": { 137 | "degree": "M", 138 | "major": [ 139 | "Finance, MBA" 140 | ], 141 | "school": [ 142 | "The Wharton School" 143 | ], 144 | "graduation_year": 2022 145 | }, 146 | "email": { 147 | "verified": false 148 | }, 149 | "phone": { 150 | "verified": false 151 | } 152 | }, 153 | { 154 | "group": [ 155 | "member", 156 | "employee", 157 | "staff" 158 | ], 159 | "first_name": "Staff", 160 | "last_name": "Member", 161 | "email": { 162 | "verified": true, 163 | "multiple": true 164 | }, 165 | "phone": { 166 | "verified": true 167 | } 168 | }, 169 | { 170 | "group": [ 171 | "member", 172 | "faculty", 173 | "alum" 174 | ], 175 | "first_name": "Professor", 176 | "last_name": "McProfessor", 177 | "email": { 178 | "verified": true 179 | }, 180 | "phone": { 181 | "verified": true, 182 | "invalid": true 183 | } 184 | }, 185 | { 186 | "group": [ 187 | "member", 188 | "faculty", 189 | "alum" 190 | ], 191 | "first_name": "Lecturer", 192 | "last_name": "Person", 193 | "email": { 194 | "verified": false 195 | }, 196 | "phone": { 197 | "verified": true 198 | } 199 | }, 200 | { 201 | "group": [ 202 | "member", 203 | "alum" 204 | ], 205 | "first_name": "Alumni", 206 | "last_name": "GraduatedPerson", 207 | "email": { 208 | "verified": true 209 | }, 210 | "phone": { 211 | "verified": true 212 | } 213 | } 214 | ] 215 | } 216 | -------------------------------------------------------------------------------- /backend/accounts/management/commands/populate_users.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | 4 | from django.contrib.auth.models import Group 5 | from django.core.management import BaseCommand, call_command 6 | 7 | from accounts.models import Email, Major, PhoneNumber, School, User 8 | 9 | 10 | with open("accounts/data/users.json") as f: 11 | users = json.load(f)["users"] 12 | 13 | 14 | class Command(BaseCommand): 15 | def add_arguments(self, parser): 16 | parser.add_argument("--force", action="store_true", help="Forces repopulation") 17 | 18 | def handle(self, *args, **options): 19 | call_command("update_academics") 20 | 21 | for x in ["alum", "employee", "faculty", "member", "staff", "student"]: 22 | Group.objects.get_or_create(name=x) 23 | for i, user in enumerate(users): 24 | username = user["first_name"].strip().lower() 25 | school = "" 26 | if "student" in user: 27 | # converts school name to school short form 28 | dict_ = { 29 | "The Wharton School": "wharton", 30 | "School of Engineering and Applied Science": "seas", 31 | "School of Arts & Sciences": "sas", 32 | "School of Nursing": "nursing", 33 | } 34 | school = dict_[user["student"]["school"][0]].lower() + "." 35 | first_name = user["first_name"] 36 | last_name = user["last_name"] 37 | pennid = i + 1000 38 | email_address = f"{username}@{school}upenn.edu" 39 | 40 | user_obj, created = User.objects.get_or_create( 41 | pennid=pennid, 42 | username=username, 43 | first_name=first_name, 44 | last_name=last_name, 45 | ) 46 | user_obj.set_unusable_password() 47 | 48 | if created: 49 | if "preferred_name" in user: 50 | user_obj.preferred_name = user["preferred_name"] 51 | user_obj.save() 52 | member_group = Group.objects.get(name="member") 53 | user_obj.groups.add(member_group) 54 | for group in user["group"]: 55 | group_obj = Group.objects.filter(name=group).first() 56 | if group_obj is not None: 57 | user_obj.groups.add(group_obj) 58 | 59 | if "student" in user: 60 | student_details = user["student"] 61 | student = user_obj.student 62 | student.graduation_year = student_details["graduation_year"] 63 | student.save() 64 | for major_name in student_details["major"]: 65 | major = Major.objects.filter(name=major_name).first() 66 | if major is not None: 67 | student.major.add(major) 68 | for school_name in student_details["school"]: 69 | school_obj = School.objects.filter(name=school_name).first() 70 | if school_obj is not None: 71 | student.school.add(school_obj) 72 | student.save() 73 | if "email" in user: 74 | email_details = user["email"] 75 | email = Email( 76 | user=user_obj, 77 | value=email_address, 78 | primary=True, 79 | verified=email_details["verified"], 80 | ) 81 | email.save() 82 | if "multiple" in email_details: 83 | email2 = Email( 84 | user=User.objects.all().get(username=username), 85 | value=f"{user['last_name'].strip().lower()}@{school}upenn.edu", 86 | primary=False, 87 | ) 88 | email2.save() 89 | if "phone" in user: 90 | if user["phone"].get("invalid") is not None: 91 | number = "555" 92 | for _ in range(7): 93 | number += str(random.randint(0, 9)) 94 | else: 95 | number = "" 96 | for _ in range(10): 97 | number += str(random.randint(0, 9)) 98 | 99 | phone = PhoneNumber( 100 | user=User.objects.all().get(username=username), 101 | value=number, 102 | primary=True, 103 | verified=user["phone"]["verified"], 104 | ) 105 | phone.save() 106 | user_obj.save() 107 | 108 | self.stdout.write("Users populated") 109 | -------------------------------------------------------------------------------- /backend/accounts/management/commands/update_academics.py: -------------------------------------------------------------------------------- 1 | from django.core.management import BaseCommand 2 | 3 | from accounts.update_majors import update_all_majors 4 | from accounts.update_schools import update_all_schools 5 | 6 | 7 | class Command(BaseCommand): 8 | def handle(self, *args, **kwargs): 9 | update_all_majors() 10 | update_all_schools() 11 | 12 | self.stdout.write("Updated active schools and majors in database.") 13 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0002_auto_20200213_1711.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-13 22:11 2 | 3 | from django.db import migrations, transaction 4 | 5 | 6 | def create_groups(apps, schema_editor): 7 | with transaction.atomic(): 8 | User = apps.get_model("accounts", "User") 9 | Group = apps.get_model("auth", "Group") 10 | for user in User.objects.all(): 11 | for affiliation in user.affiliation.all(): 12 | if affiliation.name is not None: 13 | group, _ = Group.objects.get_or_create(name=affiliation.name) 14 | user.groups.add(group) 15 | user.save() 16 | 17 | 18 | def copy_permissions(apps, schema_editor): 19 | with transaction.atomic(): 20 | User = apps.get_model("accounts", "User") 21 | Permission = apps.get_model("auth", "Permission") 22 | ContentType = apps.get_model("contenttypes", "ContentType") 23 | for user in User.objects.all(): 24 | for product_permission in user.product_permission.all(): 25 | content_type = ContentType.objects.get( 26 | app_label="accounts", model="user" 27 | ) 28 | perm, _ = Permission.objects.get_or_create( 29 | codename=product_permission.id, 30 | name=product_permission.name, 31 | content_type=content_type, 32 | ) 33 | user.user_permissions.add(perm) 34 | user.save() 35 | 36 | 37 | class Migration(migrations.Migration): 38 | dependencies = [("accounts", "0001_initial")] 39 | 40 | operations = [ 41 | migrations.RunPython(create_groups), 42 | migrations.RemoveField(model_name="user", name="affiliation"), 43 | migrations.DeleteModel(name="PennAffiliation"), 44 | migrations.RunPython(copy_permissions), 45 | migrations.RemoveField(model_name="user", name="product_permission"), 46 | migrations.DeleteModel(name="ProductPermission"), 47 | ] 48 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0004_user_profile_pic.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-11-10 02:05 2 | 3 | import accounts.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0003_auto_20210918_2041"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="user", 15 | name="profile_pic", 16 | field=models.ImageField( 17 | blank=True, null=True, upload_to=accounts.models.get_user_image_filepath 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0005_privacyresource_privacysetting.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2023-03-04 21:59 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("accounts", "0004_user_profile_pic"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="PrivacyResource", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("name", models.CharField(max_length=255)), 27 | ], 28 | ), 29 | migrations.CreateModel( 30 | name="PrivacySetting", 31 | fields=[ 32 | ( 33 | "id", 34 | models.AutoField( 35 | auto_created=True, 36 | primary_key=True, 37 | serialize=False, 38 | verbose_name="ID", 39 | ), 40 | ), 41 | ("enabled", models.BooleanField(default=True)), 42 | ( 43 | "resource", 44 | models.ForeignKey( 45 | on_delete=django.db.models.deletion.CASCADE, 46 | related_name="resource", 47 | to="accounts.privacyresource", 48 | ), 49 | ), 50 | ( 51 | "user", 52 | models.ForeignKey( 53 | on_delete=django.db.models.deletion.CASCADE, 54 | related_name="privacy_setting", 55 | to=settings.AUTH_USER_MODEL, 56 | ), 57 | ), 58 | ], 59 | ), 60 | ] 61 | -------------------------------------------------------------------------------- /backend/accounts/migrations/0006_alter_major_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-05 06:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0005_privacyresource_privacysetting"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="major", 14 | name="name", 15 | field=models.CharField(max_length=150), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /backend/accounts/mixins.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist 2 | from rest_framework import serializers 3 | 4 | 5 | class ManyToManySaveMixin(object): 6 | """ 7 | Mixin for serializers that saves ManyToMany fields by looking up related models. 8 | Create a new attribute called "save_related_fields" in the Meta class that 9 | represents the ManyToMany fields that should have save behavior. 10 | You can also specify a dictionary instead of a string, with the following fields: 11 | - field (string, required): The field to implement saving behavior on. 12 | - mode (bool): 13 | - If set to create, create the related model if it does not exist. 14 | - Otherwise, raise an exception if the user links to a nonexistent object. 15 | """ 16 | 17 | def _lookup_item(self, model, field_name, item, mode=None): 18 | if mode == "create": 19 | obj, _ = model.objects.get_or_create(**item) 20 | return obj 21 | else: 22 | try: 23 | return model.objects.get(**item) 24 | except ObjectDoesNotExist: 25 | raise serializers.ValidationError( 26 | { 27 | field_name: [ 28 | "The object with these values does not exist: {}".format( 29 | item 30 | ) 31 | ] 32 | }, 33 | code="invalid", 34 | ) 35 | except MultipleObjectsReturned: 36 | raise serializers.ValidationError( 37 | { 38 | field_name: [ 39 | "Multiple objects exist with these values: {}".format(item) 40 | ] 41 | } 42 | ) 43 | 44 | def save(self): 45 | m2m_to_save = getattr(self.Meta, "save_related_fields", []) 46 | 47 | # turn all entries into dict configs 48 | for i, m2m in enumerate(m2m_to_save): 49 | if not isinstance(m2m, dict): 50 | m2m_to_save[i] = {"field": m2m, "mode": None} 51 | 52 | # ignore fields that aren't specified 53 | ignore_fields = set() 54 | 55 | # remove m2m from validated data and save 56 | m2m_lists = {} 57 | # iterate through m2m fields 58 | for m2m in m2m_to_save: 59 | mode = m2m.get("mode", None) 60 | field_name = m2m["field"] 61 | 62 | field = self.fields[field_name] 63 | # checks if it is many-to-many 64 | if isinstance(field, serializers.ListSerializer): 65 | m2m["many"] = True 66 | # get model 67 | model = field.child.Meta.model 68 | # creates a new list of model 69 | m2m_lists[field_name] = [] 70 | # gets list of validated items to add 71 | items = self.validated_data.pop(field_name, None) 72 | if items is None: 73 | ignore_fields.add(field_name) 74 | continue 75 | 76 | for item in items: 77 | # adds item to list 78 | m2m_lists[field_name].append( 79 | self._lookup_item(model, field_name, item, mode) 80 | ) 81 | else: 82 | m2m["many"] = False 83 | # handles if it's a 1:1 field 84 | if hasattr(field, "Meta"): 85 | model = field.Meta.model 86 | item = self.validated_data.pop(field_name, None) 87 | # creates/gets objects associaated with list and creates list of objects 88 | m2m_lists[field_name] = self._lookup_item( 89 | model, field_name, item, mode 90 | ) 91 | else: 92 | # handles if it's accidentally added (e.g. Integer field, character field, etc.) 93 | ignore_fields.add(field_name) 94 | 95 | obj = super(ManyToManySaveMixin, self).save() 96 | 97 | # link models to this model 98 | updates = [] 99 | for m2m in m2m_to_save: 100 | field = m2m["field"] 101 | if field in ignore_fields: 102 | continue 103 | value = m2m_lists[field] 104 | if m2m["many"]: 105 | # overrides list of objects associated with fields 106 | getattr(obj, field).set(value) 107 | else: 108 | setattr(obj, field, value) 109 | updates.append(field) 110 | 111 | if updates: 112 | obj.save(update_fields=updates) 113 | 114 | return obj 115 | -------------------------------------------------------------------------------- /backend/accounts/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import AbstractUser 6 | from django.core.validators import MinValueValidator 7 | from django.db import models 8 | from django.db.models.signals import post_save 9 | from django.dispatch import receiver 10 | from phonenumber_field.modelfields import PhoneNumberField 11 | 12 | 13 | def get_user_image_filepath(instance, fname): 14 | """ 15 | Returns the provided User's profile picture image path. Maintains the 16 | file extension of the provided image file if it exists. 17 | """ 18 | suffix = "." + fname.rsplit(".", 1)[-1] if "." in fname else "" 19 | return os.path.join("images", f"{instance.username}{suffix}") 20 | 21 | 22 | class School(models.Model): 23 | """ 24 | Represents a school at the University of Pennsylvania. 25 | """ 26 | 27 | name = models.CharField(max_length=255) 28 | 29 | def __str__(self): 30 | return self.name 31 | 32 | 33 | class Major(models.Model): 34 | """ 35 | Represents a major at the University of Pennsylvania. 36 | """ 37 | 38 | name = models.CharField(max_length=150) 39 | is_active = models.BooleanField(default=True) 40 | 41 | DEGREE_BACHELOR = "BACHELORS" 42 | DEGREE_MASTER = "MASTERS" 43 | DEGREE_PHD = "PHD" 44 | DEGREE_PROFESSIONAL = "PROFESSIONAL" 45 | DEGREE_CHOICES = [ 46 | (DEGREE_BACHELOR, "Bachelor's"), 47 | (DEGREE_MASTER, "Master's"), 48 | (DEGREE_PHD, "PhD"), 49 | (DEGREE_PROFESSIONAL, "Professional"), 50 | ] 51 | # fixed choices for degree type 52 | degree_type = models.CharField( 53 | max_length=20, choices=DEGREE_CHOICES, default=DEGREE_PROFESSIONAL 54 | ) 55 | 56 | def __str__(self): 57 | return self.name 58 | 59 | 60 | class User(AbstractUser): 61 | # implicit username, email, first_name, and last_name fields 62 | # from AbstractUser that contains the user's PennKey 63 | pennid = models.IntegerField(primary_key=True) 64 | uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) 65 | preferred_name = models.CharField(max_length=225, blank=True) 66 | profile_pic = models.ImageField( 67 | upload_to=get_user_image_filepath, blank=True, null=True 68 | ) 69 | 70 | VERIFICATION_EXPIRATION_MINUTES = 10 71 | 72 | @property 73 | def id(self): 74 | return self.uuid 75 | 76 | def get_preferred_name(self): 77 | if self.preferred_name != "": 78 | return self.preferred_name 79 | else: 80 | return self.first_name 81 | 82 | def get_email(self): 83 | email = self.emails.filter(primary=True).first() 84 | return email.value if email else "" 85 | 86 | 87 | class Student(models.Model): 88 | """ 89 | Represents a Student at the University of Pennsylvania. 90 | """ 91 | 92 | user = models.OneToOneField( 93 | get_user_model(), related_name="student", on_delete=models.DO_NOTHING 94 | ) 95 | major = models.ManyToManyField(Major, blank=True) 96 | school = models.ManyToManyField(School, blank=True) 97 | graduation_year = models.PositiveIntegerField( 98 | validators=[MinValueValidator(1740)], null=True 99 | ) 100 | 101 | def __str__(self): 102 | return self.user.username 103 | 104 | 105 | @receiver(post_save, sender=User) 106 | def ensure_student_object(sender, instance, created, **kwargs): 107 | """ 108 | This post_save hook triggers automatically when a User object is saved, and if no Student 109 | object exists for that User, it will create one 110 | """ 111 | Student.objects.get_or_create(user=instance) 112 | 113 | 114 | class Email(models.Model): 115 | user = models.ForeignKey( 116 | get_user_model(), related_name="emails", on_delete=models.CASCADE 117 | ) 118 | value = models.EmailField(unique=True) 119 | primary = models.BooleanField(default=False) 120 | verification_code = models.CharField(max_length=6, blank=True, null=True) 121 | verification_timestamp = models.DateTimeField(auto_now_add=True) 122 | verified = models.BooleanField(default=False) 123 | 124 | def __str__(self): 125 | return f"{self.user} - {self.value}" 126 | 127 | 128 | class PhoneNumber(models.Model): 129 | user = models.ForeignKey( 130 | get_user_model(), related_name="phone_numbers", on_delete=models.CASCADE 131 | ) 132 | value = PhoneNumberField(unique=True, blank=True, default=None) 133 | primary = models.BooleanField(default=False) 134 | verification_code = models.CharField(max_length=6, blank=True, null=True) 135 | verification_timestamp = models.DateTimeField(auto_now_add=True) 136 | verified = models.BooleanField(default=False) 137 | 138 | def __str__(self): 139 | return f"{self.user} - {self.value}" 140 | 141 | 142 | class PrivacyResource(models.Model): 143 | """ 144 | Represents a resource utilized by Penn Labs that users reserve 145 | the right to withhold. 146 | """ 147 | 148 | name = models.CharField(max_length=255) 149 | 150 | 151 | @receiver(post_save, sender=PrivacyResource) 152 | def add_privacy_resource(sender, instance, created, **kwargs): 153 | """ 154 | This post_save hook triggers whenever a new privacy resource is added. 155 | Each User will receive a new privacy setting with this resource, enabled 156 | to true. 157 | """ 158 | users = User.objects.all() 159 | settings = [ 160 | PrivacySetting(user=user, resource=instance, enabled=True) for user in users 161 | ] 162 | # Bulk creating for all User objects 163 | PrivacySetting.objects.bulk_create(settings, ignore_conflicts=True) 164 | 165 | 166 | class PrivacySetting(models.Model): 167 | user = models.ForeignKey( 168 | get_user_model(), related_name="privacy_setting", on_delete=models.CASCADE 169 | ) 170 | resource = models.ForeignKey( 171 | PrivacyResource, related_name="resource", on_delete=models.CASCADE 172 | ) 173 | enabled = models.BooleanField(default=True) 174 | 175 | 176 | @receiver(post_save, sender=User) 177 | def load_privacy_settings(sender, instance, created, **kwargs): 178 | """ 179 | This post_save hook triggers automatically when a User object is saved, and loads in default 180 | privacy settings for the User 181 | """ 182 | 183 | # In most cases, first checking if settings exists should reduce the number of queries 184 | # to the database 185 | if not instance.privacy_setting.exists(): 186 | resources = PrivacyResource.objects.all() 187 | settings = [ 188 | PrivacySetting(user=instance, resource=resource, enabled=True) 189 | for resource in resources 190 | ] 191 | PrivacySetting.objects.bulk_create(settings, ignore_conflicts=True) 192 | -------------------------------------------------------------------------------- /backend/accounts/oauth2_validator.py: -------------------------------------------------------------------------------- 1 | from oauth2_provider.oauth2_validators import OAuth2Validator 2 | 3 | 4 | class LabsOAuth2Validator(OAuth2Validator): 5 | oidc_claim_scope = OAuth2Validator.oidc_claim_scope 6 | oidc_claim_scope.update( 7 | { 8 | "name": "read", 9 | "email": "read", 10 | "pennkey": "read", 11 | "pennid": "read", 12 | "is_staff": "read", 13 | "is_active": "read", 14 | } 15 | ) 16 | 17 | def get_additional_claims(self, request): 18 | return { 19 | "name": request.user.preferred_name or request.user.get_full_name(), 20 | "email": request.user.get_email(), 21 | "pennkey": request.user.username, 22 | "pennid": request.user.pennid, 23 | "is_staff": request.user.is_staff, 24 | "is_active": request.user.is_active, 25 | } 26 | -------------------------------------------------------------------------------- /backend/accounts/static/js/devlogin.js: -------------------------------------------------------------------------------- 1 | const data = JSON.parse(document.getElementById('user_data').textContent); 2 | 3 | const headers = [ 4 | "Name", 5 | "Groups", 6 | "Email(s)", 7 | "Email Verified", 8 | "Phone #(s)", 9 | "Phone Verified", 10 | "Major(s)", 11 | "School(s)" 12 | ] 13 | 14 | function parseRow(row) { 15 | let rowData = [] 16 | // name 17 | rowData.push(row.first_name + " " + row.last_name) 18 | // groups 19 | rowData.push(row.groups.join(", ")) 20 | 21 | let {value: emails, verified: emailsVerified} = row.emails[0] 22 | for (let i = 1; i < row.emails.length; i++) { 23 | emails = emails + ", " + row.emails[i].value 24 | emailsVerified = emailsVerified + ", " + row.emails[i].verified 25 | } 26 | rowData.push(emails) 27 | rowData.push(emailsVerified) 28 | 29 | let {value: phones, verified: phonesVerified} = row.phone_numbers[0] 30 | for (let i = 1; i < row.phone_numbers.length; i++) { 31 | phones = phones + ", " + row.phone_numbers[i].value 32 | phonesVerified = phonesVerified + ", " + row.phone_numbers[i].verified 33 | } 34 | 35 | rowData.push(phones) 36 | rowData.push(phonesVerified) 37 | 38 | let majors = "" 39 | let schools = "" 40 | if (row.student.graduation_year) { 41 | const majorsData = row.student.major 42 | majors = majorsData[0].name + " " + majorsData[0].degree_type 43 | const schoolsData = row.student.school 44 | schools = schoolsData[0].name 45 | 46 | for (let i = 1; i < majorsData.length; i++) { 47 | majors = majors + ", " + majorsData[i].name + " " + majorsData[0].degree_type 48 | } 49 | 50 | for (let i = 1; i < schoolsData.length; i++) { 51 | schools = schools + ", " + schoolsData[i].name 52 | } 53 | 54 | } 55 | rowData.push(majors) 56 | rowData.push(schools) 57 | return rowData 58 | } 59 | 60 | function addTable() { 61 | let table = document.createElement("table") 62 | 63 | let header = table.insertRow(-1) 64 | 65 | for (let i = 0; i < headers.length; i++) { 66 | let th = document.createElement("th") 67 | th.innerHTML = headers[i] 68 | header.appendChild(th) 69 | } 70 | 71 | for (let i = 0; i < data.length; i++) { 72 | let rowData = parseRow(data[i]) 73 | let tr = table.insertRow(-1) 74 | 75 | for (let j = 0; j < rowData.length; j++) { 76 | let cell = tr.insertCell(-1) 77 | cell.innerHTML = rowData[j] 78 | } 79 | } 80 | 81 | table.classList.add("table", "table-striped", "center-div") 82 | let container = document.getElementById("datatable") 83 | container.appendChild(table) 84 | } 85 | 86 | addTable() 87 | -------------------------------------------------------------------------------- /backend/accounts/templates/accounts/devlogin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Platform Dev Login 6 | 8 | 9 | 15 | 16 | 17 | {% load static %} 18 | {{ user_data|json_script:"user_data" }} 19 |
20 |
21 |

Platform Dev Login

22 |
23 | {% csrf_token %} 24 |
Choose a user:
25 |
26 | 31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |

User Details

39 |
40 |
41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /backend/accounts/update_majors.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from bs4 import BeautifulSoup 3 | 4 | from accounts.models import Major 5 | 6 | 7 | def contains_filters(listed_filters, desired_filters=set(), excluded_filters=set()): 8 | # ensure no excluded filters appear 9 | for curr_filter in excluded_filters: 10 | if curr_filter in listed_filters: 11 | return False 12 | 13 | # ensure at least one desired filter appears 14 | for curr_filter in desired_filters: 15 | if curr_filter in listed_filters: 16 | return True 17 | 18 | return False 19 | 20 | 21 | def update_all_majors(): 22 | # scrapes majors from the official penn catalog of all programs 23 | source = requests.get("https://catalog.upenn.edu/programs/").text 24 | 25 | soup = BeautifulSoup(source, "lxml") 26 | 27 | bachelor_filter = "filter_6" 28 | master_filter = "filter_25" 29 | phd_filter = "filter_7" 30 | professional_filter = "filter_10" 31 | minor_filter = "filter_26" 32 | desired_filters = {bachelor_filter, master_filter, phd_filter, professional_filter} 33 | excluded_filters = {minor_filter} 34 | 35 | listed_majors = set() 36 | # iterate through all list tags with "item" in the class (all programs) 37 | for program in soup.find_all( 38 | "li", class_=lambda value: value and value.startswith("item ") 39 | ): 40 | curr_filter_list = program.attrs["class"] 41 | # check if entry meets relevant desired and excluded filter criteria 42 | if not contains_filters( 43 | curr_filter_list, 44 | desired_filters=desired_filters, 45 | excluded_filters=excluded_filters, 46 | ): 47 | continue 48 | 49 | # grab the major name 50 | major_name = program.find("span", class_="title").text 51 | 52 | # identify degree type 53 | if bachelor_filter in curr_filter_list: 54 | curr_degree_type = Major.DEGREE_BACHELOR 55 | elif master_filter in curr_filter_list: 56 | curr_degree_type = Major.DEGREE_MASTER 57 | elif phd_filter in curr_filter_list: 58 | curr_degree_type = Major.DEGREE_PHD 59 | else: 60 | curr_degree_type = Major.DEGREE_PROFESSIONAL 61 | 62 | # create new major entry if it does not already exist 63 | Major.objects.update_or_create( 64 | name=major_name, defaults={"degree_type": curr_degree_type} 65 | ) 66 | 67 | # keep track of found majors 68 | listed_majors.add(major_name) 69 | 70 | # iterate through existing majors and set active/inactive status 71 | for existing_major in Major.objects.all(): 72 | existing_major.is_active = existing_major.name in listed_majors 73 | existing_major.save() 74 | -------------------------------------------------------------------------------- /backend/accounts/update_schools.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from bs4 import BeautifulSoup 3 | 4 | from accounts.models import School 5 | 6 | 7 | def update_all_schools(): 8 | # scrapes schools from the official penn catalog of all programs 9 | source = requests.get("https://catalog.upenn.edu/programs/").text 10 | 11 | soup = BeautifulSoup(source, "lxml") 12 | 13 | # iterate through all list tags with "item" in the class (all programs) 14 | school_list = soup.find("div", id="cat11list") 15 | for curr_school in school_list.find_all("div"): 16 | school_name = curr_school.text 17 | # create new school entry if it does not already exist 18 | School.objects.update_or_create(name=school_name) 19 | -------------------------------------------------------------------------------- /backend/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import path 4 | from oauth2_provider.views import ( 5 | AuthorizationView, 6 | ConnectDiscoveryInfoView, 7 | JwksInfoView, 8 | TokenView, 9 | ) 10 | from rest_framework import routers 11 | 12 | from accounts.views import ( 13 | DevLoginView, 14 | DevLogoutView, 15 | EmailViewSet, 16 | FindUserView, 17 | LoginView, 18 | LogoutView, 19 | MajorViewSet, 20 | PhoneNumberViewSet, 21 | PrivacySettingView, 22 | ProductAdminView, 23 | ProfilePicViewSet, 24 | SchoolViewSet, 25 | UserSearchView, 26 | UserView, 27 | UUIDIntrospectTokenView, 28 | ) 29 | 30 | 31 | app_name = "accounts" 32 | 33 | router = routers.SimpleRouter() 34 | router.register("me/phonenumber", PhoneNumberViewSet, basename="me-phonenumber") 35 | router.register("me/email", EmailViewSet, basename="me-email") 36 | router.register("me/pfp", ProfilePicViewSet, basename="me-pfp") 37 | router.register("majors", MajorViewSet, basename="majors") 38 | router.register("schools", SchoolViewSet, basename="schools") 39 | 40 | FinalLoginView = DevLoginView if settings.IS_DEV_LOGIN else LoginView 41 | FinalLogoutView = DevLogoutView if settings.IS_DEV_LOGIN else LogoutView 42 | 43 | urlpatterns = [ 44 | path("login/", FinalLoginView.as_view(), name="login"), 45 | path("logout/", FinalLogoutView.as_view(), name="logout"), 46 | path("me/", UserView.as_view(), name="me"), 47 | path("search/", UserSearchView.as_view(), name="search"), 48 | path("authorize/", AuthorizationView.as_view(), name="authorize"), 49 | path("token/", TokenView.as_view(), name="token"), 50 | path("introspect/", UUIDIntrospectTokenView.as_view(), name="introspect"), 51 | path("productadmin/", ProductAdminView.as_view(), name="productadmin"), 52 | path("privacy/", PrivacySettingView.as_view(), name="privacy"), 53 | path("privacy//", PrivacySettingView.as_view(), name="privacy"), 54 | path("user/", FindUserView.as_view(), name="user"), 55 | path( 56 | ".well-known/openid-configuration", 57 | ConnectDiscoveryInfoView.as_view(), 58 | name="oidc-connect-discovery-info", 59 | ), 60 | path(".well-known/jwks.json", JwksInfoView.as_view(), name="oidc-jwks-info"), 61 | ] 62 | 63 | urlpatterns += router.urls 64 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 65 | -------------------------------------------------------------------------------- /backend/accounts/verification.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from email_tools.emails import send_email 3 | from sentry_sdk import capture_message 4 | from twilio.base.exceptions import TwilioException, TwilioRestException 5 | from twilio.rest import Client 6 | 7 | 8 | def sendSMSVerification(to, verification_code): 9 | try: 10 | client = Client(settings.TWILIO_SID, settings.TWILIO_AUTH_TOKEN) 11 | body = f"Your Penn Labs Account Verification Code is: {verification_code}" 12 | client.messages.create(to=str(to), from_=settings.TWILIO_NUMBER, body=body) 13 | except TwilioRestException as e: 14 | capture_message(e, level="error") 15 | except TwilioException as e: # likely a credential issue in development 16 | capture_message(e, level="error") 17 | 18 | 19 | def sendEmailVerification(to, verification_code): 20 | context = { 21 | "verification_code": verification_code, 22 | } 23 | subject = "Penn Labs email verification" 24 | send_email("emails/email_verification.html", context, subject, to) 25 | -------------------------------------------------------------------------------- /backend/announcements/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/announcements/__init__.py -------------------------------------------------------------------------------- /backend/announcements/admin.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement, Audience 2 | from django.contrib import admin 3 | 4 | 5 | admin.site.register(Audience) 6 | admin.site.register(Announcement) 7 | -------------------------------------------------------------------------------- /backend/announcements/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AnnouncementsConfig(AppConfig): 5 | name = "announcements" 6 | -------------------------------------------------------------------------------- /backend/announcements/management/commands/populate_audiences.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Audience 2 | from django.core.management import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **kwargs): 7 | for audience_name, _ in Audience.AUDIENCE_CHOICES: 8 | Audience.objects.get_or_create(name=audience_name) 9 | -------------------------------------------------------------------------------- /backend/announcements/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-09 05:29 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Audience", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "name", 27 | models.CharField( 28 | choices=[ 29 | ("MOBILE", "Penn Mobile"), 30 | ("OHQ", "OHQ"), 31 | ("CLUBS", "Penn Clubs"), 32 | ("COURSE_PLAN", "Penn Course Plan"), 33 | ("COURSE_REVIEW", "Penn Course Review"), 34 | ("COURSE_ALERT", "Penn Course Alert"), 35 | ], 36 | max_length=20, 37 | ), 38 | ), 39 | ], 40 | ), 41 | migrations.CreateModel( 42 | name="Announcement", 43 | fields=[ 44 | ( 45 | "id", 46 | models.AutoField( 47 | auto_created=True, 48 | primary_key=True, 49 | serialize=False, 50 | verbose_name="ID", 51 | ), 52 | ), 53 | ("title", models.CharField(blank=True, max_length=255, null=True)), 54 | ("message", models.TextField()), 55 | ( 56 | "announcement_type", 57 | models.CharField( 58 | choices=[("NOTICE", "Notice"), ("ISSUE", "Issue")], 59 | default="NOTICE", 60 | max_length=20, 61 | ), 62 | ), 63 | ( 64 | "release_time", 65 | models.DateTimeField(default=django.utils.timezone.now), 66 | ), 67 | ("end_time", models.DateTimeField(blank=True, null=True)), 68 | ( 69 | "audiences", 70 | models.ManyToManyField( 71 | related_name="announcements", to="announcements.audience" 72 | ), 73 | ), 74 | ], 75 | ), 76 | ] 77 | -------------------------------------------------------------------------------- /backend/announcements/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/announcements/migrations/__init__.py -------------------------------------------------------------------------------- /backend/announcements/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils import timezone 3 | 4 | 5 | class Audience(models.Model): 6 | """ 7 | Represents a product that an announcement is intended for. 8 | """ 9 | 10 | AUDIENCE_MOBILE = "MOBILE" 11 | AUDIENCE_OHQ = "OHQ" 12 | AUDIENCE_CLUBS = "CLUBS" 13 | AUDIENCE_COURSE_PLAN = "COURSE_PLAN" 14 | AUDIENCE_COURSE_REVIEW = "COURSE_REVIEW" 15 | AUDIENCE_COURSE_ALERT = "COURSE_ALERT" 16 | 17 | AUDIENCE_CHOICES = [ 18 | (AUDIENCE_MOBILE, "Penn Mobile"), 19 | (AUDIENCE_OHQ, "OHQ"), 20 | (AUDIENCE_CLUBS, "Penn Clubs"), 21 | (AUDIENCE_COURSE_PLAN, "Penn Course Plan"), 22 | (AUDIENCE_COURSE_REVIEW, "Penn Course Review"), 23 | (AUDIENCE_COURSE_ALERT, "Penn Course Alert"), 24 | ] 25 | 26 | name = models.CharField(choices=AUDIENCE_CHOICES, max_length=20) 27 | 28 | def __str__(self): 29 | return self.name 30 | 31 | 32 | class Announcement(models.Model): 33 | """ 34 | Represents an announcement for any of the Penn Labs services. 35 | """ 36 | 37 | ANNOUNCEMENT_NOTICE = "NOTICE" 38 | ANNOUNCEMENT_ISSUE = "ISSUE" 39 | 40 | ANNOUNCEMENT_CHOICES = [ 41 | (ANNOUNCEMENT_NOTICE, "Notice"), 42 | (ANNOUNCEMENT_ISSUE, "Issue"), 43 | ] 44 | 45 | title = models.CharField( 46 | max_length=255, 47 | blank=True, 48 | null=True, 49 | ) 50 | message = models.TextField() 51 | announcement_type = models.CharField( 52 | max_length=20, 53 | choices=ANNOUNCEMENT_CHOICES, 54 | default=ANNOUNCEMENT_NOTICE, 55 | ) 56 | audiences = models.ManyToManyField("Audience", related_name="announcements") 57 | release_time = models.DateTimeField(default=timezone.now) 58 | end_time = models.DateTimeField(null=True, blank=True) 59 | 60 | def __str__(self): 61 | rtime = self.release_time.strftime("%m-%d-%Y %H:%M:%S") 62 | etime = ( 63 | f" to {self.end_time.strftime('%m-%d-%Y %H:%M:%S')}" 64 | if self.end_time 65 | else "" 66 | ) 67 | aud_str = ",".join([audience.name for audience in self.audiences.all()]) 68 | title_str = f"{self.title}: " if self.title else "" 69 | 70 | return f"[{self.get_announcement_type_display()} for {aud_str}] \ 71 | starting at {rtime}{etime} | {title_str}{self.message}" 72 | -------------------------------------------------------------------------------- /backend/announcements/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class AnnouncementPermissions(permissions.BasePermission): 5 | """ 6 | Grants permission if the current user is a superuser. 7 | """ 8 | 9 | def has_object_permission(self, request, view, obj): 10 | if request.method in permissions.SAFE_METHODS: 11 | return True 12 | return request.user.is_authenticated and request.user.is_superuser 13 | 14 | def has_permission(self, request, view): 15 | if request.method in permissions.SAFE_METHODS: 16 | return True 17 | return request.user.is_authenticated and request.user.is_superuser 18 | -------------------------------------------------------------------------------- /backend/announcements/serializers.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement, Audience 2 | from rest_framework import serializers 3 | 4 | 5 | class AudienceSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Audience 8 | fields = ("name",) 9 | 10 | 11 | class AnnouncementSerializer(serializers.ModelSerializer): 12 | audiences = serializers.SlugRelatedField( 13 | many=True, slug_field="name", queryset=Audience.objects.all() 14 | ) 15 | 16 | class Meta: 17 | model = Announcement 18 | fields = ( 19 | "id", 20 | "title", 21 | "message", 22 | "announcement_type", 23 | "release_time", 24 | "end_time", 25 | "audiences", 26 | ) 27 | 28 | def to_representation(self, instance): 29 | representation = super().to_representation(instance) 30 | representation["audiences"] = [ 31 | audience.name for audience in instance.audiences.all() 32 | ] 33 | return representation 34 | 35 | def to_internal_value(self, data): 36 | audiences = data.get("audiences") 37 | if isinstance(audiences, list): 38 | if not audiences: 39 | raise serializers.ValidationError( 40 | {"detail": "You must provide at least one audience"} 41 | ) 42 | audience_objs = [] 43 | for audience_name in audiences: 44 | audience = Audience.objects.filter(name=audience_name).first() 45 | if not audience: 46 | raise serializers.ValidationError( 47 | {"detail": f"Invalid audience name: {audience_name}"} 48 | ) 49 | audience_objs.append(audience) 50 | data["audiences"] = audience_objs 51 | return super().to_internal_value(data) 52 | 53 | def create(self, validated_data): 54 | audiences = validated_data.pop("audiences") 55 | instance = Announcement.objects.create(**validated_data) 56 | instance.audiences.set(audiences) 57 | return instance 58 | 59 | def update(self, instance, validated_data): 60 | audiences = validated_data.pop("audiences", None) 61 | super().update(instance, validated_data) 62 | if audiences: 63 | instance.audiences.set(audiences) 64 | return instance 65 | -------------------------------------------------------------------------------- /backend/announcements/urls.py: -------------------------------------------------------------------------------- 1 | from announcements.views import AnnouncementsViewSet 2 | from rest_framework import routers 3 | 4 | 5 | app_name = "announcements" 6 | router = routers.SimpleRouter() 7 | router.register("", AnnouncementsViewSet, basename="announcements") 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /backend/announcements/views.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement 2 | from announcements.permissions import AnnouncementPermissions 3 | from announcements.serializers import AnnouncementSerializer 4 | from django.db.models import Q 5 | from django.utils import timezone 6 | from rest_framework import viewsets 7 | 8 | 9 | class AnnouncementsViewSet(viewsets.ModelViewSet): 10 | serializer_class = AnnouncementSerializer 11 | permission_classes = [AnnouncementPermissions] 12 | 13 | def get_queryset(self): 14 | # automatically filter for active announcements 15 | queryset = Announcement.objects.filter( 16 | Q(release_time__lte=timezone.now()) 17 | & (Q(end_time__gte=timezone.now()) | Q(end_time__isnull=True)) 18 | ).prefetch_related("audiences") 19 | audiences = self.request.query_params.get("audience") 20 | 21 | if audiences: 22 | audience_names = audiences.split(",") 23 | queryset = queryset.filter( 24 | audiences__name__in=[name.strip().upper() for name in audience_names] 25 | ) 26 | 27 | return queryset.distinct() 28 | -------------------------------------------------------------------------------- /backend/docker/mime.types: -------------------------------------------------------------------------------- 1 | # mime type definitions copied from: 2 | # https://github.com/lucidfrontier45/docker-python-uwsgi/blob/master/mime.types 3 | 4 | text/html html htm shtml 5 | text/css css 6 | text/xml xml 7 | image/gif gif 8 | image/jpeg jpeg jpg 9 | application/javascript js 10 | application/atom+xml atom 11 | application/rss+xml rss 12 | 13 | text/mathml mml 14 | text/plain txt 15 | text/vnd.sun.j2me.app-descriptor jad 16 | text/vnd.wap.wml wml 17 | text/x-component htc 18 | 19 | image/png png 20 | image/tiff tif tiff 21 | image/vnd.wap.wbmp wbmp 22 | image/x-icon ico 23 | image/x-jng jng 24 | image/x-ms-bmp bmp 25 | image/svg+xml svg svgz 26 | image/webp webp 27 | 28 | application/font-woff woff 29 | application/java-archive jar war ear 30 | application/json json 31 | application/mac-binhex40 hqx 32 | application/msword doc 33 | application/pdf pdf 34 | application/postscript ps eps ai 35 | application/rtf rtf 36 | application/vnd.apple.mpegurl m3u8 37 | application/vnd.ms-excel xls 38 | application/vnd.ms-fontobject eot 39 | application/vnd.ms-powerpoint ppt 40 | application/vnd.wap.wmlc wmlc 41 | application/vnd.google-earth.kml+xml kml 42 | application/vnd.google-earth.kmz kmz 43 | application/x-7z-compressed 7z 44 | application/x-cocoa cco 45 | application/x-java-archive-diff jardiff 46 | application/x-java-jnlp-file jnlp 47 | application/x-makeself run 48 | application/x-perl pl pm 49 | application/x-pilot prc pdb 50 | application/x-rar-compressed rar 51 | application/x-redhat-package-manager rpm 52 | application/x-sea sea 53 | application/x-shockwave-flash swf 54 | application/x-stuffit sit 55 | application/x-tcl tcl tk 56 | application/x-x509-ca-cert der pem crt 57 | application/x-xpinstall xpi 58 | application/xhtml+xml xhtml 59 | application/xspf+xml xspf 60 | application/zip zip 61 | 62 | application/octet-stream bin exe dll 63 | application/octet-stream deb 64 | application/octet-stream dmg 65 | application/octet-stream iso img 66 | application/octet-stream msi msp msm 67 | 68 | application/vnd.openxmlformats-officedocument.wordprocessingml.document docx 69 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx 70 | application/vnd.openxmlformats-officedocument.presentationml.presentation pptx 71 | 72 | audio/midi mid midi kar 73 | audio/mpeg mp3 74 | audio/ogg ogg 75 | audio/x-m4a m4a 76 | audio/x-realaudio ra 77 | 78 | video/3gpp 3gpp 3gp 79 | video/mp2t ts 80 | video/mp4 mp4 81 | video/mpeg mpeg mpg 82 | video/quicktime mov 83 | video/webm webm 84 | video/x-flv flv 85 | video/x-m4v m4v 86 | video/x-mng mng 87 | video/x-ms-asf asx asf 88 | video/x-ms-wmv wmv 89 | video/x-msvideo avi 90 | -------------------------------------------------------------------------------- /backend/docker/nginx-default.conf: -------------------------------------------------------------------------------- 1 | upstream django { 2 | server 127.0.0.1:8080; 3 | } 4 | 5 | server { 6 | listen 443; 7 | server_name platform.pennlabs.org; 8 | 9 | # FastCGI authorizer for Auth Request module 10 | location = /shibauthorizer { 11 | internal; 12 | include fastcgi_params; 13 | fastcgi_pass unix:/opt/shibboleth/shibauthorizer.sock; 14 | } 15 | 16 | # FastCGI responder 17 | location /Shibboleth.sso { 18 | include fastcgi_params; 19 | fastcgi_pass unix:/opt/shibboleth/shibresponder.sock; 20 | } 21 | 22 | # Resources for the Shibboleth error pages. This can be customised. 23 | location /shibboleth-sp { 24 | alias /usr/share/shibboleth/; 25 | } 26 | 27 | # Secured login page 28 | location /accounts/login { 29 | include shib_clear_headers; 30 | shib_request /shibauthorizer; 31 | shib_request_use_headers on; 32 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 33 | proxy_set_header X-Forwarded-Proto $scheme; 34 | proxy_set_header Host $http_host; 35 | proxy_redirect off; 36 | proxy_pass http://django; 37 | } 38 | 39 | location / { 40 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 41 | proxy_set_header X-Forwarded-Proto $scheme; 42 | proxy_set_header Host $http_host; 43 | proxy_redirect off; 44 | proxy_pass http://django; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /backend/docker/platform-run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Django Migrate 4 | /usr/bin/python3 /app/manage.py migrate --noinput 5 | 6 | # Run supervisor 7 | /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 8 | -------------------------------------------------------------------------------- /backend/docker/shib_clear_headers: -------------------------------------------------------------------------------- 1 | more_clear_input_headers 2 | Auth-Type 3 | Shib-Application-Id 4 | Shib-Authentication-Instant 5 | Shib-Authentication-Method 6 | Shib-Authncontext-Class 7 | Shib-Handler 8 | Shib-Identity-Provider 9 | Shib-Session-Expires 10 | Shib-Session-Id 11 | Shib-Session-Inactivity 12 | Shib-Session-Index 13 | Remote-User 14 | EPPN 15 | SN 16 | GivenName 17 | DisplayName 18 | Mail 19 | Unscoped-Affiliation 20 | Affiliation 21 | EmployeeNumber; 22 | -------------------------------------------------------------------------------- /backend/docker/shibboleth/attribute-map.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /backend/docker/shibboleth/metadata.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | upenn.edu 11 | 12 | 13 | 14 | 15 | 16 | 17 | MIIDQDCCAiigAwIBAgIVAIW7U17BF4OIuf7KKeJ2n7iZo4sLMA0GCSqGSIb3DQEB 18 | BQUAMCAxHjAcBgNVBAMTFWlkcC5wZW5ua2V5LnVwZW5uLmVkdTAeFw0xMTAzMzEx 19 | NTU0MDRaFw0zMTAzMzExNTU0MDRaMCAxHjAcBgNVBAMTFWlkcC5wZW5ua2V5LnVw 20 | ZW5uLmVkdTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIEhMlhqtKBa 21 | i3JwvaN1iMN6t9WUk8jRfd34HrDIpMkziZeVobbwdBhO2Rj3568dnsKlVNEaj7Zr 22 | 3Rf2yUzqb3VfjkW0bLDX0hiJDxogQH5cL2q8cl8jNpFjU40ptKbY5VTFkrR9YAfb 23 | 09mefQcyB5kvFoR8RASSw+9Ea+D1HKEEOaCyy2miwZVdvrCC4sAlsVX9kdaUwo4p 24 | o7dMpXKEjXEkByGKBh7VHB23OYaSC0gOvcOBy4dYjP3FqL4u8Yk3h9Ir6d3raGCl 25 | RsdPzH/kHrYbkuWT4pS5b41Ptrjal6mbGK+pKLGIkld5a9sipbjh3cwXm5nFpOTE 26 | OEWdmBEJkuECAwEAAaNxMG8wTgYDVR0RBEcwRYIVaWRwLnBlbm5rZXkudXBlbm4u 27 | ZWR1hixodHRwczovL2lkcC5wZW5ua2V5LnVwZW5uLmVkdS9pZHAvc2hpYmJvbGV0 28 | aDAdBgNVHQ4EFgQUxDTQGrw4/7tu0/9D7BGoULqcWL4wDQYJKoZIhvcNAQEFBQAD 29 | ggEBAEkaTyQ3eC8thudSbBAh7bWADu2coDnw0FuWwcmI9ZbVHVU+HKbij5k5phFX 30 | DZaSTlZIwNkAeV4QTLS15TWmgsdaIxBBKfTfZJNXskfg6++2n91n4BfcDPFdjfn9 31 | sfp4DKK1/2es+OtgLQVIM1lMU3ZzNGaSr/6UhF5zvY+M1RpxwG3//nBm8y2rOAt7 32 | Y/REplQZ1ZwSoTxRxPhDa/Hflq+6mzWGdyCYDdq2Nn4Qk0bMnsNvZj3svVJeBfiG 33 | lnWwaH354x1lW83hhH/URqtxrgkftZ/oUVZCUruU3b5ytcHOYs/vXRTkRFsnb/EN 34 | iWe0xy1RO5prB/x5xli9fGaUdwE= 35 | 36 | 37 | 38 | 39 | 42 | 43 | 45 | 46 | 48 | 49 | -------------------------------------------------------------------------------- /backend/docker/shibboleth/shibboleth2.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 34 | 36 | 37 | 42 | 43 | SAML2 44 | 45 | 46 | 47 | SAML2 Local 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 73 | 74 | 75 | 78 | 79 | 80 | 81 | 93 | 94 | 95 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 115 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /backend/docker/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/var/log/supervisor/supervisord.log 4 | pidfile=/var/run/supervisord.pid 5 | 6 | [fcgi-program:shibauthorizer] 7 | command=/usr/lib/x86_64-linux-gnu/shibboleth/shibauthorizer 8 | socket=unix:///opt/shibboleth/shibauthorizer.sock 9 | socket_owner=_shibd:_shibd 10 | socket_mode=0660 11 | user=_shibd 12 | stdout_logfile=/dev/stdout 13 | redirect_stderr=true 14 | stdout_logfile_maxbytes=0 15 | autorestart = true 16 | 17 | [fcgi-program:shibresponder] 18 | command=/usr/lib/x86_64-linux-gnu/shibboleth/shibresponder 19 | socket=unix:///opt/shibboleth/shibresponder.sock 20 | socket_owner=_shibd:_shibd 21 | socket_mode=0660 22 | user=_shibd 23 | stdout_logfile=/dev/stdout 24 | redirect_stderr=true 25 | stdout_logfile_maxbytes=0 26 | autorestart = true 27 | 28 | [program:shibd] 29 | command=/usr/sbin/shibd -f -F 30 | stdout_logfile=/dev/stdout 31 | redirect_stderr=true 32 | stdout_logfile_maxbytes=0 33 | autorestart=true 34 | 35 | [program:nginx] 36 | command=/usr/sbin/nginx -g "daemon off;" 37 | stdout_logfile=/dev/stdout 38 | redirect_stderr=true 39 | stdout_logfile_maxbytes=0 40 | autorestart=true 41 | 42 | [program:platform] 43 | command=/usr/local/bin/uwsgi --ini /app/setup.cfg 44 | stdout_logfile=/dev/stdout 45 | redirect_stderr=true 46 | stdout_logfile_maxbytes=0 47 | autorestart=true 48 | -------------------------------------------------------------------------------- /backend/health/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HealthConfig(AppConfig): 5 | name = "health" 6 | -------------------------------------------------------------------------------- /backend/health/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HealthConfig(AppConfig): 5 | name = "health" 6 | -------------------------------------------------------------------------------- /backend/health/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from health.views import HealthView 3 | 4 | 5 | app_name = "health" 6 | 7 | urlpatterns = [ 8 | path("backend/", HealthView.as_view(), name="backend"), 9 | ] 10 | -------------------------------------------------------------------------------- /backend/health/views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.http import JsonResponse 4 | from django.views.generic import View 5 | 6 | 7 | class HealthView(View): 8 | def get(self, request): 9 | """ 10 | Health check endpoint to confirm the backend is running. 11 | --- 12 | summary: Health Check 13 | responses: 14 | "200": 15 | content: 16 | application/json: 17 | schema: 18 | type: object 19 | properties: 20 | message: 21 | type: string 22 | enum: ["OK"] 23 | --- 24 | """ 25 | return JsonResponse({"message": "OK"}, status=HTTPStatus.OK) 26 | -------------------------------------------------------------------------------- /backend/identity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/identity/__init__.py -------------------------------------------------------------------------------- /backend/identity/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class IdentityConfig(AppConfig): 5 | name = "identity" 6 | -------------------------------------------------------------------------------- /backend/identity/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from identity.views import AttestView, JwksInfoView, RefreshJWTView 3 | 4 | 5 | app_name = "identity" 6 | 7 | 8 | urlpatterns = [ 9 | path("jwks/", JwksInfoView.as_view(), name="jwks"), 10 | path("attest/", AttestView.as_view(), name="attest"), 11 | path("refresh/", RefreshJWTView.as_view(), name="refresh"), 12 | ] 13 | -------------------------------------------------------------------------------- /backend/identity/utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from django.conf import settings 4 | from jwcrypto import jwk, jwt 5 | 6 | 7 | SIGNING_ALG = "RS256" 8 | EXPIRY_TIME = 60 * 60 # 60 minutes 9 | ID_PRIVATE_KEY = jwk.JWK.from_pem(settings.IDENTITY_RSA_PRIVATE_KEY.encode("utf-8")) 10 | 11 | 12 | def mint_access_jwt(key: jwk.JWK, urn: str) -> jwt.JWT: 13 | """ 14 | Mint a JWT with the following claims: 15 | - use -> access - this says that this JWT is strictly an access JWT 16 | - iat -> now - this says that this JWT isn't active until the current time. 17 | this protects us from attacks from clock skew 18 | - exp -> expiry_time - this makes sure our JWT is only valid for EXPIRY_TIME 19 | """ 20 | now = time.time() 21 | expiry_time = now + EXPIRY_TIME 22 | token = jwt.JWT( 23 | header={"alg": SIGNING_ALG}, 24 | claims={"sub": urn, "use": "access", "iat": now, "exp": expiry_time}, 25 | ) 26 | token.make_signed_token(key) 27 | return token 28 | 29 | 30 | def mint_refresh_jwt(key: jwk.JWK, urn: str) -> jwt.JWT: 31 | """ 32 | Mint a JWT with the following claims: 33 | - use -> refresh - this says that this JWT is strictly a refresh JWT 34 | - iat -> now - this says that this JWT isn't active until the current time. 35 | this protects us from attacks from clock skew 36 | - no exp claim because refresh JWTs do not expire 37 | """ 38 | now = time.time() 39 | token = jwt.JWT( 40 | header={"alg": SIGNING_ALG}, claims={"sub": urn, "use": "refresh", "iat": now} 41 | ) 42 | token.make_signed_token(key) 43 | return token 44 | -------------------------------------------------------------------------------- /backend/identity/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | 4 | from django.http import JsonResponse 5 | from django.utils.decorators import method_decorator 6 | from django.utils.text import slugify 7 | from django.views.decorators.csrf import csrf_exempt 8 | from django.views.generic import View 9 | from identity.utils import ( 10 | ID_PRIVATE_KEY, 11 | SIGNING_ALG, 12 | mint_access_jwt, 13 | mint_refresh_jwt, 14 | ) 15 | from jwcrypto import jwt 16 | from oauth2_provider.settings import oauth2_settings 17 | from oauth2_provider.views.mixins import OAuthLibMixin 18 | 19 | 20 | @method_decorator(csrf_exempt, name="dispatch") 21 | class AttestView(OAuthLibMixin, View): 22 | """ 23 | Implements an endpoint to attest with client id + client secret 24 | 25 | This endpoint returns a refresh JWT with an unlimited lifetime and an access JWT with 26 | a short lifetime. 27 | """ 28 | 29 | server_class = oauth2_settings.OAUTH2_SERVER_CLASS 30 | validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS 31 | oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS 32 | 33 | def post(self, request, *args, **kwargs): 34 | request.client = None 35 | 36 | # Taken from https://github.com/jazzband/django-oauth-toolkit/blob/41aa49e6d7fad0a392b1557d1ef6d40ccc444f7e/oauth2_provider/oauth2_backends.py#L178 # noqa 37 | # Normally would use self.authenticate_client(), however since that constructs 38 | # a new request object, we don't gain access to the underlying client. 39 | self.request.headers = self.get_oauthlib_core().extract_headers(self.request) 40 | authenticated = self.get_server().request_validator.authenticate_client( 41 | request, *args, **kwargs 42 | ) 43 | if not authenticated: 44 | return JsonResponse( 45 | data={"detail": "Missing or invalid credentials."}, 46 | status=HTTPStatus.UNAUTHORIZED, 47 | ) 48 | 49 | # pulls out name as recorded in DOT application database 50 | local_name = slugify(request.client.name) 51 | # example urn: `urn:pennlabs:platform` 52 | urn = f"urn:pennlabs:{local_name}" 53 | return JsonResponse( 54 | data={ 55 | "access": mint_access_jwt(ID_PRIVATE_KEY, urn).serialize(), 56 | "refresh": mint_refresh_jwt(ID_PRIVATE_KEY, urn).serialize(), 57 | } 58 | ) 59 | 60 | 61 | class JwksInfoView(View): 62 | """ 63 | View used to show json web key set document 64 | 65 | Largely copied from the Django Oauth Toolkit implementation: 66 | https://github.com/jazzband/django-oauth-toolkit/blob/4655c030be15616ba6e0872253a2c15a897d9701/oauth2_provider/views/oidc.py#L61 # noqa 67 | """ 68 | 69 | # building out JWKS view at init time so we don't have to recalculate it for each request 70 | def __init__(self, **kwargs): 71 | data = { 72 | "keys": [ 73 | {"alg": SIGNING_ALG, "use": "sig", "kid": ID_PRIVATE_KEY.thumbprint()} 74 | ] 75 | } 76 | data["keys"][0].update(json.loads(ID_PRIVATE_KEY.export_public())) 77 | response = JsonResponse(data) 78 | response["Access-Control-Allow-Origin"] = "*" 79 | self.jwks_response = response 80 | 81 | super().__init__(**kwargs) 82 | 83 | def get(self, request, *args, **kwargs): 84 | return self.jwks_response 85 | 86 | 87 | @method_decorator(csrf_exempt, name="dispatch") 88 | class RefreshJWTView(View): 89 | """ 90 | View used for refreshing access JWTs 91 | """ 92 | 93 | def post(self, request, *args, **kwargs): 94 | auth_header = request.META.get("HTTP_AUTHORIZATION") 95 | if auth_header is None: 96 | return JsonResponse( 97 | data={"detail": "Authentication credentials were not provided."}, 98 | status=HTTPStatus.UNAUTHORIZED, 99 | ) 100 | split_header = auth_header.split(" ") 101 | if len(split_header) < 2 or split_header[0] != "Bearer": 102 | return JsonResponse( 103 | data={"detail": "No Bearer token present in Authorization header."}, 104 | status=HTTPStatus.UNAUTHORIZED, 105 | ) 106 | try: 107 | # this line will decode and validate the JWT, raising an exception for 108 | # anything that cannot be decoded or validated 109 | refresh_jwt = jwt.JWT(key=ID_PRIVATE_KEY, jwt=split_header[1]) 110 | claims = json.loads(refresh_jwt.claims) 111 | if "use" not in claims or claims["use"] != "refresh": 112 | return JsonResponse( 113 | data={"detail": "Invalid JWT. Must provide a valid refresh JWT."}, 114 | status=HTTPStatus.BAD_REQUEST, 115 | ) 116 | urn = claims["sub"] 117 | new_access_jwt = mint_access_jwt(ID_PRIVATE_KEY, urn) 118 | return JsonResponse(data={"access": new_access_jwt.serialize()}) 119 | except Exception as e: 120 | return JsonResponse( 121 | data={"detail": f"Invalid JWT: {e}"}, 122 | status=HTTPStatus.BAD_REQUEST, 123 | ) 124 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | if __name__ == "__main__": 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Platform.settings.development") 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError as exc: 11 | raise ImportError( 12 | "Couldn't import Django. Are you sure it's installed and " 13 | "available on your PYTHONPATH environment variable? Did you " 14 | "forget to activate a virtual environment?" 15 | ) from exc 16 | execute_from_command_line(sys.argv) 17 | -------------------------------------------------------------------------------- /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 = accounts, Platform 9 | line_length = 88 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/*, */wsgi.py, */apps.py, */admin.py, */.venv/*, manage.py 17 | source = . 18 | 19 | [uwsgi] 20 | http-socket = :8080 21 | chdir = /app/ 22 | module = Platform.wsgi:application 23 | master = true 24 | static-map = /assets=/app/static 25 | processes = 5 26 | -------------------------------------------------------------------------------- /backend/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/tests/__init__.py -------------------------------------------------------------------------------- /backend/tests/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/tests/accounts/__init__.py -------------------------------------------------------------------------------- /backend/tests/accounts/test_admin.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import skipIf 3 | 4 | from django.contrib.admin.sites import AdminSite 5 | from django.contrib.auth import get_user_model 6 | from django.test import TestCase 7 | from django.urls import reverse 8 | 9 | from accounts.admin import StudentAdmin 10 | from accounts.models import Student, User 11 | 12 | 13 | class StudentAdminTestCase(TestCase): 14 | def setUp(self): 15 | self.user = User.objects.create( 16 | pennid=1, username="user", first_name="First", last_name="Last" 17 | ) 18 | self.student = self.user.student 19 | self.student_admin = StudentAdmin(Student, AdminSite()) 20 | 21 | def test_username(self): 22 | self.assertEqual(self.student_admin.username(self.student), self.user.username) 23 | 24 | def test_first_name(self): 25 | self.assertEqual( 26 | self.student_admin.first_name(self.student), self.user.first_name 27 | ) 28 | 29 | def test_last_name(self): 30 | self.assertEqual( 31 | self.student_admin.last_name(self.student), self.user.last_name 32 | ) 33 | 34 | 35 | class LabsAdminTestCase(TestCase): 36 | check = ( 37 | os.environ.get("DJANGO_SETTINGS_MODULE", "") == "Platform.settings.development" 38 | ) 39 | 40 | @skipIf(check, "This test doesn't matter in development") 41 | def test_admin_not_logged_in(self): 42 | response = self.client.get(reverse("admin:login") + "?next=/admin/") 43 | redirect = reverse("accounts:login") + "?next=/admin/" 44 | self.assertRedirects(response, redirect, fetch_redirect_response=False) 45 | 46 | def test_admin_logged_in(self): 47 | get_user_model().objects.create_user( 48 | pennid=1, username="user", password="password", is_staff=True 49 | ) 50 | self.client.login(username="user", password="password") 51 | response = self.client.get(reverse("admin:login") + "?next=/admin/") 52 | redirect = "/admin/" 53 | self.assertRedirects(response, redirect, fetch_redirect_response=False) 54 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_backends.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from django.contrib import auth 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import Group 6 | from django.test import TestCase 7 | 8 | from accounts.backends import ShibbolethRemoteUserBackend 9 | from accounts.models import Student 10 | 11 | 12 | class BackendTestCase(TestCase): 13 | def setUp(self): 14 | self.shibboleth_attributes = { 15 | "username": "user", 16 | "first_name": "", 17 | "last_name": "", 18 | "affiliation": [], 19 | } 20 | 21 | def test_invalid_remote_user(self): 22 | user = auth.authenticate( 23 | remote_user=-1, shibboleth_attributes=self.shibboleth_attributes 24 | ) 25 | self.assertIsNone(user) 26 | 27 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 28 | def test_empty_shibboleth_attributes(self, mock_get_email): 29 | mock_get_email.return_value = None 30 | user = auth.authenticate( 31 | remote_user=1, shibboleth_attributes=self.shibboleth_attributes 32 | ) 33 | self.assertEqual(user.pennid, 1) 34 | self.assertEqual(user.first_name, "") 35 | self.assertEqual(user.emails.count(), 1) 36 | self.assertEqual( 37 | user.emails.all()[0].value, 38 | f"{self.shibboleth_attributes['username']}@upenn.edu", 39 | ) 40 | 41 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 42 | def test_create_user(self, mock_get_email): 43 | mock_get_email.return_value = None 44 | auth.authenticate( 45 | remote_user=1, shibboleth_attributes=self.shibboleth_attributes 46 | ) 47 | self.assertEqual(len(get_user_model().objects.all()), 1) 48 | user = get_user_model().objects.all()[0] 49 | self.assertEqual(user.pennid, 1) 50 | self.assertEqual(user.emails.count(), 1) 51 | self.assertEqual(user.emails.all()[0].value, "user@upenn.edu") 52 | 53 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 54 | def test_create_user_with_attributes(self, mock_get_email): 55 | mock_get_email.return_value = None 56 | attributes = { 57 | "username": "user", 58 | "first_name": "test", 59 | "last_name": "user", 60 | "affiliation": ["student", "member"], 61 | } 62 | student_affiliation = Group.objects.create(name="student") 63 | user = auth.authenticate(remote_user=1, shibboleth_attributes=attributes) 64 | self.assertEqual(user.first_name, "test") 65 | self.assertEqual(user.last_name, "user") 66 | self.assertEqual(user.groups.get(name="student"), student_affiliation) 67 | self.assertEqual( 68 | user.groups.get(name="member"), Group.objects.get(name="member") 69 | ) 70 | self.assertEqual(len(user.groups.all()), 2) 71 | self.assertEqual(len(Group.objects.all()), 2) 72 | 73 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 74 | def test_update_user_with_attributes(self, mock_get_email): 75 | mock_get_email.return_value = None 76 | attributes = { 77 | "username": "user", 78 | "first_name": "test", 79 | "last_name": "user", 80 | "affiliation": [], 81 | } 82 | user = auth.authenticate(remote_user=1, shibboleth_attributes=attributes) 83 | self.assertEqual(user.username, "user") 84 | attributes["username"] = "changed_user" 85 | user = auth.authenticate(remote_user=1, shibboleth_attributes=attributes) 86 | self.assertEqual(user.username, "changed_user") 87 | 88 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 89 | def test_login_user(self, mock_get_email): 90 | mock_get_email.return_value = None 91 | student = get_user_model().objects.create_user( 92 | pennid=1, username="student", password="secret" 93 | ) 94 | user = auth.authenticate( 95 | remote_user=1, shibboleth_attributes=self.shibboleth_attributes 96 | ) 97 | self.assertEqual(user, student) 98 | 99 | @patch("accounts.backends.ShibbolethRemoteUserBackend.get_email") 100 | def test_create_student_object(self, mock_get_email): 101 | mock_get_email.return_value = None 102 | attributes = { 103 | "username": "user", 104 | "first_name": "test", 105 | "last_name": "user", 106 | "affiliation": ["student"], 107 | } 108 | user = auth.authenticate(remote_user=1, shibboleth_attributes=attributes) 109 | self.assertEqual(len(Student.objects.filter(user=user)), 1) 110 | 111 | @patch("accounts.backends.requests.post") 112 | @patch("accounts.backends.requests.get") 113 | def test_get_email_exists(self, mock_get, mock_post): 114 | mock_response_get = MagicMock() 115 | mock_response_get.status_code = 200 116 | mock_response_get.json.return_value = { 117 | "result_data": [{"email": "test@example.com"}] 118 | } 119 | mock_get.return_value = mock_response_get 120 | 121 | mock_response_post = MagicMock() 122 | mock_response_post.status_code = 200 123 | mock_response_post.json.return_value = {"access_token": "my-access-token"} 124 | mock_post.return_value = mock_response_post 125 | 126 | backend = ShibbolethRemoteUserBackend() 127 | self.assertEqual(backend.get_email(1), "test@example.com") 128 | 129 | @patch("accounts.backends.requests.post") 130 | @patch("accounts.backends.requests.get") 131 | def test_get_email_no_exists(self, mock_get, mock_post): 132 | mock_response_get = MagicMock() 133 | mock_response_get.status_code = 200 134 | mock_response_get.json.return_value = {"result_data": []} 135 | mock_get.return_value = mock_response_get 136 | 137 | mock_response_post = MagicMock() 138 | mock_response_post.status_code = 200 139 | mock_response_post.json.return_value = {"access_token": "my-access-token"} 140 | mock_post.return_value = mock_response_post 141 | 142 | backend = ShibbolethRemoteUserBackend() 143 | self.assertEqual(backend.get_email(1), None) 144 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_commands.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management import call_command 3 | from django.test import TestCase 4 | 5 | from accounts.models import Major, School, Student 6 | 7 | 8 | class UpdateMajorsTestCase(TestCase): 9 | def test_update_academics(self): 10 | call_command("update_academics") 11 | self.assertNotEquals(0, Major.objects.all().count()) 12 | self.assertNotEquals(0, School.objects.all().count()) 13 | 14 | 15 | class PopulateUsersTestCase(TestCase): 16 | def test_populate_users(self): 17 | call_command("populate_users") 18 | self.assertTrue(get_user_model().objects.all().count() > 0) 19 | self.assertTrue(Major.objects.all().count() > 0) 20 | self.assertTrue(Student.objects.all().count() > 0) 21 | 22 | def test_populate_twice(self): 23 | call_command("populate_users") 24 | call_command("populate_users") 25 | self.assertEqual(get_user_model().objects.all().count(), 10) 26 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | 4 | from accounts.models import Email, Major, PhoneNumber, School 5 | 6 | 7 | class MajorModelTestCase(TestCase): 8 | def setUp(self): 9 | self.user = get_user_model().objects.create_user( 10 | pennid=1, 11 | username="student", 12 | first_name="first", 13 | last_name="last", 14 | password="secret", 15 | ) 16 | 17 | self.major_name = "Test Major" 18 | self.major = Major.objects.create(name=self.major_name) 19 | 20 | def test_str(self): 21 | self.assertEqual(str(self.major), f"{self.major_name}") 22 | 23 | 24 | class SchoolModelTestCase(TestCase): 25 | def setUp(self): 26 | self.user = get_user_model().objects.create_user( 27 | pennid=1, 28 | username="student", 29 | first_name="first", 30 | last_name="last", 31 | password="secret", 32 | ) 33 | 34 | self.school_name = "Test School" 35 | self.school = School.objects.create(name=self.school_name) 36 | 37 | def test_str(self): 38 | self.assertEqual(str(self.school_name), f"{self.school}") 39 | 40 | 41 | class StudentTestCase(TestCase): 42 | def setUp(self): 43 | self.user = get_user_model().objects.create_user( 44 | pennid=1, username="student", password="secret" 45 | ) 46 | 47 | def test_str(self): 48 | self.assertEqual(str(self.user.student), self.user.username) 49 | 50 | 51 | class UserTestCase(TestCase): 52 | def setUp(self): 53 | self.user = get_user_model().objects.create_user( 54 | pennid=1, 55 | username="student", 56 | first_name="first", 57 | last_name="last", 58 | password="secret", 59 | ) 60 | 61 | self.user2 = get_user_model().objects.create_user( 62 | pennid=2, 63 | username="student2", 64 | password="secret2", 65 | first_name="first2", 66 | last_name="last2", 67 | preferred_name="prefer", 68 | ) 69 | 70 | def test_get_preferred_name_none(self): 71 | self.assertEqual(self.user.get_preferred_name(), "first") 72 | 73 | def test_get_preferred_name_with_preferred(self): 74 | self.assertEqual(self.user2.get_preferred_name(), "prefer") 75 | 76 | 77 | class PhoneNumberModelTestCase(TestCase): 78 | def setUp(self): 79 | self.user = get_user_model().objects.create_user( 80 | pennid=1, 81 | username="student", 82 | first_name="first", 83 | last_name="last", 84 | password="secret", 85 | ) 86 | 87 | self.phone_number = "+15550000000" 88 | self.number = PhoneNumber.objects.create( 89 | user=self.user, value=self.phone_number, primary=True, verified=False 90 | ) 91 | 92 | def test_str(self): 93 | self.assertEqual(str(self.number), f"{self.user} - {self.phone_number}") 94 | 95 | 96 | class EmailTestCase(TestCase): 97 | def setUp(self): 98 | self.user = get_user_model().objects.create_user( 99 | pennid=1, 100 | username="student", 101 | first_name="first", 102 | last_name="last", 103 | password="secret", 104 | ) 105 | 106 | self.email_address = "example@example.com" 107 | self.email = Email.objects.create( 108 | user=self.user, value=self.email_address, primary=True, verified=False 109 | ) 110 | 111 | def test_str(self): 112 | self.assertEqual(str(self.email), f"{self.user} - {self.email_address}") 113 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_pfp.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/tests/accounts/test_pfp.jpg -------------------------------------------------------------------------------- /backend/tests/accounts/test_pfp_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/tests/accounts/test_pfp_large.png -------------------------------------------------------------------------------- /backend/tests/accounts/test_update_majors.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from accounts.models import Major 6 | from accounts.update_majors import update_all_majors 7 | 8 | 9 | @patch("accounts.update_majors.requests.get") 10 | class UpdateMajorsTestCase(TestCase): 11 | def setUp(self): 12 | with open(r"./tests/accounts/PennCoursePrograms.html", "r") as f: 13 | self.html = f.read() 14 | 15 | def testTotalMajorCount(self, mock_source_file): 16 | mock_source_file.return_value.text = self.html 17 | update_all_majors() 18 | 19 | self.assertEquals(Major.objects.all().count(), 469) 20 | 21 | def testBachelorMajorCount(self, mock_source_file): 22 | mock_source_file.return_value.text = self.html 23 | update_all_majors() 24 | 25 | self.assertEquals(Major.objects.filter(degree_type="BACHELORS").count(), 215) 26 | 27 | def testMasterCount(self, mock_source_file): 28 | mock_source_file.return_value.text = self.html 29 | update_all_majors() 30 | 31 | self.assertEquals(Major.objects.filter(degree_type="MASTERS").count(), 123) 32 | 33 | def testProfessionalCount(self, mock_source_file): 34 | mock_source_file.return_value.text = self.html 35 | update_all_majors() 36 | 37 | self.assertEquals(Major.objects.filter(degree_type="PROFESSIONAL").count(), 47) 38 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_update_schools.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | 5 | from accounts.models import School 6 | from accounts.update_schools import update_all_schools 7 | 8 | 9 | @patch("accounts.update_schools.requests.get") 10 | class UpdateMajorsTestCase(TestCase): 11 | def testTotalSchoolCount(self, mock_source_file): 12 | with open(r"./tests/accounts/PennCoursePrograms.html", "r") as f: 13 | mock_source_file.return_value.text = f.read() 14 | 15 | update_all_schools() 16 | 17 | self.assertEquals(School.objects.all().count(), 12) 18 | -------------------------------------------------------------------------------- /backend/tests/accounts/test_verification.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | from twilio.base.exceptions import TwilioRestException 5 | 6 | from accounts.verification import sendEmailVerification, sendSMSVerification 7 | 8 | 9 | class sendEmailVerificationTestCase(TestCase): 10 | @patch("accounts.verification.send_email") 11 | def test_(self, mock_send_email): 12 | sendEmailVerification("+15555555555", "000000") 13 | mock_send_email.assert_called() 14 | self.assertEqual(1, len(mock_send_email.mock_calls)) 15 | expected = {"verification_code": "000000"} 16 | self.assertEqual( 17 | "emails/email_verification.html", mock_send_email.call_args[0][0] 18 | ) 19 | self.assertEqual(expected, mock_send_email.call_args[0][1]) 20 | self.assertEqual( 21 | "Penn Labs email verification", mock_send_email.call_args[0][2] 22 | ) 23 | self.assertEqual("+15555555555", mock_send_email.call_args[0][3]) 24 | 25 | 26 | class sendSMSVerificationTestCase(TestCase): 27 | @patch("accounts.verification.capture_message") 28 | def test_invalid_client(self, mock_sentry): 29 | sendSMSVerification("+15555555555", "000000") 30 | mock_sentry.assert_called() 31 | self.assertEqual(1, len(mock_sentry.mock_calls)) 32 | expected = {"level": "error"} 33 | self.assertEqual(expected, mock_sentry.call_args[1]) 34 | 35 | @patch("accounts.verification.capture_message") 36 | @patch("accounts.verification.Client") 37 | def test_rest_exception(self, mock_client, mock_sentry): 38 | mock_client.return_value.messages.create.side_effect = TwilioRestException( 39 | "", "" 40 | ) 41 | sendSMSVerification("+15555555555", "000000") 42 | mock_sentry.assert_called() 43 | self.assertEqual(1, len(mock_sentry.mock_calls)) 44 | expected = {"level": "error"} 45 | self.assertEqual(expected, mock_sentry.call_args[1]) 46 | 47 | @patch("accounts.verification.Client") 48 | def test_send_sms(self, mock_client): 49 | sendSMSVerification("+15555555555", "000000") 50 | mock_client.assert_called() 51 | mock_calls = mock_client.mock_calls 52 | self.assertEqual(2, len(mock_calls)) 53 | expected = { 54 | "to": "+15555555555", 55 | "body": "Your Penn Labs Account Verification Code is: 000000", 56 | "from_": "", 57 | } 58 | self.assertEqual(expected, mock_client.mock_calls[1][2]) 59 | -------------------------------------------------------------------------------- /backend/tests/announcements/test_models.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement, Audience 2 | from django.test import TestCase 3 | from django.utils import timezone 4 | 5 | 6 | class AudienceTestCase(TestCase): 7 | def setUp(self): 8 | self.audience_name = "CLUBS" 9 | self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) 10 | 11 | def test_str(self): 12 | self.assertEqual(str(self.audience), self.audience_name) 13 | 14 | 15 | class AnnouncementTestCase(TestCase): 16 | def setUp(self): 17 | self.audience_name = "CLUBS" 18 | self.title = "Test Announcement" 19 | self.message = "This is a test" 20 | self.announcement_type = "Issue" 21 | self.release_time = timezone.datetime( 22 | year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() 23 | ) 24 | self.end_time = timezone.datetime( 25 | year=3001, month=1, day=1, tzinfo=timezone.get_current_timezone() 26 | ) 27 | self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) 28 | self.announcement = Announcement.objects.create( 29 | title=self.title, 30 | message=self.message, 31 | announcement_type=Announcement.ANNOUNCEMENT_ISSUE, 32 | release_time=self.release_time, 33 | end_time=self.end_time, 34 | ) 35 | self.announcement.audiences.add(self.audience) 36 | 37 | def test_str(self): 38 | self.assertEqual( 39 | str(self.announcement), 40 | f"[{self.announcement_type} for {self.audience_name}] \ 41 | starting at {self.release_time.strftime('%m-%d-%Y %H:%M:%S')} to \ 42 | {self.end_time.strftime('%m-%d-%Y %H:%M:%S')} | {self.title}: {self.message}", 43 | ) 44 | -------------------------------------------------------------------------------- /backend/tests/announcements/test_populate_audiences.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Audience 2 | from django.core.management import call_command 3 | from django.test import TestCase 4 | 5 | 6 | class PopulateAudiencesTestCase(TestCase): 7 | def test_populate_audiences(self): 8 | call_command("populate_audiences") 9 | self.assertTrue(Audience.objects.all().count() > 0) 10 | 11 | def test_populate_twice(self): 12 | call_command("populate_audiences") 13 | count = Audience.objects.all().count() 14 | call_command("populate_audiences") 15 | self.assertEqual(Audience.objects.all().count(), count) 16 | -------------------------------------------------------------------------------- /backend/tests/announcements/test_serializers.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement, Audience 2 | from announcements.serializers import AnnouncementSerializer, AudienceSerializer 3 | from django.test import TestCase 4 | from django.utils import timezone 5 | 6 | 7 | class AudienceSerializerTestCase(TestCase): 8 | def setUp(self): 9 | self.audience = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) 10 | self.serializer = AudienceSerializer(self.audience) 11 | 12 | def test_serializer(self): 13 | data = {"name": self.audience.name} 14 | self.assertEqual(self.serializer.data, data) 15 | 16 | 17 | class AnnouncementSerializerTestCase(TestCase): 18 | def setUp(self): 19 | self.audience_clubs = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) 20 | self.audience_ohq = Audience.objects.create(name=Audience.AUDIENCE_OHQ) 21 | self.announcement = Announcement.objects.create( 22 | title="Test message", 23 | message="This is a test", 24 | announcement_type=Announcement.ANNOUNCEMENT_NOTICE, 25 | release_time=timezone.datetime( 26 | year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() 27 | ), 28 | ) 29 | self.announcement.audiences.add(self.audience_clubs) 30 | self.announcement.audiences.add(self.audience_ohq) 31 | self.serializer = AnnouncementSerializer(self.announcement) 32 | 33 | def test_serializer(self): 34 | data = { 35 | "id": self.announcement.id, 36 | "title": self.announcement.title, 37 | "message": self.announcement.message, 38 | "announcement_type": self.announcement.announcement_type, 39 | "release_time": self.announcement.release_time.isoformat(), 40 | "end_time": None, 41 | "audiences": [Audience.AUDIENCE_CLUBS, Audience.AUDIENCE_OHQ], 42 | } 43 | self.assertEqual(self.serializer.data, data) 44 | -------------------------------------------------------------------------------- /backend/tests/announcements/test_views.py: -------------------------------------------------------------------------------- 1 | from announcements.models import Announcement, Audience 2 | from announcements.serializers import AnnouncementSerializer 3 | from django.contrib.auth import get_user_model 4 | from django.test import Client, TestCase 5 | from django.utils import timezone 6 | 7 | 8 | class AnnouncementsFilterTestCase(TestCase): 9 | def setUp(self): 10 | self.client = Client() 11 | self.audience_clubs = Audience.objects.create(name=Audience.AUDIENCE_CLUBS) 12 | self.audience_ohq = Audience.objects.create(name=Audience.AUDIENCE_OHQ) 13 | self.announcement1 = Announcement.objects.create( 14 | title="Test message", 15 | message="This is a test", 16 | announcement_type=Announcement.ANNOUNCEMENT_NOTICE, 17 | release_time=timezone.datetime( 18 | year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() 19 | ), 20 | end_time=timezone.datetime( 21 | year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() 22 | ), 23 | ) 24 | self.announcement2 = Announcement.objects.create( 25 | title="Test message 2", 26 | message="This is also a test", 27 | announcement_type=Announcement.ANNOUNCEMENT_NOTICE, 28 | end_time=timezone.datetime( 29 | year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() 30 | ), 31 | ) 32 | self.announcement1.audiences.add(self.audience_clubs) 33 | self.announcement2.audiences.add(self.audience_ohq) 34 | 35 | def test_get_active(self): 36 | response = self.client.get("/announcements/") 37 | announcement3 = Announcement.objects.create( 38 | title="Test message 3", 39 | message="This is yet another a test", 40 | announcement_type=Announcement.ANNOUNCEMENT_NOTICE, 41 | release_time=timezone.datetime( 42 | year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() 43 | ), 44 | end_time=timezone.datetime( 45 | year=1001, month=12, day=31, tzinfo=timezone.get_current_timezone() 46 | ), 47 | ) 48 | announcement3.audiences.add(self.audience_ohq) 49 | self.assertIn(AnnouncementSerializer(self.announcement1).data, response.json()) 50 | self.assertIn(AnnouncementSerializer(self.announcement2).data, response.json()) 51 | self.assertNotIn(AnnouncementSerializer(announcement3).data, response.json()) 52 | 53 | def test_filter_audience(self): 54 | response = self.client.get("/announcements/?audience=clubs") 55 | self.assertIn(AnnouncementSerializer(self.announcement1).data, response.json()) 56 | self.assertNotIn( 57 | AnnouncementSerializer(self.announcement2).data, response.json() 58 | ) 59 | 60 | 61 | class AnnouncementsPermissionTestCase(TestCase): 62 | def setUp(self): 63 | self.client = Client() 64 | 65 | def test_invalid_permission(self): 66 | response = self.client.post( 67 | "/announcements/", 68 | { 69 | "title": "Maintenance Alert", 70 | "message": "We apologize for any inconvenience caused.", 71 | "audiences": ["CLUBS", "COURSE_PLAN", "COURSE_ALERT"], 72 | }, 73 | ) 74 | self.assertEqual(response.status_code, 403) 75 | 76 | 77 | class AnnouncementsModifyTestCase(TestCase): 78 | def setUp(self): 79 | self.client = Client() 80 | for audience_name, _ in Audience.AUDIENCE_CHOICES: 81 | Audience.objects.get_or_create(name=audience_name) 82 | self.announcement = Announcement.objects.create( 83 | title="Test message", 84 | message="This is a test", 85 | announcement_type=Announcement.ANNOUNCEMENT_NOTICE, 86 | release_time=timezone.datetime( 87 | year=1000, month=12, day=31, tzinfo=timezone.get_current_timezone() 88 | ), 89 | end_time=timezone.datetime( 90 | year=3000, month=12, day=31, tzinfo=timezone.get_current_timezone() 91 | ), 92 | ) 93 | self.user = get_user_model().objects.create( 94 | pennid=1, 95 | username="student", 96 | password="secret", 97 | first_name="First", 98 | last_name="Last", 99 | email="test@test.com", 100 | is_superuser=1, 101 | ) 102 | self.client.force_login(self.user) 103 | 104 | def test_create_announcement(self): 105 | response = self.client.post( 106 | "/announcements/", 107 | { 108 | "title": "Maintenance Alert", 109 | "message": "We apologize for any inconvenience caused.", 110 | "audiences": ["CLUBS", "COURSE_PLAN", "COURSE_ALERT"], 111 | }, 112 | ) 113 | self.assertEqual(response.status_code, 201) 114 | self.assertTrue(Announcement.objects.filter(title="Maintenance Alert").exists()) 115 | 116 | def test_update_announcement(self): 117 | response = self.client.patch( 118 | f"/announcements/{self.announcement.id}/", 119 | {"title": "Wow!"}, 120 | content_type="application/json", 121 | ) 122 | self.assertEqual(response.status_code, 200) 123 | self.assertTrue(Announcement.objects.filter(title="Wow!").exists()) 124 | self.assertFalse(Announcement.objects.filter(title="Test message").exists()) 125 | 126 | def test_delete_announcement(self): 127 | response = self.client.delete(f"/announcements/{self.announcement.id}/") 128 | self.assertEqual(response.status_code, 204) 129 | self.assertFalse(Announcement.objects.filter(title="Test message").exists()) 130 | -------------------------------------------------------------------------------- /backend/tests/identity/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pennlabs/platform/022d2671a7811e8e2b6f772948ff28d18fa6422a/backend/tests/identity/__init__.py -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "styled-components", 5 | { "ssr": true, "displayName": true, "preprocess": false } 6 | ] 7 | ], 8 | "presets": [ 9 | "next/babel" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.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 | node_modules/ 15 | .next/ 16 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "airbnb", 5 | "airbnb/hooks", 6 | "next/core-web-vitals", 7 | "plugin:prettier/recommended" 8 | ], 9 | "env": { 10 | "browser": true 11 | }, 12 | "rules": { 13 | "import/extensions": 0, 14 | "no-unused-vars": "off", 15 | "no-shadow": "off", 16 | "no-use-before-define": 0, 17 | "import/prefer-default-export": 0, 18 | "react/jsx-filename-extension": 0, 19 | "react/prop-types": 0, 20 | "jsx-a11y/click-events-have-key-events": 0, 21 | "jsx-a11y/interactive-supports-focus": 0, 22 | "react/require-default-props": 0, 23 | "react/jsx-boolean-value": 0, 24 | "no-bitwise": "off", 25 | "no-await-in-loop": "warn", 26 | "no-else-return": 0, 27 | "global-require": 0, 28 | "jsx-a11y/label-has-associated-control": [ 29 | "error", 30 | { 31 | "labelComponents": [], 32 | "labelAttributes": [], 33 | "controlComponents": [ 34 | "AsyncSelect", 35 | "Form.Group", 36 | "Form.Radio", 37 | "Form.Input", 38 | "Form.Dropdown", 39 | "TextField", 40 | "Form.TextArea" 41 | ], 42 | "assert": "either" 43 | } 44 | ], 45 | "react/jsx-uses-react": "off", 46 | "react/react-in-jsx-scope": "off", 47 | "react/jsx-props-no-spreading": "off", 48 | "react/jsx-no-undef": ["error", { "allowGlobals": true }], 49 | "react/function-component-definition": ["error", { 50 | "namedComponents": "arrow-function", 51 | "unnamedComponents": "arrow-function" 52 | }] 53 | }, 54 | "settings": { 55 | "import/resolver": { 56 | "node": { 57 | "extensions": [ 58 | ".js", 59 | ".jsx", 60 | ".ts", 61 | ".tsx" 62 | ] 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | /.log 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | -------------------------------------------------------------------------------- /frontend/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "trailingComma": "es5", 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-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/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /frontend/components/accounts/forms/contact-info-form.tsx: -------------------------------------------------------------------------------- 1 | import { Columns, Heading } from "react-bulma-components"; 2 | import { ContactType, User } from "../../../types"; 3 | import { Flex } from "../ui"; 4 | import ContactInput from "./contact-input"; 5 | 6 | interface ContactInfoProps { 7 | initialData: User; 8 | } 9 | 10 | const ContactInfoForm = (props: ContactInfoProps) => { 11 | const { initialData: user } = props; 12 | return ( 13 |
14 | 15 | 16 | 17 | Emails 18 | 19 | 25 | 31 | 32 | 33 | 34 | 35 | Phone Numbers 36 | 37 | 43 | 49 | 50 | 51 | 52 |
53 | ); 54 | }; 55 | 56 | export default ContactInfoForm; 57 | -------------------------------------------------------------------------------- /frontend/components/accounts/forms/generic-info-form.tsx: -------------------------------------------------------------------------------- 1 | import { mutateResourceFunction } from "@pennlabs/rest-hooks/dist/types"; 2 | import _ from "lodash"; 3 | import { HTMLInputTypeAttribute, RefObject, useMemo } from "react"; 4 | import { Button, Form } from "react-bulma-components"; 5 | import { 6 | Control, 7 | Controller, 8 | FieldPath, 9 | useForm, 10 | useFormState, 11 | } from "react-hook-form"; 12 | import toast from "react-hot-toast"; 13 | import { User } from "../../../types"; 14 | import MultiSelectInput from "./multi-select"; 15 | 16 | export interface GenericInfoProps { 17 | mutate: mutateResourceFunction; 18 | initialData?: User; 19 | } 20 | 21 | interface TextInputProps { 22 | control: Control; 23 | disabled?: boolean; 24 | name: FieldPath; 25 | displayName: string; 26 | type?: HTMLInputTypeAttribute; 27 | rules?: any; 28 | } 29 | 30 | const TextInput = ({ 31 | control, 32 | disabled, 33 | name, 34 | displayName, 35 | type = "text", 36 | rules = { required: { value: true, message: "Required field!" } }, 37 | }: TextInputProps) => ( 38 | 39 | {displayName} 40 | ( 43 | <> 44 | } 48 | type={type} 49 | color={error ? "danger" : "black"} 50 | disabled={disabled} 51 | /> 52 | {error?.message || ""} 53 | 54 | )} 55 | name={name} 56 | rules={rules} 57 | /> 58 | 59 | ); 60 | 61 | const getGradYearLimits = () => { 62 | const year = new Date().getFullYear(); 63 | return [year, year + 10]; 64 | }; 65 | 66 | const GenericInfoForm = ({ mutate, initialData }: GenericInfoProps) => { 67 | const { handleSubmit, control, reset } = useForm({ 68 | defaultValues: initialData, 69 | }); 70 | 71 | const { isDirty, isValid, isSubmitting } = useFormState({ control }); 72 | const canSubmit = isDirty && isValid && !isSubmitting; 73 | 74 | const [minGradYear, maxGradYear] = useMemo(getGradYearLimits, []); 75 | 76 | const onSubmit = async (formData: Partial) => { 77 | /* eslint-disable camelcase */ 78 | // because graduation year and first name and such lol 79 | const { first_name, student } = _.cloneDeep(formData); 80 | if (student && !student.graduation_year) { 81 | student.graduation_year = null; 82 | } 83 | // TODO: once we have error handling this will work... 84 | await toast.promise( 85 | mutate({ 86 | first_name, 87 | student, 88 | }), 89 | { 90 | loading: "Saving...", 91 | success: "Saved!", 92 | error: "Something went wrong...", 93 | } 94 | ); 95 | /* eslint-enable */ 96 | reset(await mutate()); 97 | }; 98 | 99 | return ( 100 |
101 | 107 | {initialData?.groups.includes("student") && ( 108 | <> 109 | 110 | Majors 111 | 117 | 118 | 119 | Schools 120 | 126 | 127 | 144 | 145 | )} 146 | 147 | 150 | 151 | 152 | ); 153 | }; 154 | 155 | export default GenericInfoForm; 156 | -------------------------------------------------------------------------------- /frontend/components/accounts/forms/multi-select.tsx: -------------------------------------------------------------------------------- 1 | import { useResourceList } from "@pennlabs/rest-hooks"; 2 | import React, { useEffect, useMemo, useState } from "react"; 3 | import { Form } from "react-bulma-components"; 4 | import { Control, Controller, FieldPath } from "react-hook-form"; 5 | import Select from "react-select"; 6 | 7 | import { User } from "../../../types"; 8 | 9 | interface DataOption { 10 | id: number; 11 | name: string; 12 | } 13 | 14 | export interface MultiSelectProps { 15 | control: Control; 16 | route: string; 17 | name: FieldPath; 18 | disabled?: boolean; 19 | } 20 | 21 | const toSelectOptions = (options: DataOption[]) => 22 | options.map((obj) => ({ value: obj.id, label: obj.name })); 23 | 24 | const MultiSelectInput = (props: MultiSelectProps) => { 25 | const { control, name, route, disabled } = props; 26 | const { data: rawData } = useResourceList( 27 | route, 28 | (id) => `${route}${id}/` 29 | ); 30 | 31 | const optionsData = useMemo(() => rawData || [], [rawData]); 32 | const selectOptions = useMemo( 33 | () => toSelectOptions(optionsData), 34 | [optionsData] 35 | ); 36 | 37 | const [loading, setLoading] = useState(true); 38 | useEffect(() => { 39 | if (rawData) setLoading(false); 40 | }, [rawData]); 41 | 42 | return ( 43 | { 47 | const fieldValue = (field.value as DataOption[]) || []; 48 | return ( 49 | <> 50 |