├── .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 | [](https://circleci.com/gh/pennlabs/platform)
4 | [](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 |
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 |
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 |