├── .dockerignore ├── .env.example ├── .envrc ├── .eslintignore ├── .github └── workflows │ ├── lint-pipelines.sh │ ├── lint-pipelines.yml │ └── pythonpackage.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierrc ├── .python-version ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── backend ├── Dockerfile ├── atlas │ ├── __init__.py │ ├── apps.py │ ├── celery.py │ ├── cli.py │ ├── constants.py │ ├── factories │ │ ├── __init__.py │ │ ├── department.py │ │ ├── identity.py │ │ ├── office.py │ │ ├── profile.py │ │ ├── team.py │ │ └── user.py │ ├── management │ │ └── commands │ │ │ ├── generate_auth_token.py │ │ │ ├── load_mocks.py │ │ │ ├── runserver.py │ │ │ ├── sync_google.py │ │ │ ├── web.py │ │ │ └── worker.py │ ├── middleware │ │ └── auth.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_profile_handle.py │ │ ├── 0003_profile_primary_phone.py │ │ ├── 0004_auto_20190711_1346.py │ │ ├── 0005_auto_20190716_2034.py │ │ ├── 0006_auto_20190717_0052.py │ │ ├── 0007_office_external_id.py │ │ ├── 0008_auto_20190717_2113.py │ │ ├── 0009_auto_20190717_2114.py │ │ ├── 0010_auto_20190717_2229.py │ │ ├── 0011_office_region_code.py │ │ ├── 0012_auto_20190717_2233.py │ │ ├── 0013_identity_scopes.py │ │ ├── 0014_auto_20190718_0353.py │ │ ├── 0015_profile_pronouns.py │ │ ├── 0016_auto_20190724_0104.py │ │ ├── 0017_auto_20190724_0111.py │ │ ├── 0018_auto_20190729_1735.py │ │ ├── 0019_auto_20190729_1915.py │ │ ├── 0020_auto_20190806_2344.py │ │ ├── 0021_profile_referred_by.py │ │ ├── 0022_profile_team.py │ │ ├── 0023_auto_20190925_2223.py │ │ ├── 0024_profile_has_onboarded.py │ │ ├── 0025_auto_20191015_1957.py │ │ ├── 0026_department_cost_center.py │ │ ├── 0027_profile_is_directory_hidden.py │ │ ├── 0028_auto_20200519_1746.py │ │ ├── 0029_change_user.py │ │ ├── 0030_auto_20200519_2146.py │ │ ├── 0031_auto_20200520_1539.py │ │ └── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── change.py │ │ ├── department.py │ │ ├── identity.py │ │ ├── office.py │ │ ├── photo.py │ │ ├── profile.py │ │ ├── team.py │ │ └── user.py │ ├── mutations │ │ ├── __init__.py │ │ ├── create_department.py │ │ ├── delete_department.py │ │ ├── delete_team.py │ │ ├── import_csv.py │ │ ├── login.py │ │ ├── sync_google.py │ │ ├── test_create_department.py │ │ ├── test_delete_department.py │ │ ├── test_delete_team.py │ │ ├── test_import_csv.py │ │ ├── test_login.py │ │ ├── test_update_department.py │ │ ├── test_update_office.py │ │ ├── test_update_user.py │ │ ├── update_department.py │ │ ├── update_office.py │ │ └── update_user.py │ ├── queries │ │ ├── __init__.py │ │ ├── changes.py │ │ ├── departments.py │ │ ├── employeetypes.py │ │ ├── me.py │ │ ├── offices.py │ │ ├── teams.py │ │ ├── test_changes.py │ │ ├── test_departments.py │ │ ├── test_me.py │ │ ├── test_offices.py │ │ ├── test_teams.py │ │ ├── test_users.py │ │ └── users.py │ ├── root_schema.py │ ├── schema │ │ ├── __init__.py │ │ ├── binary.py │ │ ├── change.py │ │ ├── dayschedule.py │ │ ├── decimal.py │ │ ├── department.py │ │ ├── departmentinput.py │ │ ├── employeetype.py │ │ ├── nullable.py │ │ ├── office.py │ │ ├── phonenumber.py │ │ ├── photo.py │ │ ├── pronouns.py │ │ ├── team.py │ │ ├── user.py │ │ └── userinput.py │ ├── settings.py │ ├── tasks │ │ ├── __init__.py │ │ └── sync_google.py │ ├── urls.py │ ├── utils │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── google.py │ │ ├── graphql.py │ │ ├── query.py │ │ └── test_google.py │ ├── views.py │ └── wsgi.py ├── bin │ ├── atlas │ └── docker-entrypoint ├── cloudbuild.yaml ├── conftest.py ├── poetry.lock └── pyproject.toml ├── cloudbuild.yaml ├── docker-compose.yml ├── frontend ├── .eslintignore ├── .gitignore ├── Dockerfile ├── README.md ├── bin │ ├── docker-entrypoint │ ├── generate-config │ └── server ├── cloudbuild.yaml ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── actions │ ├── auth.js │ └── index.js │ ├── colors.js │ ├── components │ ├── Address.js │ ├── AuthenticatedPage.js │ ├── Avatar.js │ ├── Avatar.test.js │ ├── Birthday.js │ ├── Button.js │ ├── Card.js │ ├── ChangeHeading.js │ ├── Container.js │ ├── Content.js │ ├── DaySchedule.js │ ├── DefinitionList.js │ ├── DepartmentSelectField.js │ ├── ErrorBoundary.js │ ├── ErrorMessage.js │ ├── FieldWrapper.js │ ├── FlashCard.js │ ├── FormikEffect.js │ ├── GlobalLoader.js │ ├── Header.js │ ├── IconLink.js │ ├── InternalError.js │ ├── Layout.js │ ├── Map.js │ ├── Modal.js │ ├── NetworkError.js │ ├── NotFoundError.js │ ├── OfficeList.js │ ├── OfficeMap.js │ ├── OrgChart.css │ ├── OrgChart.js │ ├── PageLoader.js │ ├── PeopleList.js │ ├── PeopleViewSelectors.js │ ├── Person.js │ ├── Person.test.js │ ├── PersonCard.js │ ├── PersonLink.js │ ├── PersonList.js │ ├── PersonSelectField.js │ ├── Pronouns.js │ ├── QuizCard.js │ ├── SuperuserOnly.js │ ├── TeamSelectField.js │ ├── UpdateOfficeForm.js │ ├── UpdatePersonForm.js │ └── __snapshots__ │ │ └── Avatar.test.js.snap │ ├── config.js │ ├── errors.js │ ├── fonts │ ├── Roboto-Medium.ttf │ ├── Rubik-Bold.ttf │ ├── Rubik-BoldItalic.ttf │ ├── Rubik-Medium.ttf │ ├── Rubik-MediumItalic.ttf │ └── Rubik-Regular.ttf │ ├── images │ ├── avatar.svg │ └── google-icon.svg │ ├── index.js │ ├── pages │ ├── AdminAudit.js │ ├── AdminChangeDetails.js │ ├── AdminChanges.js │ ├── AdminCreateDepartment.js │ ├── AdminDeleteDepartment.js │ ├── AdminDeleteTeam.js │ ├── AdminDepartments.js │ ├── AdminImportExportPeople.js │ ├── AdminLayout.js │ ├── AdminTeams.js │ ├── AdminUpdateDepartment.js │ ├── AdminUpdateTeam.js │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── Flashcards.js │ ├── HealthCheck.js │ ├── HealthCheck.test.js │ ├── Home.js │ ├── Login.js │ ├── Login.test.js │ ├── Office.js │ ├── Offices.js │ ├── Onboarding.js │ ├── OrgChart.js │ ├── OrgChartInteractive.js │ ├── People.js │ ├── Profile.js │ ├── Quiz.js │ ├── UpdateOffice.js │ └── UpdateProfile.js │ ├── queries.js │ ├── reducers │ ├── auth.js │ └── index.js │ ├── routes.js │ ├── serviceWorker.js │ ├── setupTests.js │ ├── store.js │ ├── types.js │ └── utils │ ├── apollo.js │ ├── cookie.js │ ├── csv.js │ ├── loadScript.js │ ├── quiz.js │ ├── shuffle.js │ ├── strings.js │ └── testing.js ├── gocd └── pipelines │ └── atlas.yaml ├── package-lock.json ├── package.json └── setup.cfg /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | cloudbuild.yaml 3 | .dockerignore 4 | .venv 5 | node_modules 6 | .git 7 | .freight.yaml 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_CLIENT_SECRET= 2 | GOOGLE_CLIENT_ID=514555689588-uj2mgn0cbno4dmbib9m6oc9nfsubkp27.apps.googleusercontent.com 3 | GOOGLE_MAPS_KEY=AIzaSyA6Cju8CPvuCtVMfnUWYBKfeGPdnv9ZYSI 4 | GOOGLE_DOMAIN=sentry.io 5 | GOOGLE_REDIRECT_URI=http://localhost:8080 6 | SENTRY_DSN=https://5064f45dc0554742b94045c4207fe4f8@sentry.io/1473210 7 | SENTRY_ENVIRONMENT=development 8 | 9 | # Disable push sync for Google updates 10 | # (Useful for local development to avoid corruption of prod databases) 11 | DISABLE_GOOGLE_PUSH=1 12 | 13 | # CELERY_BROKER_URL= 14 | # DATABASE_URL= 15 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | 2 | layout_poetry() { 3 | if [[ -f backend/pyproject.toml ]]; then 4 | local VENV=$( cd backend && poetry show -v|grep "Using virtualenv:"|cut -f 3 -d " " 2>/dev/null) 5 | export VIRTUAL_ENV=$VENV 6 | PATH_add "$VIRTUAL_ENV/bin" 7 | fi 8 | } 9 | 10 | set -e 11 | 12 | # check if python version is set in current dir 13 | if [ -f ".python-version" ] ; then 14 | if [ ! -d ".venv" ] ; then 15 | echo "Installing virtualenv for $(python -V)" 16 | python -m venv .venv 17 | fi 18 | echo "Activating $(python -V) virtualenv" 19 | source .venv/bin/activate 20 | fi 21 | 22 | layout node 23 | layout_poetry 24 | 25 | # load local environment variables 26 | if [ -f ".env" ] ; then 27 | dotenv .env 28 | else 29 | echo "Unable to find .env. Please see README for configuring environment." 30 | fi 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | backend 2 | node_modules 3 | -------------------------------------------------------------------------------- /.github/workflows/lint-pipelines.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # gocd-cli does not catch all errors, but does catch some simple issues. 4 | # A better solution may be: https://github.com/GaneshSPatil/gocd-mergeable 5 | 6 | echo "GoCD YAML Linting" 7 | 8 | find "gocd" -name "*.yaml" -type f \ 9 | -exec printf "\n🔎 Linting {}\n\t" \; \ 10 | -exec ./gocd-cli configrepo syntax --yaml --raw "{}" \; 11 | -------------------------------------------------------------------------------- /.github/workflows/lint-pipelines.yml: -------------------------------------------------------------------------------- 1 | name: Lint Deployment Pipelines 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main, test-me-*] 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: cache bin 19 | id: cache-bin 20 | uses: actions/cache@v3 21 | with: 22 | path: ${HOME}/.local/bin 23 | # Bump this key if you're changing gocd-cli versions. 24 | key: ${{ runner.os }}-bin 25 | 26 | - name: Install gocd-cli 27 | run: | 28 | # this is on github runner's PATH but it isn't created, lol 29 | mkdir -p "${HOME}/.local/bin" 30 | bin="${HOME}/.local/bin/gocd-cli" 31 | curl -L -o "$bin" 'https://sentry-dev-infra-assets.storage.googleapis.com/gocd-085ab00-linux-amd64' 32 | echo "11d517c0c0058d1204294d01bfac987c0eaf9e37ba533ad54107b0949403321e ${bin}" | sha256sum -c - 33 | chmod +x "$bin" 34 | 35 | - name: Lint Pipelines with gocd-cli 36 | run: ./.github/workflows/lint-pipelines.sh 37 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Atlas 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-22.04 8 | services: 9 | postgresql: 10 | image: postgres:10.1-alpine 11 | env: 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | POSTGRES_DB: postgres 15 | ports: 16 | - 5432:5432 17 | # needed because the postgres container does not provide a healthcheck 18 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 19 | redis: 20 | image: redis:5.0-alpine 21 | ports: 22 | - 6379:6379 23 | steps: 24 | - uses: actions/checkout@v1 25 | - uses: actions/setup-python@v4 26 | with: 27 | python-version: 3.8 28 | - name: Install dependencies 29 | env: 30 | POETRY_VERSION: 1.1.4 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pre-commit==2.20 poetry==1.2.0 34 | curl -sSLf https://get.volta.sh | bash 35 | NODE_ENV= make 36 | - name: Lint with pre-commit 37 | run: | 38 | PATH=node_modules/.bin:$PATH pre-commit run -a -v 39 | - name: Test backend 40 | env: 41 | CELERY_BROKER_URL: redis://localhost:6379/0 42 | DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres 43 | SENTRY_ENVIRONMENT: test 44 | run: | 45 | cd backend && poetry run pytest -v --cov . --cov-report="xml:.artifacts/coverage.xml" --junit-xml=".artifacts/pytest.junit.xml" 46 | - name: Test frontend 47 | env: 48 | SENTRY_ENVIRONMENT: test 49 | run: | 50 | npm test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | pip-wheel-metadata/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # local cache 108 | /cache 109 | /media 110 | 111 | # Next.js 112 | .next/ 113 | 114 | # Dependency directories 115 | node_modules/ 116 | jspm_packages/ 117 | 118 | # TypeScript v1 declaration files 119 | typings/ 120 | 121 | # Optional npm cache directory 122 | .npm 123 | 124 | # Optional eslint cache 125 | .eslintcache 126 | 127 | # Junk 128 | .DS_Store 129 | 130 | # IDE 131 | .idea/ 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | node: latest 3 | exclude: '\.snap|node_modules$' 4 | repos: 5 | - repo: https://github.com/psf/black 6 | rev: 22.10.0 7 | hooks: 8 | - id: black 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v1.4.0 11 | hooks: 12 | - id: check-case-conflict 13 | - id: check-merge-conflict 14 | - id: check-symlinks 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: detect-private-key 18 | - id: end-of-file-fixer 19 | - id: trailing-whitespace 20 | - id: debug-statements 21 | - id: flake8 22 | - id: fix-encoding-pragma 23 | args: ["--remove"] 24 | - repo: https://github.com/getsentry/pre-commit-hooks 25 | rev: f3237d2d65af81d435c49dee3593dc8f03d23c2d 26 | hooks: 27 | - id: prettier 28 | entry: frontend/node_modules/.bin/prettier 29 | - id: eslint 30 | entry: frontend/node_modules/.bin/eslint 31 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90 3 | } 4 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8.8 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "ms-python.python", 6 | "lextudio.restructuredtext", 7 | "DavidAnson.vscode-markdownlint", 8 | "bungcip.better-toml" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/*.pyc": true, 4 | "htmlcov": true, 5 | "build": true, 6 | "static": true, 7 | "*.log": true, 8 | "*.egg-info": true, 9 | ".vscode/tags": true, 10 | ".mypy_cache": true, 11 | ".venv": true, 12 | ".next": true 13 | }, 14 | "files.trimTrailingWhitespace": false, 15 | "files.trimFinalNewlines": false, 16 | "files.insertFinalNewline": true, 17 | "[json]": { 18 | "editor.insertSpaces": true, 19 | "editor.detectIndentation": false, 20 | "editor.tabSize": 2, 21 | "editor.formatOnSave": true 22 | }, 23 | "[javascript]": { 24 | "editor.insertSpaces": true, 25 | "editor.detectIndentation": false, 26 | "editor.tabSize": 2, 27 | "editor.formatOnSave": true 28 | }, 29 | "[javascriptreact]": { 30 | "editor.insertSpaces": true, 31 | "editor.detectIndentation": false, 32 | "editor.tabSize": 2, 33 | "editor.formatOnSave": true 34 | }, 35 | "[css]": { 36 | "editor.insertSpaces": true, 37 | "editor.detectIndentation": false, 38 | "editor.tabSize": 2, 39 | "editor.formatOnSave": true 40 | }, 41 | "[html]": { 42 | "editor.insertSpaces": true, 43 | "editor.detectIndentation": false, 44 | "editor.tabSize": 2, 45 | "editor.formatOnSave": true 46 | }, 47 | "[python]": { 48 | "editor.insertSpaces": true, 49 | "editor.detectIndentation": false, 50 | "editor.tabSize": 4, 51 | "editor.formatOnSave": true, 52 | "editor.codeActionsOnSave": { 53 | "source.organizeImports": true 54 | } 55 | }, 56 | "python.linting.pylintEnabled": false, 57 | "python.linting.flake8Enabled": true, 58 | "python.formatting.provider": "black", 59 | "python.pythonPath": "${workspaceFolder}/.venv" 60 | } 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: install-requirements setup-git 2 | 3 | upgrade: install-requirements 4 | cd backend && poetry run atlas migrate 5 | 6 | setup-git: 7 | pre-commit install 8 | git config branch.autosetuprebase always 9 | git config --bool flake8.strict true 10 | 11 | install-requirements: 12 | cd backend && poetry install 13 | cd frontend && npm install 14 | npm install 15 | 16 | generate-requirements: 17 | cd backend && poetry run pip freeze > $@ 18 | 19 | test: 20 | cd backend && poetry run py.test 21 | 22 | reset-db: 23 | $(MAKE) drop-db 24 | $(MAKE) create-db 25 | cd backend && poetry run atlas migrate 26 | 27 | drop-db: 28 | dropdb --if-exists -h 127.0.0.1 -U postgres postgres 29 | 30 | create-db: 31 | createdb -E utf-8 -h 127.0.0.1 -U postgres postgres 32 | 33 | build-docker-images: 34 | docker build -t atlas-backend backend 35 | docker build -t atlas-frontend frontend 36 | 37 | run-docker-images: 38 | docker rm atlas-backend || exit 0 39 | docker rm atlas-frontend || exit 0 40 | docker run --rm --init -d -p 8000:8000/tcp --name atlas-backend atlas-backend 41 | docker run --rm --init -d -p 3000:3000/tcp --name atlas-frontend atlas-frontend 42 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.8-slim-buster 3 | 4 | # add our user and group first to make sure their IDs get assigned consistently 5 | RUN groupadd -r app && useradd -r -m -g app app 6 | 7 | ENV PATH /usr/src/app/bin:/root/.poetry/bin:$PATH 8 | 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | ENV PIP_NO_CACHE_DIR off 12 | ENV PIP_DISABLE_PIP_VERSION_CHECK on 13 | 14 | ENV POETRY_VERSION 1.1.4 15 | 16 | ENV NODE_ENV production 17 | 18 | RUN mkdir -p /usr/src/app 19 | WORKDIR /usr/src/app 20 | 21 | RUN set -ex \ 22 | && apt-get update && apt-get install -y --no-install-recommends \ 23 | build-essential \ 24 | ca-certificates \ 25 | curl \ 26 | gcc \ 27 | git \ 28 | gosu \ 29 | libffi-dev \ 30 | libpq-dev \ 31 | && rm -rf /var/lib/apt/lists/* 32 | 33 | RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python \ 34 | && poetry config virtualenvs.create false 35 | 36 | COPY pyproject.toml poetry.lock /usr/src/app/ 37 | # HACK(dcramer): we need the atlas module to be installable at this stage 38 | RUN mkdir atlas && touch atlas/__init__.py 39 | RUN poetry install --no-dev 40 | 41 | COPY . /usr/src/app/ 42 | # ensure we've got full module install now 43 | RUN poetry install --no-dev 44 | 45 | ENV PATH /usr/src/app/bin:$PATH 46 | 47 | EXPOSE 8080 48 | 49 | ENTRYPOINT ["docker-entrypoint"] 50 | 51 | CMD ["atlas", "web"] 52 | -------------------------------------------------------------------------------- /backend/atlas/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("celery_app",) 2 | 3 | from .celery import app as celery_app 4 | 5 | default_app_config = "atlas.apps.AppConfig" 6 | -------------------------------------------------------------------------------- /backend/atlas/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AppConfig(AppConfig): 5 | name = "atlas" 6 | verbose_name = "atlas-backend" 7 | 8 | def ready(self): 9 | pass 10 | -------------------------------------------------------------------------------- /backend/atlas/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "atlas.settings") 6 | 7 | app = Celery("atlas") 8 | 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | -------------------------------------------------------------------------------- /backend/atlas/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "atlas.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /backend/atlas/constants.py: -------------------------------------------------------------------------------- 1 | from atlas.models import Profile, User 2 | 3 | FIELD_MODEL_MAP = { 4 | "name": User, 5 | "is_superuser": User, 6 | "handle": Profile, 7 | "date_of_birth": Profile, 8 | "date_started": Profile, 9 | "schedule": Profile, 10 | "department": Profile, 11 | "team": Profile, 12 | "title": Profile, 13 | "bio": Profile, 14 | "reports_to": Profile, 15 | "primary_phone": Profile, 16 | "employee_type": Profile, 17 | "is_human": Profile, 18 | "is_directory_hidden": Profile, 19 | "office": Profile, 20 | "pronouns": Profile, 21 | "linkedin": Profile, 22 | "twitter": Profile, 23 | "github": Profile, 24 | "steam": Profile, 25 | "xbox": Profile, 26 | "playstation": Profile, 27 | "nintendo": Profile, 28 | "referred_by": Profile, 29 | "has_onboarded": Profile, 30 | } 31 | 32 | # only HR can edit restricted fields 33 | RESTRICTED_FIELDS = frozenset( 34 | [ 35 | "name", 36 | "date_of_birth", 37 | "date_started", 38 | "title", 39 | "department", 40 | "team", 41 | "reports_to", 42 | "office", 43 | "employee_type", 44 | "referred_by", 45 | ] 46 | ) 47 | 48 | SUPERUSER_ONLY_FIELDS = frozenset( 49 | ["is_human", "is_directory_hidden", "is_superuser", "has_onboarded"] 50 | ) 51 | 52 | # attribute prefixes which are always booleans 53 | BOOLEAN_PREFIXES = ("is_", "has_") 54 | 55 | DEFAULT_VALUES = { 56 | "is_human": True, 57 | "is_directory_hidden": False, 58 | "has_onboarded": False, 59 | "employee_type": "FULL_TIME", 60 | } 61 | -------------------------------------------------------------------------------- /backend/atlas/factories/__init__.py: -------------------------------------------------------------------------------- 1 | from .department import * # NOQA 2 | from .identity import * # NOQA 3 | from .office import * # NOQA 4 | from .profile import * # NOQA 5 | from .team import * # NOQA 6 | from .user import * # NOQA 7 | -------------------------------------------------------------------------------- /backend/atlas/factories/department.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .. import models 4 | 5 | # unused atm 6 | DEPARTMENT_NAMES = [ 7 | "Engineering", 8 | "Product", 9 | "Customer Support", 10 | "Customer Success", 11 | "People", 12 | "Sales", 13 | "Marketing", 14 | "Finance", 15 | "Legal", 16 | "Design", 17 | ] 18 | 19 | 20 | class DepartmentFactory(factory.django.DjangoModelFactory): 21 | name = factory.Faker("name") 22 | 23 | class Meta: 24 | model = models.Department 25 | -------------------------------------------------------------------------------- /backend/atlas/factories/identity.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .. import models 4 | from .user import UserFactory 5 | 6 | 7 | class IdentityFactory(factory.django.DjangoModelFactory): 8 | user = factory.SubFactory(UserFactory) 9 | provider = "google" 10 | external_id = factory.Faker("random_int", min=1, max=100000000) 11 | is_active = True 12 | access_token = factory.Faker("sha1") 13 | refresh_token = factory.Faker("sha1") 14 | is_admin = False 15 | 16 | class Meta: 17 | model = models.Identity 18 | 19 | class Params: 20 | admin = factory.Trait(is_admin=True) 21 | -------------------------------------------------------------------------------- /backend/atlas/factories/office.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .. import models 4 | 5 | 6 | class OfficeFactory(factory.django.DjangoModelFactory): 7 | name = factory.Faker("name") 8 | 9 | class Meta: 10 | model = models.Office 11 | -------------------------------------------------------------------------------- /backend/atlas/factories/profile.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import date 3 | 4 | import factory 5 | import factory.fuzzy 6 | 7 | from .. import models 8 | from .department import DepartmentFactory 9 | from .user import UserFactory 10 | 11 | DEPARTMENT_TITLES = { 12 | "Engineering": ["Software Engineer"], 13 | "Product": ["Product Manager"], 14 | "Customer Support": ["Customer Support Manager"], 15 | "Customer Success": ["Customer Success Manager"], 16 | "People": ["People Operations Manager", "HR Specialist", "Recruiter"], 17 | "Marketing": [ 18 | "Product Marketing Manager", 19 | "Content Strategist", 20 | "Marketing Operations Manager", 21 | "Events Coordinator", 22 | ], 23 | "Sales": [ 24 | "Account Executive", 25 | "Sales Engineer", 26 | "Sales Development Representative", 27 | "Sales Operations Manager", 28 | ], 29 | "Finance": ["Staff Accountant"], 30 | "Legal": ["Counsel"], 31 | "Design": ["Product Designer", "Illustrator"], 32 | } 33 | 34 | 35 | class ProfileFactory(factory.django.DjangoModelFactory): 36 | user = factory.SubFactory(UserFactory) 37 | title = factory.LazyAttribute( 38 | lambda o: random.choice(DEPARTMENT_TITLES[o.department.name]) 39 | ) 40 | department = factory.SubFactory(DepartmentFactory) 41 | date_started = factory.fuzzy.FuzzyDate(start_date=date(2010, 1, 1)) 42 | 43 | class Meta: 44 | model = models.Profile 45 | 46 | # TODO: 47 | # class Params: 48 | # ceo = factory.Trait(title="Chief Executive Officer", department="G&A") 49 | # cfo = factory.Trait(title="Chief Financial Officer", department="G&A") 50 | # cpo = factory.Trait(title="Chief Product Officer", department="G&A") 51 | # cto = factory.Trait(title="Chief Technology Officer", department="G&A") 52 | # cmo = factory.Trait(title="Chief Marketing Officer", department="G&A") 53 | # cro = factory.Trait(title="Chief Revenue Officer", department="G&A") 54 | # marketing = factory.Trait(department="Marketing") 55 | # engineering = factory.Trait(department="Engineering") 56 | # sales = factory.Trait(department="Sales") 57 | # finance = factory.Trait(department="Finance") 58 | -------------------------------------------------------------------------------- /backend/atlas/factories/team.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .. import models 4 | 5 | 6 | class TeamFactory(factory.django.DjangoModelFactory): 7 | name = factory.Faker("name") 8 | 9 | class Meta: 10 | model = models.Team 11 | -------------------------------------------------------------------------------- /backend/atlas/factories/user.py: -------------------------------------------------------------------------------- 1 | import factory 2 | 3 | from .. import models 4 | 5 | 6 | class UserFactory(factory.django.DjangoModelFactory): 7 | name = factory.Faker("name") 8 | email = factory.LazyAttribute( 9 | lambda x: "{0}@example.com".format(x.name.replace(" ", ".").lower()).lower() 10 | ) 11 | password = "password" 12 | 13 | class Meta: 14 | model = models.User 15 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/generate_auth_token.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.utils import timezone 5 | 6 | from atlas.models import User 7 | from atlas.utils.auth import generate_token 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Generate an authentication token for the given user" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument("email", type=str) 15 | 16 | def handle(self, *args, **options): 17 | user = User.objects.get(email=options["email"]) 18 | 19 | expires_in = 3600 * 24 * 30 20 | 21 | token = generate_token(user, expires_in) 22 | 23 | self.stdout.write( 24 | self.style.MIGRATE_HEADING('Authentication for "%s"' % user.email) 25 | ) 26 | self.stdout.write(self.style.SQL_FIELD("User ID")) 27 | self.stdout.write(self.style.MIGRATE_LABEL(" %s " % str(user.id))) 28 | self.stdout.write(self.style.SQL_FIELD("Email")) 29 | self.stdout.write(self.style.MIGRATE_LABEL(" %s " % user.email)) 30 | self.stdout.write(self.style.SQL_FIELD("Token")) 31 | self.stdout.write(self.style.MIGRATE_LABEL(" %s " % token)) 32 | self.stdout.write(self.style.SQL_FIELD("Expires")) 33 | self.stdout.write( 34 | self.style.MIGRATE_LABEL( 35 | " %s" % str((timezone.now() + timedelta(minutes=expires_in))) 36 | ) 37 | ) 38 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/load_mocks.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from atlas import factories 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Load mock data" 8 | 9 | def handle(self, *args, **options): 10 | raise NotImplementedError("TODO") 11 | ceo = factories.ProfileFactory.create(ceo=True) 12 | print(f"Created {ceo}") 13 | cmo = factories.ProfileFactory.create(cmo=True, reports_to=ceo.user) 14 | print(f"Created {cmo}") 15 | cto = factories.ProfileFactory.create(cto=True, reports_to=ceo.user) 16 | print(f"Created {cto}") 17 | cpo = factories.ProfileFactory.create(cpo=True, reports_to=ceo.user) 18 | print(f"Created {cpo}") 19 | cfo = factories.ProfileFactory.create(cfo=True, reports_to=ceo.user) 20 | print(f"Created {cfo}") 21 | cro = factories.ProfileFactory.create(cfo=True, reports_to=ceo.user) 22 | print(f"Created {cro}") 23 | 24 | factories.ProfileFactory.create(reports_to=cmo.user, marketing=True) 25 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/runserver.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | if "django.contrib.staticfiles" in settings.INSTALLED_APPS: 4 | from django.contrib.staticfiles.management.commands.runserver import ( 5 | Command as BaseCommand, 6 | ) 7 | else: 8 | from django.core.management.commands.runserver import Command as BaseCommand 9 | 10 | 11 | class Command(BaseCommand): 12 | def execute(self, *args, **options): 13 | settings.DEBUG = True 14 | return super().execute(*args, **options) 15 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/sync_google.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.management.base import BaseCommand 3 | 4 | from atlas.utils import google 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Synchronize users from Google" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("--domain", type=str, default=settings.GOOGLE_DOMAIN) 12 | parser.add_argument("--push", action="store_true", default=False) 13 | parser.add_argument("--ignore-versions", action="store_true", default=False) 14 | parser.add_argument("users", nargs="*", metavar="EMAIL") 15 | 16 | def handle(self, *args, **options): 17 | domain = options.get("domain") 18 | 19 | identity = google.get_admin_identity() 20 | self.stdout.write( 21 | self.style.MIGRATE_HEADING( 22 | "Synchronizing users for [{}] with identity [{}]".format( 23 | domain, identity.user.email 24 | ) 25 | ) 26 | ) 27 | 28 | if options["push"]: 29 | result = google.update_all_profiles( 30 | identity=identity, users=options["users"] 31 | ) 32 | else: 33 | result = google.sync_domain( 34 | identity=identity, 35 | domain=domain, 36 | users=options["users"], 37 | ignore_versions=options["ignore_versions"], 38 | ) 39 | self.stdout.write(self.style.MIGRATE_HEADING("Done!")) 40 | 41 | if not options["push"]: 42 | self.stdout.write( 43 | self.style.MIGRATE_HEADING( 44 | " -> buildings: {} ({} created; {} updated; {} pruned)".format( 45 | result.total_buildings, 46 | result.created_buildings, 47 | result.updated_buildings, 48 | result.pruned_buildings, 49 | ) 50 | ) 51 | ) 52 | self.stdout.write( 53 | self.style.MIGRATE_HEADING( 54 | " -> users: {} ({} created; {} updated; {} pruned)".format( 55 | result.total_users, 56 | result.created_users, 57 | result.updated_users, 58 | result.pruned_users, 59 | ) 60 | ) 61 | ) 62 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/web.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Run web process" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("--log-level", dest="log_level", default="INFO") 11 | parser.add_argument("--host", dest="host", default="127.0.0.1") 12 | parser.add_argument("--port", type=int, dest="port", default="8000") 13 | 14 | def handle(self, host, port, log_level, **options): 15 | command = ["gunicorn", f"-b {host}:{port}", "atlas.wsgi", "--log-file -"] 16 | os.execvp(command[0], command) 17 | -------------------------------------------------------------------------------- /backend/atlas/management/commands/worker.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.management.base import BaseCommand 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Run worker processes including crontab" 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("--cron", dest="cron", action="store_true", default=True) 11 | parser.add_argument( 12 | "--no-cron", dest="cron", action="store_false", default=True 13 | ) 14 | parser.add_argument("--log-level", dest="log_level", default="INFO") 15 | parser.add_argument("--concurrency", "-c", dest="concurrency", default=None) 16 | 17 | def handle(self, cron, log_level, concurrency, **options): 18 | command = [ 19 | "celery", 20 | "--app=atlas.celery:app", 21 | "worker", 22 | f"--loglevel={log_level}", 23 | "--max-tasks-per-child=10000", 24 | ] 25 | if concurrency: 26 | command.append(f"--concurrency={concurrency}") 27 | if cron: 28 | command.append("--beat") 29 | 30 | os.execvp(command[0], command) 31 | -------------------------------------------------------------------------------- /backend/atlas/middleware/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import sentry_sdk 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.utils.functional import SimpleLazyObject 6 | 7 | from atlas.models import User 8 | from atlas.utils.auth import parse_token, security_hash 9 | 10 | 11 | def get_user(header): 12 | if not header.startswith("Token "): 13 | return AnonymousUser() 14 | 15 | token = header.split(" ", 1)[1] 16 | payload = parse_token(token) 17 | if not payload: 18 | return AnonymousUser() 19 | 20 | try: 21 | user = User.objects.get(id=payload["uid"]) 22 | except (TypeError, KeyError, User.DoesNotExist): 23 | logging.error("auth.invalid-uid", exc_info=True) 24 | return AnonymousUser() 25 | 26 | if security_hash(user) != payload["sh"]: 27 | logging.error("auth.invalid-security-hash uid={}".format(payload["uid"])) 28 | return AnonymousUser() 29 | 30 | return user 31 | 32 | 33 | class JWSTokenAuthenticationMiddleware(object): 34 | def __init__(self, get_response): 35 | self.get_response = get_response 36 | 37 | def __call__(self, request): 38 | header = request.META.get("HTTP_AUTHORIZATION") 39 | if header: 40 | request.user = SimpleLazyObject(lambda: get_user(header)) 41 | else: 42 | request.user = AnonymousUser() 43 | 44 | with sentry_sdk.configure_scope() as scope: 45 | scope.user = ( 46 | {"id": str(request.user.id), "email": request.user.email} 47 | if request.user.is_authenticated 48 | else {} 49 | ) 50 | 51 | return self.get_response(request) 52 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0002_profile_handle.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-09 05:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0001_initial")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", name="handle", field=models.TextField(null=True) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0003_profile_primary_phone.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-11 11:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0002_profile_handle")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", 13 | name="primary_phone", 14 | field=models.TextField(null=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0004_auto_20190711_1346.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-11 13:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0003_profile_primary_phone")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="office", 13 | name="lat", 14 | field=models.DecimalField(decimal_places=6, max_digits=9, null=True), 15 | ), 16 | migrations.AddField( 17 | model_name="office", 18 | name="lng", 19 | field=models.DecimalField(decimal_places=6, max_digits=9, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0005_auto_20190716_2034.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-16 20:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("atlas", "0004_auto_20190711_1346")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="profile", 14 | name="is_human", 15 | field=models.BooleanField(default=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="profile", 19 | name="office", 20 | field=models.ForeignKey( 21 | null=True, 22 | on_delete=django.db.models.deletion.SET_NULL, 23 | related_name="profiles", 24 | to="atlas.Office", 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0006_auto_20190717_0052.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 00:52 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [("atlas", "0005_auto_20190716_2034")] 12 | 13 | operations = [ 14 | migrations.RemoveField(model_name="profile", name="photo_url"), 15 | migrations.CreateModel( 16 | name="Photo", 17 | fields=[ 18 | ( 19 | "id", 20 | models.UUIDField( 21 | default=uuid.uuid4, 22 | editable=False, 23 | primary_key=True, 24 | serialize=False, 25 | ), 26 | ), 27 | ("data", models.BinaryField()), 28 | ("width", models.PositiveIntegerField()), 29 | ("height", models.PositiveIntegerField()), 30 | ("mime_type", models.CharField(max_length=128)), 31 | ( 32 | "user", 33 | models.OneToOneField( 34 | on_delete=django.db.models.deletion.CASCADE, 35 | to=settings.AUTH_USER_MODEL, 36 | ), 37 | ), 38 | ], 39 | options={"db_table": "photo"}, 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0007_office_external_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 21:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0006_auto_20190717_0052")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="office", 13 | name="external_id", 14 | field=models.CharField(max_length=64, null=True, unique=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0008_auto_20190717_2113.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 21:13 2 | 3 | from django.db import migrations 4 | 5 | 6 | def backfill_external_ids(apps, schema_editor): 7 | # We can't import the Person model directly as it may be a newer 8 | # version than this migration expects. We use the historical version. 9 | Office = apps.get_model("atlas", "Office") 10 | for office in Office.objects.filter(external_id__isnull=True): 11 | office.external_id = office.name 12 | office.save() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [("atlas", "0007_office_external_id")] 18 | 19 | operations = [migrations.RunPython(backfill_external_ids)] 20 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0009_auto_20190717_2114.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 21:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0008_auto_20190717_2113")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="office", 13 | name="external_id", 14 | field=models.CharField(max_length=64, unique=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0010_auto_20190717_2229.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 22:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0009_auto_20190717_2114")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="office", name="name", field=models.CharField(max_length=64) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0011_office_region_code.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 22:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0010_auto_20190717_2229")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="office", 13 | name="region_code", 14 | field=models.CharField(max_length=64, null=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0012_auto_20190717_2233.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 22:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0011_office_region_code")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="office", name="description", field=models.TextField(null=True) 13 | ), 14 | migrations.AddField( 15 | model_name="office", 16 | name="postal_code", 17 | field=models.CharField(max_length=64, null=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0013_identity_scopes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-17 23:19 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("atlas", "0012_auto_20190717_2233")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="identity", 14 | name="scopes", 15 | field=django.contrib.postgres.fields.ArrayField( 16 | base_field=models.CharField(max_length=32), default=list, size=None 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0014_auto_20190718_0353.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-18 03:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0013_identity_scopes")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="office", 13 | name="administrative_area", 14 | field=models.CharField(max_length=64, null=True), 15 | ), 16 | migrations.AddField( 17 | model_name="office", 18 | name="locality", 19 | field=models.CharField(max_length=64, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0015_profile_pronouns.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-18 04:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0014_auto_20190718_0353")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", name="pronouns", field=models.TextField(null=True) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0016_auto_20190724_0104.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-24 01:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0015_profile_pronouns")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", name="bio", field=models.TextField(null=True) 13 | ), 14 | migrations.AddField( 15 | model_name="profile", name="github", field=models.TextField(null=True) 16 | ), 17 | migrations.AddField( 18 | model_name="profile", name="linkedin", field=models.TextField(null=True) 19 | ), 20 | migrations.AddField( 21 | model_name="profile", name="twitter", field=models.TextField(null=True) 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0017_auto_20190724_0111.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-24 01:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0016_auto_20190724_0104")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", name="nintendo", field=models.TextField(null=True) 13 | ), 14 | migrations.AddField( 15 | model_name="profile", name="playstation", field=models.TextField(null=True) 16 | ), 17 | migrations.AddField( 18 | model_name="profile", name="steam", field=models.TextField(null=True) 19 | ), 20 | migrations.AddField( 21 | model_name="profile", name="xbox", field=models.TextField(null=True) 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0018_auto_20190729_1735.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-29 17:35 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("atlas", "0017_auto_20190724_0111")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="profile", 14 | name="is_contractor", 15 | field=models.BooleanField(default=False), 16 | ), 17 | migrations.AddField( 18 | model_name="profile", 19 | name="schedule", 20 | field=django.contrib.postgres.fields.ArrayField( 21 | base_field=models.CharField(blank=True, max_length=6), null=True, size=7 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0019_auto_20190729_1915.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-29 19:15 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("atlas", "0018_auto_20190729_1735")] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="profile", 14 | name="schedule", 15 | field=django.contrib.postgres.fields.ArrayField( 16 | base_field=models.CharField(blank=True, max_length=8), null=True, size=7 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0020_auto_20190806_2344.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-06 23:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0019_auto_20190729_1915")] 9 | 10 | operations = [ 11 | migrations.RemoveField(model_name="profile", name="is_contractor"), 12 | migrations.AddField( 13 | model_name="profile", 14 | name="employee_type", 15 | field=models.TextField(null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0021_profile_referred_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-13 23:14 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 | 10 | dependencies = [("atlas", "0020_auto_20190806_2344")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="profile", 15 | name="referred_by", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.SET_NULL, 19 | related_name="referrals", 20 | to=settings.AUTH_USER_MODEL, 21 | ), 22 | ) 23 | ] 24 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0022_profile_team.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-25 17:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0021_profile_referred_by")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", name="team", field=models.TextField(null=True) 13 | ) 14 | ] 15 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0024_profile_has_onboarded.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-10-03 14:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0023_auto_20190925_2223")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", 13 | name="has_onboarded", 14 | field=models.BooleanField(default=False), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0025_auto_20191015_1957.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-10-15 19:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0024_profile_has_onboarded")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="department", 13 | name="name", 14 | field=models.CharField(max_length=64, unique=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0026_department_cost_center.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-10-15 22:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0025_auto_20191015_1957")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="department", 13 | name="cost_center", 14 | field=models.PositiveIntegerField(null=True, unique=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0027_profile_is_directory_hidden.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-03-18 23:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("atlas", "0026_department_cost_center")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="profile", 13 | name="is_directory_hidden", 14 | field=models.BooleanField(default=False), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0028_auto_20200519_1746.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-05-19 17:46 2 | 3 | import uuid 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | import django.db.models.deletion 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [("atlas", "0027_profile_is_directory_hidden")] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="profile", 17 | name="department", 18 | field=models.ForeignKey( 19 | null=True, 20 | on_delete=django.db.models.deletion.SET_NULL, 21 | related_name="profiles", 22 | to="atlas.Department", 23 | ), 24 | ), 25 | migrations.CreateModel( 26 | name="Change", 27 | fields=[ 28 | ( 29 | "id", 30 | models.UUIDField( 31 | default=uuid.uuid4, 32 | editable=False, 33 | primary_key=True, 34 | serialize=False, 35 | ), 36 | ), 37 | ("timestamp", models.DateTimeField(auto_now_add=True)), 38 | ( 39 | "object_type", 40 | models.CharField( 41 | choices=[("user", "user"), ("office", "office")], max_length=32 42 | ), 43 | ), 44 | ("object_id", models.UUIDField()), 45 | ("changes", django.contrib.postgres.fields.jsonb.JSONField()), 46 | ("version", models.PositiveIntegerField()), 47 | ], 48 | options={"unique_together": {("object_type", "object_id", "version")}}, 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0029_change_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-05-19 20:03 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 | 10 | dependencies = [("atlas", "0028_auto_20200519_1746")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="change", 15 | name="user", 16 | field=models.ForeignKey( 17 | null=True, 18 | on_delete=django.db.models.deletion.DO_NOTHING, 19 | to=settings.AUTH_USER_MODEL, 20 | ), 21 | ) 22 | ] 23 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0030_auto_20200519_2146.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-05-19 21:46 2 | 3 | import atlas.models.change 4 | import django.contrib.postgres.fields.jsonb 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("atlas", "0029_change_user")] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="change", 15 | name="previous", 16 | field=django.contrib.postgres.fields.jsonb.JSONField( 17 | encoder=atlas.models.change.CustomJSONEncoder, null=True 18 | ), 19 | ), 20 | migrations.AlterField( 21 | model_name="change", 22 | name="changes", 23 | field=django.contrib.postgres.fields.jsonb.JSONField( 24 | encoder=atlas.models.change.CustomJSONEncoder 25 | ), 26 | ), 27 | migrations.AlterField( 28 | model_name="change", 29 | name="object_type", 30 | field=models.CharField( 31 | choices=[ 32 | ("user", "user"), 33 | ("office", "office"), 34 | ("department", "department"), 35 | ], 36 | max_length=32, 37 | ), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /backend/atlas/migrations/0031_auto_20200520_1539.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2020-05-20 15:39 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import uuid 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("atlas", "0030_auto_20200519_2146")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Team", 15 | fields=[ 16 | ( 17 | "id", 18 | models.UUIDField( 19 | default=uuid.uuid4, 20 | editable=False, 21 | primary_key=True, 22 | serialize=False, 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=64, unique=True)), 26 | ("description", models.TextField(null=True)), 27 | ], 28 | options={"db_table": "team"}, 29 | ), 30 | migrations.AddField( 31 | model_name="profile", 32 | name="team", 33 | field=models.ForeignKey( 34 | null=True, 35 | on_delete=django.db.models.deletion.SET_NULL, 36 | related_name="profiles", 37 | to="atlas.Team", 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /backend/atlas/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/backend/atlas/migrations/__init__.py -------------------------------------------------------------------------------- /backend/atlas/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .change import * # NOQA 2 | from .department import * # NOQA 3 | from .identity import * # NOQA 4 | from .office import * # NOQA 5 | from .photo import * # NOQA 6 | from .profile import * # NOQA 7 | from .team import * # NOQA 8 | from .user import * # NOQA 9 | -------------------------------------------------------------------------------- /backend/atlas/models/change.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID, uuid4 2 | 3 | from django.conf import settings 4 | from django.contrib.postgres.fields import JSONField 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | from django.db import models 7 | 8 | 9 | class CustomJSONEncoder(DjangoJSONEncoder): 10 | def default(self, o): 11 | if isinstance(o, models.Model): 12 | return o.pk 13 | elif isinstance(o, UUID): 14 | return str(o) 15 | return super().default(o) 16 | 17 | 18 | class Change(models.Model): 19 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 20 | timestamp = models.DateTimeField(auto_now_add=True) 21 | 22 | object_type = models.CharField( 23 | choices=(("user", "user"), ("office", "office"), ("department", "department")), 24 | max_length=32, 25 | ) 26 | object_id = models.UUIDField() 27 | 28 | user = models.ForeignKey( 29 | settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, null=True 30 | ) 31 | previous = JSONField(null=True, encoder=CustomJSONEncoder) 32 | changes = JSONField(encoder=CustomJSONEncoder) 33 | version = models.PositiveIntegerField() 34 | 35 | class Meta: 36 | unique_together = (("object_type", "object_id", "version"),) 37 | 38 | @classmethod 39 | def record(cls, instance, changes, user=None, previous=None): 40 | if previous is None: 41 | previous = {} 42 | for key, value in changes.items(): 43 | previous[key] = getattr(instance, key) 44 | 45 | object_type = instance._meta.model_name.lower() 46 | object_id = instance.pk 47 | 48 | # TODO(dcramer): version here isnt atomic, but it has a constraint so it'll error out 49 | return cls.objects.create( 50 | object_type=object_type, 51 | object_id=object_id, 52 | changes=changes, 53 | previous=previous, 54 | user=user, 55 | version=( 56 | ( 57 | cls.objects.filter( 58 | object_type=object_type, object_id=object_id 59 | ).aggregate(v=models.Max("version"))["v"] 60 | or 0 61 | ) 62 | + 1 63 | ), 64 | ) 65 | 66 | @classmethod 67 | def get_current_version(cls, object_type, object_id): 68 | return int( 69 | cls.objects.filter(object_type=object_type, object_id=object_id).aggregate( 70 | v=models.Max("version") 71 | )["v"] 72 | or 0 73 | ) 74 | -------------------------------------------------------------------------------- /backend/atlas/models/department.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID, uuid4 3 | 4 | from django.contrib.postgres.fields import ArrayField 5 | from django.db import models 6 | 7 | 8 | class DepartmentManager(models.Manager): 9 | def get_by_natural_key(self, cost_center: Optional[str], name: str): 10 | try: 11 | UUID(name) 12 | except ValueError: 13 | name_is_id = False 14 | else: 15 | name_is_id = True 16 | 17 | if cost_center: 18 | try: 19 | return self.get(cost_center=cost_center) 20 | except self.model.DoesNotExist: 21 | pass 22 | if name_is_id: 23 | return self.get(id=name) 24 | return self.get(name=name) 25 | 26 | def get_or_create_by_natural_key(self, cost_center: Optional[int], name: str): 27 | if cost_center: 28 | cost_center = int(cost_center) 29 | try: 30 | inst, created = self.get_by_natural_key(cost_center, name), False 31 | except self.model.DoesNotExist: 32 | inst, created = self.create(name=name, cost_center=cost_center), True 33 | 34 | if not created: 35 | fields = [] 36 | # we only override the name if cost_center is empty or the name is empty 37 | if inst.name != name and (not cost_center or not inst.name): 38 | inst.name = name 39 | fields.append("name") 40 | 41 | if inst.cost_center != cost_center and cost_center: 42 | inst.cost_center = cost_center 43 | fields.append("cost_center") 44 | 45 | if fields: 46 | inst.save(update_fields=fields) 47 | return inst, created 48 | 49 | 50 | class Department(models.Model): 51 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 52 | # full materialized tree excluding self (all parents, in order) 53 | tree = ArrayField(models.UUIDField(), null=True, db_index=True) 54 | parent = models.ForeignKey("self", null=True, on_delete=models.CASCADE) 55 | name = models.CharField(max_length=64, unique=True) 56 | cost_center = models.PositiveIntegerField(null=True, unique=True) 57 | 58 | objects = DepartmentManager() 59 | 60 | class Meta: 61 | db_table = "department" 62 | unique_together = (("parent", "name"),) 63 | 64 | def natural_key(self): 65 | return [self.cost_center, self.name] 66 | -------------------------------------------------------------------------------- /backend/atlas/models/identity.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.conf import settings 4 | from django.contrib.postgres.fields import ArrayField, JSONField 5 | from django.db import models 6 | 7 | 8 | class Identity(models.Model): 9 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 10 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 11 | provider = models.CharField(max_length=32) 12 | external_id = models.CharField(max_length=32) 13 | config = JSONField(default=dict) 14 | scopes = ArrayField(models.CharField(max_length=32), default=list) 15 | is_active = models.BooleanField(default=False) 16 | access_token = models.TextField(null=True) 17 | refresh_token = models.TextField(null=True) 18 | is_admin = models.BooleanField(default=False) 19 | 20 | class Meta: 21 | db_table = "identity" 22 | unique_together = (("provider", "external_id"),) 23 | -------------------------------------------------------------------------------- /backend/atlas/models/office.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.db import models 4 | 5 | 6 | class OfficeManager(models.Manager): 7 | def get_by_natural_key(self, external_id): 8 | return self.get(external_id=external_id) 9 | 10 | def get_or_create_by_natural_key(self, external_id): 11 | return self.get_or_create( 12 | external_id=external_id, defaults={"name": external_id} 13 | ) 14 | 15 | 16 | class Office(models.Model): 17 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 18 | external_id = models.CharField(max_length=64, unique=True) 19 | name = models.CharField(max_length=64) 20 | description = models.TextField(null=True) 21 | location = models.TextField(null=True) 22 | region_code = models.CharField(max_length=64, null=True) 23 | postal_code = models.CharField(max_length=64, null=True) 24 | administrative_area = models.CharField(max_length=64, null=True) 25 | locality = models.CharField(max_length=64, null=True) 26 | lat = models.DecimalField(max_digits=9, decimal_places=6, null=True) 27 | lng = models.DecimalField(max_digits=9, decimal_places=6, null=True) 28 | 29 | objects = OfficeManager() 30 | 31 | class Meta: 32 | db_table = "office" 33 | 34 | def natural_key(self): 35 | return [self.external_id] 36 | -------------------------------------------------------------------------------- /backend/atlas/models/photo.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | 6 | 7 | class Photo(models.Model): 8 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 9 | user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 10 | data = models.BinaryField() 11 | width = models.PositiveIntegerField() 12 | height = models.PositiveIntegerField() 13 | mime_type = models.CharField(max_length=128) 14 | 15 | class Meta: 16 | db_table = "photo" 17 | -------------------------------------------------------------------------------- /backend/atlas/models/profile.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.conf import settings 4 | from django.contrib.postgres.fields import ArrayField, JSONField 5 | from django.db import models 6 | 7 | 8 | class Profile(models.Model): 9 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 10 | user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 11 | 12 | # a handle is what a person "Goes by" - its like a username but irl 13 | pronouns = models.TextField(null=True) 14 | employee_type = models.TextField(null=True) 15 | handle = models.TextField(null=True) 16 | # the year of birth is considered confidential and thus is discarded by Atlas 17 | # instead we store the year as 1900 to keep this in a known/easy to work w/ schema 18 | date_of_birth = models.DateField(null=True) 19 | date_started = models.DateField(null=True) 20 | schedule = ArrayField(models.CharField(max_length=8, blank=True), size=7, null=True) 21 | title = models.TextField(null=True) 22 | bio = models.TextField(null=True) 23 | reports_to = models.ForeignKey( 24 | settings.AUTH_USER_MODEL, 25 | null=True, 26 | on_delete=models.SET_NULL, 27 | related_name="reports", 28 | ) 29 | referred_by = models.ForeignKey( 30 | settings.AUTH_USER_MODEL, 31 | null=True, 32 | on_delete=models.SET_NULL, 33 | related_name="referrals", 34 | ) 35 | office = models.ForeignKey( 36 | "atlas.Office", null=True, on_delete=models.SET_NULL, related_name="profiles" 37 | ) 38 | department = models.ForeignKey( 39 | "atlas.Department", 40 | null=True, 41 | on_delete=models.SET_NULL, 42 | related_name="profiles", 43 | ) 44 | team = models.ForeignKey( 45 | "atlas.Team", null=True, on_delete=models.SET_NULL, related_name="profiles" 46 | ) 47 | primary_phone = models.TextField(null=True) 48 | linkedin = models.TextField(null=True) 49 | twitter = models.TextField(null=True) 50 | github = models.TextField(null=True) 51 | steam = models.TextField(null=True) 52 | xbox = models.TextField(null=True) 53 | playstation = models.TextField(null=True) 54 | nintendo = models.TextField(null=True) 55 | config = JSONField(default=dict) 56 | is_human = models.BooleanField(default=True) 57 | has_onboarded = models.BooleanField(default=False) 58 | is_directory_hidden = models.BooleanField(default=False) 59 | 60 | class Meta: 61 | db_table = "profile" 62 | -------------------------------------------------------------------------------- /backend/atlas/models/team.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from django.db import models 4 | 5 | 6 | class TeamManager(models.Manager): 7 | def get_by_natural_key(self, name): 8 | return self.get(name=name) 9 | 10 | def get_or_create_by_natural_key(self, name): 11 | return self.get_or_create(name=name) 12 | 13 | 14 | class Team(models.Model): 15 | id = models.UUIDField(primary_key=True, default=uuid4, editable=False) 16 | name = models.CharField(max_length=64, unique=True) 17 | description = models.TextField(null=True) 18 | 19 | objects = TeamManager() 20 | 21 | class Meta: 22 | db_table = "team" 23 | 24 | def natural_key(self): 25 | return [self.name] 26 | -------------------------------------------------------------------------------- /backend/atlas/mutations/__init__.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from .create_department import CreateDepartment 4 | from .delete_department import DeleteDepartment 5 | from .delete_team import DeleteTeam 6 | from .import_csv import ImportCsv 7 | from .login import Login 8 | from .sync_google import SyncGoogle 9 | from .update_department import UpdateDepartment 10 | from .update_office import UpdateOffice 11 | from .update_user import UpdateUser 12 | 13 | 14 | class RootMutation(graphene.ObjectType): 15 | create_department = CreateDepartment.Field() 16 | delete_department = DeleteDepartment.Field() 17 | delete_team = DeleteTeam.Field() 18 | import_csv = ImportCsv.Field() 19 | login = Login.Field() 20 | sync_google = SyncGoogle.Field() 21 | update_department = UpdateDepartment.Field() 22 | update_office = UpdateOffice.Field() 23 | update_user = UpdateUser.Field() 24 | -------------------------------------------------------------------------------- /backend/atlas/mutations/create_department.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from atlas.models import Department 4 | from atlas.schema import DepartmentInput, DepartmentNode 5 | 6 | 7 | class CreateDepartment(graphene.Mutation): 8 | class Arguments: 9 | data = DepartmentInput(required=True) 10 | 11 | ok = graphene.Boolean() 12 | errors = graphene.List(graphene.String) 13 | department = graphene.Field(DepartmentNode) 14 | 15 | def mutate(self, info, data: DepartmentInput): 16 | current_user = info.context.user 17 | if not current_user.is_authenticated: 18 | return CreateDepartment(ok=False, errors=["Authentication required"]) 19 | 20 | # only superuser (human resources) can edit departments 21 | if not current_user.is_superuser: 22 | return CreateDepartment(ok=False, errors=["Cannot edit this resource"]) 23 | 24 | if data.get("parent"): 25 | data["parent"] = parent = Department.objects.get(pk=data["parent"]) 26 | tree = (parent.tree or []) + [parent.pk] 27 | else: 28 | tree = None 29 | 30 | department = Department.objects.create(tree=tree, **data) 31 | 32 | return CreateDepartment(ok=True, department=department) 33 | -------------------------------------------------------------------------------- /backend/atlas/mutations/delete_department.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django.db import transaction 3 | 4 | from atlas.models import Department, Profile 5 | from atlas.tasks import update_profile 6 | 7 | 8 | class DeleteDepartment(graphene.Mutation): 9 | class Arguments: 10 | department = graphene.UUID(required=True) 11 | new_department = graphene.UUID(required=True) 12 | 13 | ok = graphene.Boolean() 14 | errors = graphene.List(graphene.String) 15 | 16 | def mutate(self, info, department: str, new_department: str): 17 | current_user = info.context.user 18 | if not current_user.is_authenticated: 19 | return DeleteDepartment(ok=False, errors=["Authentication required"]) 20 | 21 | if department == new_department: 22 | return DeleteDepartment( 23 | ok=False, errors=["Must select a unique new department"] 24 | ) 25 | 26 | try: 27 | department = Department.objects.get(id=department) 28 | except Department.DoesNotExist: 29 | return DeleteDepartment(ok=False, errors=["Invalid resource"]) 30 | 31 | try: 32 | new_department = Department.objects.get(id=new_department) 33 | except Department.DoesNotExist: 34 | return DeleteDepartment(ok=False, errors=["Invalid resource"]) 35 | 36 | # only superuser (human resources) can edit departments 37 | if not current_user.is_superuser: 38 | return DeleteDepartment(ok=False, errors=["Cannot edit this resource"]) 39 | 40 | # XXX(dcramer): this is potentially a very long transaction 41 | with transaction.atomic(): 42 | department_id = department.id 43 | affected_users = [] 44 | for user_id in Profile.objects.filter(department=department_id).values_list( 45 | "user", flat=True 46 | ): 47 | affected_users.append(user_id) 48 | Profile.objects.filter(user=user_id).update(department=new_department) 49 | 50 | department.delete() 51 | 52 | for user_id in affected_users: 53 | update_profile.delay( 54 | user_id=user_id, updates={"department": str(new_department.id)} 55 | ) 56 | 57 | return DeleteDepartment(ok=True) 58 | -------------------------------------------------------------------------------- /backend/atlas/mutations/delete_team.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django.db import transaction 3 | 4 | from atlas.models import Profile, Team 5 | from atlas.tasks import update_profile 6 | 7 | 8 | class DeleteTeam(graphene.Mutation): 9 | class Arguments: 10 | team = graphene.UUID(required=True) 11 | new_team = graphene.UUID(required=False) 12 | 13 | ok = graphene.Boolean() 14 | errors = graphene.List(graphene.String) 15 | 16 | def mutate(self, info, team: str, new_team: str = None): 17 | current_user = info.context.user 18 | if not current_user.is_authenticated: 19 | return DeleteTeam(ok=False, errors=["Authentication required"]) 20 | 21 | if team == new_team: 22 | return DeleteTeam(ok=False, errors=["Must select a unique new team"]) 23 | 24 | try: 25 | team = Team.objects.get(id=team) 26 | except Team.DoesNotExist: 27 | return DeleteTeam(ok=False, errors=["Invalid resource"]) 28 | 29 | if new_team: 30 | try: 31 | new_team = Team.objects.get(id=new_team) 32 | except Team.DoesNotExist: 33 | return DeleteTeam(ok=False, errors=["Invalid resource"]) 34 | 35 | # only superuser (human resources) can edit teams 36 | if not current_user.is_superuser: 37 | return DeleteTeam(ok=False, errors=["Cannot edit this resource"]) 38 | 39 | # XXX(dcramer): this is potentially a very long transaction 40 | with transaction.atomic(): 41 | team_id = team.id 42 | affected_users = [] 43 | for user_id in Profile.objects.filter(team=team_id).values_list( 44 | "user", flat=True 45 | ): 46 | affected_users.append(user_id) 47 | Profile.objects.filter(user=user_id).update(team=new_team) 48 | 49 | team.delete() 50 | 51 | for user_id in affected_users: 52 | update_profile.delay( 53 | user_id=user_id, 54 | updates={"team": str(new_team.id) if new_team else None}, 55 | ) 56 | 57 | return DeleteTeam(ok=True) 58 | -------------------------------------------------------------------------------- /backend/atlas/mutations/sync_google.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from atlas.tasks import sync_google 4 | 5 | 6 | class SyncGoogle(graphene.Mutation): 7 | ok = graphene.Boolean() 8 | errors = graphene.List(graphene.String) 9 | 10 | def mutate(self, info): 11 | current_user = info.context.user 12 | if not current_user.is_authenticated: 13 | return SyncGoogle(ok=False, errors=["Authentication required"]) 14 | 15 | if not current_user.is_superuser: 16 | return SyncGoogle(ok=False, errors=["Permission required"]) 17 | 18 | sync_google() 19 | 20 | return SyncGoogle(ok=True) 21 | -------------------------------------------------------------------------------- /backend/atlas/mutations/test_create_department.py: -------------------------------------------------------------------------------- 1 | from atlas.models import Department 2 | 3 | 4 | def test_user_cannot_create(gql_client, default_user): 5 | executed = gql_client.execute( 6 | """ 7 | mutation { 8 | createDepartment(data:{name:"Not Design"}) { 9 | ok 10 | errors 11 | department { id } 12 | } 13 | }""", 14 | user=default_user, 15 | ) 16 | assert not executed.get("errors") 17 | resp = executed["data"]["createDepartment"] 18 | assert resp["errors"] 19 | assert resp["ok"] is False 20 | 21 | 22 | def test_superuser_can_create_without_parent(gql_client, default_superuser): 23 | executed = gql_client.execute( 24 | """ 25 | mutation { 26 | createDepartment(data:{name:"Not Design"}) { 27 | ok 28 | errors 29 | department { id } 30 | } 31 | }""", 32 | user=default_superuser, 33 | ) 34 | assert not executed.get("errors") 35 | resp = executed["data"]["createDepartment"] 36 | assert not resp["errors"] 37 | assert resp["ok"] is True 38 | 39 | department = Department.objects.get(id=resp["department"]["id"]) 40 | assert department.name == "Not Design" 41 | assert not department.tree 42 | assert not department.parent 43 | 44 | 45 | def test_superuser_can_create_with_parent( 46 | gql_client, default_superuser, design_department 47 | ): 48 | executed = gql_client.execute( 49 | """ 50 | mutation { 51 | createDepartment(data:{name:"Creative" parent:"%s"}) { 52 | ok 53 | errors 54 | department { id } 55 | } 56 | }""" 57 | % (design_department.id,), 58 | user=default_superuser, 59 | ) 60 | assert not executed.get("errors") 61 | resp = executed["data"]["createDepartment"] 62 | assert not resp["errors"] 63 | assert resp["ok"] is True 64 | 65 | department = Department.objects.get(id=resp["department"]["id"]) 66 | assert department.name == "Creative" 67 | assert department.tree == [design_department.id] 68 | assert department.parent_id == design_department.id 69 | -------------------------------------------------------------------------------- /backend/atlas/mutations/test_delete_department.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from atlas.models import Department, Profile 4 | 5 | 6 | def test_user_cannot_delete(gql_client, default_user, design_department, ga_department): 7 | executed = gql_client.execute( 8 | """ 9 | mutation { 10 | deleteDepartment(department:"%s" newDepartment:"%s") { 11 | ok 12 | errors 13 | } 14 | }""" 15 | % (design_department.id, ga_department.id), 16 | user=default_user, 17 | ) 18 | assert not executed.get("errors") 19 | resp = executed["data"]["deleteDepartment"] 20 | assert resp["errors"] 21 | assert resp["ok"] is False 22 | 23 | assert Department.objects.filter(id=ga_department.id).exists() 24 | 25 | 26 | @patch("atlas.tasks.update_profile.delay") 27 | def test_superuser_can_delete( 28 | mock_task, gql_client, default_superuser, design_department, ga_department 29 | ): 30 | assert default_superuser.profile.department_id == ga_department.id 31 | 32 | executed = gql_client.execute( 33 | """ 34 | mutation { 35 | deleteDepartment(department:"%s" newDepartment:"%s") { 36 | ok 37 | errors 38 | } 39 | }""" 40 | % (ga_department.id, design_department.id), 41 | user=default_superuser, 42 | ) 43 | assert not executed.get("errors") 44 | resp = executed["data"]["deleteDepartment"] 45 | assert not resp["errors"] 46 | assert resp["ok"] is True 47 | 48 | profile = Profile.objects.get(user=default_superuser) 49 | assert profile.department_id == design_department.id 50 | 51 | assert not Department.objects.filter(id=ga_department.id).exists() 52 | 53 | mock_task.assert_called_once_with( 54 | user_id=default_superuser.id, updates={"department": str(design_department.id)} 55 | ) 56 | -------------------------------------------------------------------------------- /backend/atlas/mutations/test_update_office.py: -------------------------------------------------------------------------------- 1 | from atlas.models import Office 2 | 3 | 4 | def test_requires_superuser(gql_client, default_user, default_office): 5 | executed = gql_client.execute( 6 | """ 7 | mutation { 8 | updateOffice(office:"%s" data:{location:"132 Hawthorne St, San Francisco CA, 94103, USA"}) { 9 | ok 10 | errors 11 | office {id, location} 12 | } 13 | }""" 14 | % (default_office.id,), 15 | user=default_user, 16 | ) 17 | assert not executed.get("errors") 18 | resp = executed["data"]["updateOffice"] 19 | assert resp["errors"] 20 | assert resp["ok"] is False 21 | 22 | 23 | def test_update_location(gql_client, default_superuser, default_office): 24 | location = "132 Hawthorne St, San Francisco CA, 94103, USA" 25 | 26 | executed = gql_client.execute( 27 | """ 28 | mutation { 29 | updateOffice(office:"%s" data:{location:"%s"}) { 30 | ok 31 | errors 32 | office {id, location} 33 | } 34 | }""" 35 | % (default_office.id, location), 36 | user=default_superuser, 37 | ) 38 | assert not executed.get("errors") 39 | resp = executed["data"]["updateOffice"] 40 | assert resp["errors"] is None 41 | assert resp["ok"] is True 42 | assert resp["office"] == {"id": str(default_office.id), "location": location} 43 | 44 | office = Office.objects.get(id=default_office.id) 45 | assert office.location == location 46 | -------------------------------------------------------------------------------- /backend/atlas/mutations/update_department.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from enum import Enum 3 | 4 | import graphene 5 | from django.db import models 6 | 7 | from atlas.models import Department 8 | from atlas.schema import DepartmentInput, DepartmentNode 9 | 10 | 11 | class UpdateDepartment(graphene.Mutation): 12 | class Arguments: 13 | department = graphene.UUID(required=True) 14 | data = DepartmentInput(required=True) 15 | 16 | ok = graphene.Boolean() 17 | errors = graphene.List(graphene.String) 18 | department = graphene.Field(DepartmentNode) 19 | 20 | def mutate(self, info, department: str, data: DepartmentInput): 21 | current_user = info.context.user 22 | if not current_user.is_authenticated: 23 | return UpdateDepartment(ok=False, errors=["Authentication required"]) 24 | 25 | try: 26 | department = Department.objects.get(id=department) 27 | except Department.DoesNotExist: 28 | return UpdateDepartment(ok=False, errors=["Invalid resource"]) 29 | 30 | # only superuser (human resources) can edit departments 31 | if not current_user.is_superuser: 32 | return UpdateDepartment(ok=False, errors=["Cannot edit this resource"]) 33 | 34 | updates = {} 35 | flattened_data = data.copy() 36 | for field, value in flattened_data.items(): 37 | if value == "": 38 | value = None 39 | 40 | cur_value = getattr(department, field) 41 | if isinstance(value, Enum): 42 | value = value.name 43 | 44 | if cur_value != value: 45 | updates[field] = value 46 | if isinstance(value, date): 47 | value = value.isoformat() 48 | elif isinstance(value, models.Model): 49 | value = value.pk 50 | # track update for two-way sync 51 | updates[field] = value 52 | 53 | if "parent" in updates: 54 | if updates["parent"]: 55 | updates["parent"] = parent = Department.objects.get( 56 | pk=updates["parent"] 57 | ) 58 | updates["tree"] = (parent.tree or []) + [parent.pk] 59 | else: 60 | updates["tree"] = None 61 | 62 | if updates: 63 | for key, value in updates.items(): 64 | setattr(department, key, value) 65 | department.save(update_fields=updates.keys()) 66 | 67 | return UpdateDepartment(ok=True, department=department) 68 | -------------------------------------------------------------------------------- /backend/atlas/mutations/update_office.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from django.db import transaction 3 | 4 | from atlas.models import Change, Office 5 | from atlas.schema import Decimal, Nullable, OfficeNode 6 | 7 | 8 | class OfficeInput(graphene.InputObjectType): 9 | # name = graphene.String(required=False) 10 | location = graphene.String(required=False) 11 | lat = Nullable(Decimal, required=False) 12 | lng = Nullable(Decimal, required=False) 13 | 14 | 15 | class UpdateOffice(graphene.Mutation): 16 | class Arguments: 17 | office = graphene.UUID(required=True) 18 | data = OfficeInput(required=True) 19 | 20 | ok = graphene.Boolean() 21 | errors = graphene.List(graphene.String) 22 | office = graphene.Field(OfficeNode) 23 | 24 | def mutate(self, info, office: str, data: OfficeInput): 25 | current_user = info.context.user 26 | if not current_user.is_authenticated: 27 | return UpdateOffice(ok=False, errors=["Authentication required"]) 28 | 29 | if not current_user.is_superuser: 30 | return UpdateOffice(ok=False, errors=["Superuser required"]) 31 | 32 | try: 33 | office = Office.objects.get(id=office) 34 | except Office.DoesNotExist: 35 | return UpdateOffice(ok=False, errors=["Invalid user"]) 36 | 37 | with transaction.atomic(): 38 | updates = {} 39 | for field, value in data.items(): 40 | if value == "": 41 | value = None 42 | if getattr(office, field) != value: 43 | updates[field] = value 44 | 45 | if updates: 46 | Change.record(office, updates, user=current_user) 47 | for key, value in updates.items(): 48 | setattr(office, key, value) 49 | office.save(update_fields=list(updates.keys())) 50 | 51 | return UpdateOffice(ok=True, office=office) 52 | -------------------------------------------------------------------------------- /backend/atlas/queries/__init__.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from . import changes, departments, employeetypes, me, offices, teams, users 4 | 5 | 6 | class RootQuery( 7 | me.Query, 8 | changes.Query, 9 | departments.Query, 10 | employeetypes.Query, 11 | offices.Query, 12 | teams.Query, 13 | users.Query, 14 | graphene.ObjectType, 15 | ): 16 | pass 17 | -------------------------------------------------------------------------------- /backend/atlas/queries/changes.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | import graphene 4 | import graphene_django_optimizer as gql_optimizer 5 | from graphql.error import GraphQLError 6 | 7 | from atlas.models import Change 8 | from atlas.schema import ChangeNode 9 | 10 | 11 | class Query(object): 12 | changes = graphene.List( 13 | ChangeNode, 14 | id=graphene.UUID(), 15 | object_type=graphene.String(), 16 | object_id=graphene.UUID(), 17 | offset=graphene.Int(), 18 | limit=graphene.Int(), 19 | ) 20 | 21 | def resolve_changes( 22 | self, 23 | info, 24 | id: str = None, 25 | object_type: str = None, 26 | object_id: UUID = None, 27 | offset: int = 0, 28 | limit: int = 1000, 29 | **kwargs 30 | ): 31 | assert limit <= 1000 32 | assert offset >= 0 33 | 34 | current_user = info.context.user 35 | if not current_user.is_authenticated: 36 | raise GraphQLError("You must be authenticated") 37 | 38 | if not current_user.is_superuser: 39 | raise GraphQLError("You must be superuser") 40 | 41 | qs = Change.objects.all().distinct() 42 | 43 | if id: 44 | qs = qs.filter(id=id) 45 | 46 | if object_type: 47 | qs = qs.filter(object_type=object_type) 48 | 49 | if object_id: 50 | qs = qs.filter(object_id=object_id) 51 | 52 | qs = qs.order_by("-timestamp") 53 | 54 | return gql_optimizer.query(qs, info)[offset:limit] 55 | -------------------------------------------------------------------------------- /backend/atlas/queries/employeetypes.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from graphql.error import GraphQLError 3 | 4 | from atlas.schema import EmployeeTypeEnum, EmployeeTypeNode 5 | 6 | 7 | class Query(object): 8 | employee_types = graphene.List( 9 | EmployeeTypeNode, name=graphene.String(), query=graphene.String() 10 | ) 11 | 12 | def resolve_employee_types( 13 | self, info, name: str = None, query: str = None, **kwargs 14 | ): 15 | current_user = info.context.user 16 | if not current_user.is_authenticated: 17 | raise GraphQLError("You must be authenticated") 18 | 19 | results = ( 20 | EmployeeTypeEnum.FULL_TIME, 21 | EmployeeTypeEnum.CONTRACT, 22 | EmployeeTypeEnum.INTERN, 23 | ) 24 | 25 | if name: 26 | results = [r for r in results if str(r) == name] 27 | 28 | if query: 29 | results = [r for r in results if str(r).startswith(name)] 30 | 31 | return [{"id": r.name} for r in results] 32 | -------------------------------------------------------------------------------- /backend/atlas/queries/me.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from atlas.schema import UserNode 4 | 5 | 6 | class Query(object): 7 | me = graphene.Field(UserNode) 8 | 9 | def resolve_me(self, info, **kwargs): 10 | if info.context.user.is_authenticated: 11 | return info.context.user 12 | return None 13 | -------------------------------------------------------------------------------- /backend/atlas/queries/offices.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | import graphene_django_optimizer as gql_optimizer 3 | from graphql.error import GraphQLError 4 | 5 | from atlas.models import Office 6 | from atlas.schema import OfficeNode 7 | 8 | 9 | class Query(object): 10 | offices = graphene.List( 11 | OfficeNode, 12 | id=graphene.UUID(), 13 | external_id=graphene.String(), 14 | query=graphene.String(), 15 | offset=graphene.Int(), 16 | limit=graphene.Int(), 17 | ) 18 | 19 | def resolve_offices( 20 | self, 21 | info, 22 | id: str = None, 23 | external_id: str = None, 24 | query: str = None, 25 | offset: int = 0, 26 | limit: int = 1000, 27 | **kwargs 28 | ): 29 | assert limit <= 1000 30 | assert offset >= 0 31 | 32 | current_user = info.context.user 33 | if not current_user.is_authenticated: 34 | raise GraphQLError("You must be authenticated") 35 | 36 | qs = Office.objects.all().distinct() 37 | 38 | if id: 39 | qs = qs.filter(id=id) 40 | 41 | if external_id: 42 | qs = qs.filter(external_id=external_id) 43 | 44 | if query: 45 | qs = qs.filter(name__istartswith=query) 46 | 47 | qs = qs.exclude(name__istartswith="$$") 48 | 49 | qs = qs.order_by("name") 50 | 51 | return gql_optimizer.query(qs, info)[offset:limit] 52 | -------------------------------------------------------------------------------- /backend/atlas/queries/teams.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | import graphene_django_optimizer as gql_optimizer 3 | from graphql.error import GraphQLError 4 | 5 | from atlas.models import Team 6 | from atlas.schema import TeamNode 7 | 8 | 9 | class Query(object): 10 | teams = graphene.List( 11 | TeamNode, 12 | id=graphene.UUID(), 13 | query=graphene.String(), 14 | people_only=graphene.Boolean(default_value=False), 15 | offset=graphene.Int(), 16 | limit=graphene.Int(), 17 | ) 18 | 19 | def resolve_teams( 20 | self, 21 | info, 22 | id: str = None, 23 | query: str = None, 24 | people_only: bool = False, 25 | offset: int = 0, 26 | limit: int = 1000, 27 | **kwargs 28 | ): 29 | assert limit <= 1000 30 | assert offset >= 0 31 | 32 | current_user = info.context.user 33 | if not current_user.is_authenticated: 34 | raise GraphQLError("You must be authenticated") 35 | 36 | qs = Team.objects.all().distinct() 37 | 38 | if id: 39 | qs = qs.filter(id=id) 40 | 41 | if people_only: 42 | qs = qs.filter( 43 | profiles__is_human=True, 44 | profiles__user__is_active=True, 45 | profiles__is_directory_hidden=False, 46 | ).exclude(profiles=None) 47 | 48 | if query: 49 | qs = qs.filter(name__istartswith=query) 50 | 51 | qs = qs.order_by("name") 52 | 53 | return gql_optimizer.query(qs, info)[offset:limit] 54 | -------------------------------------------------------------------------------- /backend/atlas/queries/test_changes.py: -------------------------------------------------------------------------------- 1 | from atlas.models import Change 2 | 3 | 4 | def test_changes(gql_client, default_superuser, default_office): 5 | change = Change.objects.create( 6 | object_type="user", 7 | object_id=default_superuser.id, 8 | changes={"name": "Joe Dirt"}, 9 | user=default_superuser, 10 | version=1, 11 | ) 12 | 13 | executed = gql_client.execute("""{changes {id}}""", user=default_superuser) 14 | assert executed["data"]["changes"] == [{"id": str(change.id)}] 15 | -------------------------------------------------------------------------------- /backend/atlas/queries/test_departments.py: -------------------------------------------------------------------------------- 1 | def test_departments(gql_client, default_user, design_department): 2 | executed = gql_client.execute("""{departments {id, name}}""", user=default_user) 3 | assert executed["data"]["departments"] == [ 4 | {"id": str(design_department.id), "name": design_department.name} 5 | ] 6 | 7 | 8 | def test_departments_query_with_results(gql_client, default_user, design_department): 9 | executed = gql_client.execute( 10 | """{departments(query:"Design") {id}}""", user=default_user 11 | ) 12 | assert executed["data"]["departments"] == [{"id": str(design_department.id)}] 13 | 14 | 15 | def test_departments_query_no_results(gql_client, default_user, design_department): 16 | executed = gql_client.execute( 17 | """{departments(query:"Phish") {id}}""", user=default_user 18 | ) 19 | assert executed["data"]["departments"] == [] 20 | 21 | 22 | def test_departments_with_tree( 23 | gql_client, default_user, design_department, creative_department 24 | ): 25 | executed = gql_client.execute( 26 | """{departments {id, tree {id}}}""", user=default_user 27 | ) 28 | assert executed["data"]["departments"] == [ 29 | {"id": str(design_department.id), "tree": []}, 30 | { 31 | "id": str(creative_department.id), 32 | "tree": [{"id": str(design_department.id)}], 33 | }, 34 | ] 35 | -------------------------------------------------------------------------------- /backend/atlas/queries/test_me.py: -------------------------------------------------------------------------------- 1 | def test_me_logged_in(gql_client, default_user): 2 | executed = gql_client.execute("""{me {id, email, name}}""", user=default_user) 3 | assert executed["data"]["me"] == { 4 | "id": str(default_user.id), 5 | "email": default_user.email, 6 | "name": default_user.name, 7 | } 8 | 9 | 10 | def test_me_logged_out(gql_client, default_user): 11 | executed = gql_client.execute("""{me {id, email, name}}""") 12 | assert executed["data"]["me"] is None 13 | -------------------------------------------------------------------------------- /backend/atlas/queries/test_offices.py: -------------------------------------------------------------------------------- 1 | def test_offices(gql_client, default_user, default_office): 2 | executed = gql_client.execute("""{offices {id, name}}""", user=default_user) 3 | assert executed["data"]["offices"] == [ 4 | {"id": str(default_office.id), "name": default_office.name} 5 | ] 6 | 7 | 8 | def test_offices_query_with_results(gql_client, default_user, default_office): 9 | executed = gql_client.execute("""{offices(query:"SF") {id}}""", user=default_user) 10 | assert executed["data"]["offices"] == [{"id": str(default_office.id)}] 11 | 12 | 13 | def test_offices_query_no_results(gql_client, default_user, default_office): 14 | executed = gql_client.execute( 15 | """{offices(query:"Phish") {id}}""", user=default_user 16 | ) 17 | assert executed["data"]["offices"] == [] 18 | -------------------------------------------------------------------------------- /backend/atlas/queries/test_teams.py: -------------------------------------------------------------------------------- 1 | def test_teams(gql_client, default_user, default_team): 2 | executed = gql_client.execute("""{teams {id, name}}""", user=default_user) 3 | assert executed["data"]["teams"] == [ 4 | {"id": str(default_team.id), "name": default_team.name} 5 | ] 6 | 7 | 8 | def test_teams_query_with_results(gql_client, default_user, default_team): 9 | executed = gql_client.execute( 10 | """{teams(query:"Workflow") {id}}""", user=default_user 11 | ) 12 | assert executed["data"]["teams"] == [{"id": str(default_team.id)}] 13 | 14 | 15 | def test_teams_query_no_results(gql_client, default_user, default_team): 16 | executed = gql_client.execute("""{teams(query:"Phish") {id}}""", user=default_user) 17 | assert executed["data"]["teams"] == [] 18 | -------------------------------------------------------------------------------- /backend/atlas/root_schema.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | import atlas.mutations 4 | import atlas.queries 5 | 6 | schema = graphene.Schema( 7 | query=atlas.queries.RootQuery, mutation=atlas.mutations.RootMutation 8 | ) 9 | -------------------------------------------------------------------------------- /backend/atlas/schema/__init__.py: -------------------------------------------------------------------------------- 1 | from .binary import * # NOQA 2 | from .change import * # NOQA 3 | from .decimal import * # NOQA 4 | from .department import * # NOQA 5 | from .departmentinput import * # NOQA 6 | from .employeetype import * # NOQA 7 | from .nullable import * # NOQA 8 | from .office import * # NOQA 9 | from .phonenumber import * # NOQA 10 | from .photo import * # NOQA 11 | from .team import * # NOQA 12 | from .user import * # NOQA 13 | from .userinput import * # NOQA 14 | -------------------------------------------------------------------------------- /backend/atlas/schema/binary.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | import graphene 4 | from graphql.language.ast import StringValue 5 | 6 | 7 | class BinaryField(graphene.Scalar): 8 | class Meta: 9 | name = "Binary" 10 | 11 | @staticmethod 12 | def coerce_binary(value): 13 | if isinstance(value, memoryview): 14 | return b64encode(value.tobytes()).decode("utf-8") 15 | elif isinstance(value, bytes): 16 | return b64encode(value).decode("utf-8") 17 | return value 18 | 19 | serialize = coerce_binary 20 | parse_value = coerce_binary 21 | 22 | @staticmethod 23 | def parse_literal(ast): 24 | if isinstance(ast, StringValue): 25 | return ast.value 26 | -------------------------------------------------------------------------------- /backend/atlas/schema/change.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | import graphene_django_optimizer as gql_optimizer 3 | 4 | from atlas.models import Change, Department, Office, User 5 | 6 | from .department import DepartmentNode 7 | from .office import OfficeNode 8 | from .user import UserNode 9 | 10 | TYPE_MAP = {"department": Department, "office": Office, "user": User} 11 | 12 | 13 | class ChangeNode(gql_optimizer.OptimizedDjangoObjectType): 14 | object_department = graphene.Field(DepartmentNode) 15 | object_office = graphene.Field(OfficeNode) 16 | object_user = graphene.Field(UserNode) 17 | 18 | class Meta: 19 | model = Change 20 | name = "Change" 21 | fields = ( 22 | "id", 23 | "object_type", 24 | "object_id", 25 | "user", 26 | "changes", 27 | "previous", 28 | "timestamp", 29 | "version", 30 | ) 31 | 32 | def get_object(self): 33 | object_cache = getattr(self, "object_cache", {}) 34 | result = object_cache.get(self.object_type, {}).get(self.object_id, -1) 35 | if result is -1: 36 | model = TYPE_MAP[self.object_type] 37 | try: 38 | result = model.objects.get(id=self.object_id) 39 | except model.DoesNotExist: 40 | result = None 41 | object_cache.setdefault(self.object_type, {})[self.object_id] = result 42 | return result 43 | 44 | def resolve_object_department(self, info): 45 | if self.object_type != "department": 46 | return None 47 | return ChangeNode.get_object(self) 48 | 49 | def resolve_object_office(self, info): 50 | if self.object_type != "office": 51 | return None 52 | return ChangeNode.get_object(self) 53 | 54 | def resolve_object_user(self, info): 55 | if self.object_type != "user": 56 | return None 57 | return ChangeNode.get_object(self) 58 | -------------------------------------------------------------------------------- /backend/atlas/schema/dayschedule.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class DaySchedule(graphene.Enum): 5 | WFH = "WFH" 6 | INOFFICE = "INOFFICE" 7 | OFF = "OFF" 8 | NONE = "" 9 | 10 | @property 11 | def description(self): 12 | if self == DaySchedule.WFH: 13 | return "Work From Home" 14 | if self == DaySchedule.INOFFICE: 15 | return "In Office" 16 | if self == DaySchedule.OFF: 17 | return "Off" 18 | return "" 19 | -------------------------------------------------------------------------------- /backend/atlas/schema/decimal.py: -------------------------------------------------------------------------------- 1 | # XXX: Adapted from Upstream which wasnt in our version 2 | 3 | from decimal import Decimal as _Decimal 4 | 5 | from graphene import Scalar 6 | from graphql.language import ast 7 | 8 | 9 | class Decimal(Scalar): 10 | """ 11 | The `Decimal` scalar type represents a python Decimal. 12 | """ 13 | 14 | @staticmethod 15 | def serialize(dec): 16 | if isinstance(dec, str): 17 | dec = _Decimal(dec) 18 | assert isinstance(dec, _Decimal), 'Received not compatible Decimal "{}"'.format( 19 | repr(dec) 20 | ) 21 | return str(dec) 22 | 23 | @classmethod 24 | def parse_literal(cls, node): 25 | if isinstance(node, ast.StringValue): 26 | return cls.parse_value(node.value) 27 | 28 | @staticmethod 29 | def parse_value(value): 30 | try: 31 | return _Decimal(value) 32 | except ValueError: 33 | return None 34 | -------------------------------------------------------------------------------- /backend/atlas/schema/department.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import graphene 4 | import graphene_django_optimizer as gql_optimizer 5 | from django.db.models import Q 6 | 7 | from atlas.models import Department, Profile 8 | 9 | 10 | class DepartmentNode(gql_optimizer.OptimizedDjangoObjectType): 11 | num_people = graphene.Int(required=False) 12 | parent = graphene.Field(lambda: DepartmentNode) 13 | tree = graphene.List(lambda: DepartmentNode) 14 | 15 | class Meta: 16 | model = Department 17 | name = "Department" 18 | fields = ("id", "name", "cost_center") 19 | 20 | def resolve_tree(self, info): 21 | if not self.id or not self.tree: 22 | return [] 23 | if hasattr(self, "_tree_cache"): 24 | qs = self._tree_cache 25 | else: 26 | qs = Department.objects.filter(id__in=self.tree) 27 | if ( 28 | not hasattr(self, "_prefetched_objects_cache") 29 | or "tree" not in self._prefetched_objects_cache 30 | ): 31 | logging.warning("Uncached resolution for DepartmentNode.tree") 32 | self._tree_cache = list(qs) 33 | if len(qs) != len(self.tree): 34 | logging.warning( 35 | "Missing nodes for DepartmentNode.tree (department_id=%s)", self.id 36 | ) 37 | results = {d.id: d for d in qs if d} 38 | return [results[i] for i in self.tree] 39 | 40 | def resolve_num_people(self, info): 41 | if hasattr(self, "num_people"): 42 | return self.num_people 43 | if not self.id: 44 | return 0 45 | qs = Profile.objects.filter( 46 | Q(department=self.id) | Q(department__tree__contains=[self.id]), 47 | is_human=True, 48 | is_directory_hidden=False, 49 | ) 50 | if ( 51 | not hasattr(self, "_prefetched_objects_cache") 52 | or "people" not in self._prefetched_objects_cache 53 | ): 54 | logging.warning("Uncached resolution for DepartmentNode.num_people") 55 | qs = qs.select_related("user") 56 | return sum( 57 | [ 58 | 1 59 | for r in qs 60 | if r.user.is_active and r.is_human and not r.is_directory_hidden 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /backend/atlas/schema/departmentinput.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from .nullable import Nullable 4 | 5 | 6 | class DepartmentInput(graphene.InputObjectType): 7 | id = graphene.UUID(required=False) 8 | name = graphene.String(required=False) 9 | parent = Nullable(graphene.UUID, required=False) 10 | cost_center = Nullable(graphene.Int, required=False) 11 | -------------------------------------------------------------------------------- /backend/atlas/schema/employeetype.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import graphene 4 | 5 | from atlas.models import Profile 6 | 7 | 8 | class EmployeeTypeEnum(graphene.Enum): 9 | NONE = "" 10 | FULL_TIME = "Full-time" 11 | CONTRACT = "Contract" 12 | INTERN = "Intern" 13 | 14 | 15 | class EmployeeTypeNode(graphene.ObjectType): 16 | id = graphene.String() 17 | name = graphene.String() 18 | num_people = graphene.Int(required=False) 19 | 20 | def resolve_num_people(self, info): 21 | if "num_people" in self: 22 | return self["num_people"] 23 | if not self["id"]: 24 | return 0 25 | qs = Profile.objects.filter( 26 | employee_type=self["id"], is_human=True, is_directory_hidden=False 27 | ) 28 | if ( 29 | not hasattr(self, "_prefetched_objects_cache") 30 | or "people" not in self._prefetched_objects_cache 31 | ): 32 | logging.warning("Uncached resolution for EmployeeTypeNode.num_people") 33 | qs = qs.select_related("user") 34 | return sum( 35 | [ 36 | 1 37 | for r in qs 38 | if r.user.is_active and r.is_human and not r.is_directory_hidden 39 | ] 40 | ) 41 | 42 | def resolve_name(self, info): 43 | if not self["id"]: 44 | return "Unknown" 45 | return EmployeeTypeEnum[self["id"]].value 46 | 47 | 48 | ALL_EMPLOYEE_TYPES = ( 49 | EmployeeTypeEnum.FULL_TIME, 50 | EmployeeTypeEnum.CONTRACT, 51 | EmployeeTypeEnum.INTERN, 52 | ) 53 | -------------------------------------------------------------------------------- /backend/atlas/schema/nullable.py: -------------------------------------------------------------------------------- 1 | FIELD_CACHE = {} 2 | 3 | 4 | def Nullable(cls, **kwargs): 5 | if cls in FIELD_CACHE: 6 | return FIELD_CACHE[cls](**kwargs) 7 | 8 | class new(cls): 9 | class Meta: 10 | name = f"Nullable{cls._meta.name}" 11 | 12 | @staticmethod 13 | def serialize(value): 14 | if not value: 15 | return None 16 | return cls.serialize(value) 17 | 18 | @staticmethod 19 | def parse_value(value): 20 | if not value: 21 | return "" 22 | return cls.parse_value(value) 23 | 24 | new.__name__ = f"Nullable{type(cls).__name__}" 25 | new.__doc__ = cls.__doc__ 26 | FIELD_CACHE[cls] = new 27 | return new(**kwargs) 28 | -------------------------------------------------------------------------------- /backend/atlas/schema/office.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import graphene 4 | import graphene_django_optimizer as gql_optimizer 5 | 6 | from atlas.models import Office 7 | 8 | from .decimal import Decimal 9 | 10 | 11 | class OfficeNode(gql_optimizer.OptimizedDjangoObjectType): 12 | lat = Decimal(required=False) 13 | lng = Decimal(required=False) 14 | num_people = graphene.Int(required=False) 15 | 16 | class Meta: 17 | model = Office 18 | name = "Office" 19 | fields = ( 20 | "id", 21 | "external_id", 22 | "name", 23 | "description", 24 | "location", 25 | "region_code", 26 | "postal_code", 27 | "locality", 28 | "administrative_area", 29 | "lat", 30 | "lng", 31 | ) 32 | 33 | @gql_optimizer.resolver_hints(prefetch_related=("profiles", "profiles__user")) 34 | def resolve_num_people(self, info): 35 | if hasattr(self, "num_people"): 36 | return self.num_people 37 | if not self.id: 38 | return 0 39 | qs = self.profiles.filter(is_human=True, is_directory_hidden=False) 40 | if ( 41 | not hasattr(self, "_prefetched_objects_cache") 42 | or "people" not in self._prefetched_objects_cache 43 | ): 44 | logging.warning("Uncached resolution for OfficeNode.num_people") 45 | qs = qs.select_related("user") 46 | return sum( 47 | [ 48 | 1 49 | for r in qs 50 | if r.user.is_active and r.is_human and not r.is_directory_hidden 51 | ] 52 | ) 53 | 54 | @gql_optimizer.resolver_hints(prefetch_related=("profiles", "profiles__user")) 55 | def resolve_people(self, info): 56 | if not self.id: 57 | return [] 58 | qs = self.profiles.filter(is_human=True, is_directory_hidden=False) 59 | if ( 60 | not hasattr(self, "_prefetched_objects_cache") 61 | or "people" not in self._prefetched_objects_cache 62 | ): 63 | logging.warning("Uncached resolution for OfficeNode.people") 64 | qs = qs.select_related("user") 65 | return [ 66 | r.user 67 | for r in qs 68 | if r.user.is_active and r.is_human and not r.is_directory_hidden 69 | ] 70 | -------------------------------------------------------------------------------- /backend/atlas/schema/phonenumber.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | import phonenumbers 3 | from graphql.language.ast import StringValue 4 | 5 | FORMAT = phonenumbers.PhoneNumberFormat.INTERNATIONAL 6 | 7 | DEFAULT_REGION = "US" 8 | 9 | 10 | class PhoneNumberField(graphene.Scalar): 11 | class Meta: 12 | name = "PhoneNumber" 13 | 14 | @staticmethod 15 | def coerce_phone_number(value): 16 | if not value: 17 | return "" 18 | return phonenumbers.format_number( 19 | phonenumbers.parse(value, DEFAULT_REGION), FORMAT 20 | ) 21 | 22 | serialize = coerce_phone_number 23 | parse_value = coerce_phone_number 24 | 25 | @staticmethod 26 | def parse_literal(ast): 27 | if isinstance(ast, StringValue): 28 | return phonenumbers.format_number( 29 | phonenumbers.parse(ast.value, DEFAULT_REGION), FORMAT 30 | ) 31 | -------------------------------------------------------------------------------- /backend/atlas/schema/photo.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | from graphene_django.types import DjangoObjectType 3 | 4 | from atlas.models import Photo 5 | 6 | from .binary import BinaryField 7 | 8 | 9 | class PhotoNode(DjangoObjectType): 10 | data = BinaryField(required=False) 11 | width = graphene.Int(required=False) 12 | height = graphene.Int(required=False) 13 | mime_type = graphene.String(required=True) 14 | 15 | class Meta: 16 | model = Photo 17 | name = "Photo" 18 | fields = ("width", "height", "mime_type") 19 | -------------------------------------------------------------------------------- /backend/atlas/schema/pronouns.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class Pronouns(graphene.Enum): 5 | NONE = "" 6 | HE_HIM = "HE_HIM" 7 | SHE_HER = "SHE_HER" 8 | THEY_THEM = "THEY_THEM" 9 | OTHER = "OTHER" 10 | DECLINE = "DECLINE" 11 | 12 | @property 13 | def description(self): 14 | if self == Pronouns.HE_HIM: 15 | return "he / him" 16 | if self == Pronouns.SHE_HER: 17 | return "she / her" 18 | if self == Pronouns.THEY_THEM: 19 | return "they / them" 20 | if self == Pronouns.OTHER: 21 | return "other" 22 | if self == Pronouns.DECLINE: 23 | return "decline to choose" 24 | return "" 25 | -------------------------------------------------------------------------------- /backend/atlas/schema/team.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import graphene 4 | import graphene_django_optimizer as gql_optimizer 5 | 6 | from atlas.models import Team 7 | 8 | 9 | class TeamNode(gql_optimizer.OptimizedDjangoObjectType): 10 | num_people = graphene.Int(required=False) 11 | 12 | class Meta: 13 | model = Team 14 | name = "Team" 15 | fields = ("id", "name", "description") 16 | 17 | @gql_optimizer.resolver_hints(prefetch_related=("profiles", "profiles__user")) 18 | def resolve_num_people(self, info): 19 | if hasattr(self, "num_people"): 20 | return self.num_people 21 | if not self.id: 22 | return 0 23 | qs = self.profiles.filter(is_human=True, is_directory_hidden=False) 24 | if ( 25 | not hasattr(self, "_prefetched_objects_cache") 26 | or "people" not in self._prefetched_objects_cache 27 | ): 28 | logging.warning("Uncached resolution for TeamNode.num_people") 29 | qs = qs.select_related("user") 30 | return sum( 31 | [ 32 | 1 33 | for r in qs 34 | if r.user.is_active and r.is_human and not r.is_directory_hidden 35 | ] 36 | ) 37 | 38 | @gql_optimizer.resolver_hints(prefetch_related=("profiles", "profiles__user")) 39 | def resolve_people(self, info): 40 | if not self.id: 41 | return [] 42 | qs = self.profiles.filter(is_human=True, is_directory_hidden=False) 43 | if ( 44 | not hasattr(self, "_prefetched_objects_cache") 45 | or "people" not in self._prefetched_objects_cache 46 | ): 47 | logging.warning("Uncached resolution for TeamNode.people") 48 | qs = qs.select_related("user") 49 | return [ 50 | r.user 51 | for r in qs 52 | if r.user.is_active and r.is_human and not r.is_directory_hidden 53 | ] 54 | -------------------------------------------------------------------------------- /backend/atlas/schema/userinput.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from .dayschedule import DaySchedule 4 | from .nullable import Nullable 5 | from .phonenumber import PhoneNumberField 6 | from .pronouns import Pronouns 7 | 8 | 9 | class ScheduleInput(graphene.InputObjectType): 10 | sunday = DaySchedule(default_value=DaySchedule.OFF) 11 | monday = DaySchedule(default_value=DaySchedule.INOFFICE) 12 | tuesday = DaySchedule(default_value=DaySchedule.INOFFICE) 13 | wednesday = DaySchedule(default_value=DaySchedule.INOFFICE) 14 | thursday = DaySchedule(default_value=DaySchedule.INOFFICE) 15 | friday = DaySchedule(default_value=DaySchedule.INOFFICE) 16 | saturday = DaySchedule(default_value=DaySchedule.OFF) 17 | 18 | 19 | class SocialInput(graphene.InputObjectType): 20 | linkedin = graphene.String(required=False) 21 | github = graphene.String(required=False) 22 | twitter = graphene.String(required=False) 23 | 24 | 25 | class GamerTagsInput(graphene.InputObjectType): 26 | steam = graphene.String(required=False) 27 | xbox = graphene.String(required=False) 28 | playstation = graphene.String(required=False) 29 | nintendo = graphene.String(required=False) 30 | 31 | 32 | class UserInput(graphene.InputObjectType): 33 | id = graphene.UUID(required=False) 34 | name = graphene.String(required=False) 35 | handle = graphene.String(required=False) 36 | bio = graphene.String(required=False) 37 | pronouns = Pronouns(required=False) 38 | date_of_birth = Nullable(graphene.Date, required=False) 39 | date_started = Nullable(graphene.Date, required=False) 40 | employee_type = graphene.String(required=False) 41 | title = graphene.String(required=False) 42 | department = Nullable(graphene.UUID, required=False) 43 | team = Nullable(graphene.String, required=False) 44 | reports_to = Nullable(graphene.UUID, required=False) 45 | referred_by = Nullable(graphene.UUID, required=False) 46 | primary_phone = Nullable(PhoneNumberField, required=False) 47 | is_human = graphene.Boolean(required=False) 48 | is_directory_hidden = graphene.Boolean(required=False) 49 | office = Nullable(graphene.String, required=False) 50 | is_superuser = graphene.Boolean(required=False) 51 | social = Nullable(SocialInput, required=False) 52 | gamer_tags = Nullable(GamerTagsInput, required=False) 53 | schedule = Nullable(ScheduleInput, required=False) 54 | -------------------------------------------------------------------------------- /backend/atlas/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync_google import * # NOQA 2 | -------------------------------------------------------------------------------- /backend/atlas/tasks/sync_google.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from django.conf import settings 3 | 4 | from atlas.models import User 5 | from atlas.utils import google 6 | 7 | 8 | @shared_task(name="atlas.tasks.sync_google") 9 | def sync_google(domain=None): 10 | if domain is None: 11 | domain = settings.GOOGLE_DOMAIN 12 | 13 | identity = google.get_admin_identity() 14 | google.sync_domain(identity, domain) 15 | 16 | 17 | @shared_task(name="atlas.tasks.update_profile") 18 | def update_profile(user_id, updates, version=None): 19 | if not settings.GOOGLE_PUSH_UPDATES: 20 | return 21 | user = User.objects.get(id=user_id) 22 | identity = google.get_admin_identity() 23 | google.update_profile(identity, user, updates, version=version) 24 | -------------------------------------------------------------------------------- /backend/atlas/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.urls import include, path 4 | from django.views.decorators.csrf import csrf_exempt 5 | 6 | from atlas.views import EnhancedGraphQLView 7 | 8 | urlpatterns = [ 9 | path("healthz/", include("health_check.urls")), 10 | path("graphql/", csrf_exempt(EnhancedGraphQLView.as_view(graphiql=settings.DEBUG))), 11 | ] 12 | if settings.MEDIA_ROOT: 13 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 14 | -------------------------------------------------------------------------------- /backend/atlas/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/backend/atlas/utils/__init__.py -------------------------------------------------------------------------------- /backend/atlas/utils/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from hashlib import sha1 3 | from typing import Optional 4 | 5 | from django.conf import settings 6 | from django.contrib.auth.models import User 7 | from itsdangerous import BadSignature, TimedJSONWebSignatureSerializer 8 | 9 | logger = logging.getLogger("atlas") 10 | 11 | 12 | def generate_token(user: User, expires_in: int = 3600 * 24 * 30) -> str: 13 | s = TimedJSONWebSignatureSerializer( 14 | settings.SECRET_KEY, expires_in=expires_in, salt="auth" 15 | ) 16 | payload = {"uid": str(user.id), "sh": security_hash(user)} 17 | return s.dumps(payload).decode("utf-8") 18 | 19 | 20 | def parse_token(token: str) -> Optional[str]: 21 | s = TimedJSONWebSignatureSerializer(settings.SECRET_KEY, salt="auth") 22 | try: 23 | payload = s.loads(token) 24 | except BadSignature: 25 | logger.warning("auth.bad-signature") 26 | return None 27 | if "uid" not in payload: 28 | logger.warning("auth.missing-uid") 29 | return None 30 | if "sh" not in payload: 31 | logger.warning("auth.missing-security-hash") 32 | return None 33 | return payload 34 | 35 | 36 | def security_hash(user: User) -> str: 37 | return sha1((user.password or str(user.id)).encode("utf-8")).hexdigest() 38 | -------------------------------------------------------------------------------- /backend/atlas/utils/graphql.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | 3 | 4 | class TracingMiddleware(object): 5 | def resolve(self, _next, root, info, *args, **kwargs): 6 | span = sentry_sdk.Hub.current.start_span( 7 | # transaction=str(info.path[0]) if len(info.path) == 1 else None, 8 | op="graphql.resolve", 9 | description=".".join(str(p) for p in info.path), 10 | ) 11 | span.__enter__() 12 | # XXX(dcramer): we cannot use .then() on the promise here as the order of 13 | # execution is a stack, meaning the first resolved call in a list ends up 14 | # not popping off of the tree until every other child span has been created 15 | # (which is not actually how the execution tree looks) 16 | try: 17 | return _next(root, info, *args, **kwargs) 18 | finally: 19 | span.__exit__(None, None, None) 20 | -------------------------------------------------------------------------------- /backend/atlas/utils/query.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Aggregate, FloatField 2 | 3 | 4 | # http://www.evanmiller.org/how-not-to-sort-by-average-rating.html 5 | class WilsonScore(Aggregate): 6 | name = "WilsonScore" 7 | template = ( 8 | "CASE WHEN COUNT(%(expressions)s) > 0 THEN ((COUNT(CASE WHEN %(expressions)s > 3 THEN 1 END) + 1.9208) / (COUNT(%(expressions)s)) - " 9 | "1.96 * SQRT((COUNT(%(expressions)s)) / (COUNT(%(expressions)s)) + 0.9604) / " 10 | "(COUNT(%(expressions)s))) / (1 + 3.8416 / (COUNT(%(expressions)s))) ELSE 0 END" 11 | ) 12 | output_field = FloatField() 13 | 14 | def convert_value(self, value, expression, connection): 15 | return 0.0 if value is None else value 16 | -------------------------------------------------------------------------------- /backend/atlas/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | 4 | import sentry_sdk 5 | from graphene_file_upload.django import FileUploadGraphQLView 6 | 7 | logger = logging.getLogger("atlas") 8 | 9 | 10 | def get_operation_name(params): 11 | operation_name = params.get("operationName") 12 | if operation_name: 13 | return operation_name 14 | query = params.get("query") 15 | if not query: 16 | return "invalid query" 17 | return "unnamed operation ({})".format(hashlib.sha1(query).hexdigest()) 18 | 19 | 20 | class EnhancedGraphQLView(FileUploadGraphQLView): 21 | # https://github.com/graphql-python/graphene-django/issues/124 22 | def execute_graphql_request(self, request, params, *args, **kwargs): 23 | """Extract any exceptions and send them to Sentry""" 24 | with sentry_sdk.configure_scope() as scope: 25 | scope.transaction = "{} {}".format(request.path, get_operation_name(params)) 26 | result = super().execute_graphql_request(request, params, *args, **kwargs) 27 | if result and result.errors: 28 | for error in result.errors: 29 | if hasattr(error, "original_error"): 30 | try: 31 | raise error.original_error 32 | except Exception as e: 33 | logger.exception(e) 34 | sentry_sdk.capture_exception(e) 35 | else: 36 | logger.error(error) 37 | sentry_sdk.capture_message(error, "error") 38 | return result 39 | -------------------------------------------------------------------------------- /backend/atlas/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for atlas 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 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "atlas.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/bin/atlas: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from atlas.cli import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /backend/bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Perform an upgrade before booting up web processes 6 | case "$1" in 7 | atlas) 8 | case "$2" in 9 | web) 10 | gosu app atlas migrate 11 | ;; 12 | esac 13 | ;; 14 | esac 15 | 16 | # Check if we're trying to execute a bin 17 | if [ -f "/usr/src/app/bin/$1" ]; then 18 | if [ "$(id -u)" = '0' ]; then 19 | exec gosu app "$@" 20 | fi 21 | fi 22 | 23 | exec "$@" 24 | -------------------------------------------------------------------------------- /backend/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/cloud-builders/docker" 3 | entrypoint: "bash" 4 | args: ["-c", "docker pull us.gcr.io/$PROJECT_ID/$atlas-backend:latest || true"] 5 | - name: "gcr.io/cloud-builders/docker" 6 | args: 7 | [ 8 | "build", 9 | "--cache-from", 10 | "us.gcr.io/$PROJECT_ID/atlas-backend:latest", 11 | "--build-arg", 12 | "BUILD_REVISION=$COMMIT_SHA", 13 | "-t", 14 | "us.gcr.io/$PROJECT_ID/atlas-backend:$COMMIT_SHA", 15 | "-t", 16 | "us.gcr.io/$PROJECT_ID/atlas-backend:latest", 17 | ".", 18 | ] 19 | images: 20 | - "us.gcr.io/$PROJECT_ID/atlas-backend:$COMMIT_SHA" 21 | - "us.gcr.io/$PROJECT_ID/atlas-backend:latest" 22 | options: 23 | substitution_option: "ALLOW_LOOSE" 24 | logsBucket: "gs://sentryio-cloudbuild-logs/getsentry/" 25 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "atlas-backend" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["David Cramer "] 6 | license = "Apache-2.0" 7 | packages = [ 8 | { include = "atlas"} 9 | ] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.8" 13 | django = "^2.1" 14 | psycopg2-binary = "^2.7" 15 | itsdangerous = "^1.1" 16 | graphene-django = "^2.2" 17 | gunicorn = "^19.9" 18 | factory-boy = "^2.11" 19 | django-enumfields = "^1.0" 20 | google-auth = "^1.6" 21 | sentry-sdk = "^1.0" 22 | requests = "^2.22" 23 | aniso8601 = "^5.0" 24 | graphene-django-optimizer = "^0.4.0" 25 | celery = "^4.3" 26 | django-celery-beat = "^1.5" 27 | redis = "^3.2" 28 | phonenumbers = "^8.10" 29 | django-health-check = "^3.10" 30 | dj-database-url = "^0.5.0" 31 | graphene-file-upload = "^1.2.2" 32 | 33 | [tool.poetry.dev-dependencies] 34 | black = "=19.3b0" 35 | flake8 = "^3.6" 36 | pytest = "^4.6" 37 | pytest-django = "^3.5" 38 | pytest-xdist = "^1.28" 39 | pytest-timeout = "^1.3" 40 | pytest-cov = "^2" 41 | pytest-responses = "^0.4.0" 42 | responses = "^0.10.6" 43 | 44 | [tool.poetry.scripts] 45 | atlas = 'atlas.cli:main' 46 | 47 | [build-system] 48 | requires = ["poetry>=1.1.4"] 49 | build-backend = "poetry.masonry.api" 50 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/cloud-builders/gcloud" 3 | entrypoint: "bash" 4 | args: 5 | - "-ceu" 6 | - | 7 | for d in */; do 8 | config="${d}cloudbuild.yaml" 9 | if [[ ! -f "${config}" ]]; then 10 | echo "No cloudbuild.yaml found in $d" 11 | continue 12 | fi 13 | 14 | echo "Building $d ... " 15 | ( 16 | gcloud builds submit $d --substitutions=COMMIT_SHA=$COMMIT_SHA --config=${config} 17 | ) 18 | done 19 | wait 20 | logsBucket: "gs://sentryio-cloudbuild-logs/getsentry/" 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | db: 5 | image: postgres:10.1-alpine 6 | volumes: 7 | - postgres_data:/var/lib/postgresql/data/ 8 | ports: 9 | - 5432:5432 10 | redis: 11 | image: redis:5.0-alpine 12 | volumes: 13 | - redis_data:/data 14 | ports: 15 | - 6379:6379 16 | # frontend: 17 | # build: frontend 18 | # # command: npm dev 19 | # # volumes: 20 | # # - frontend:/usr/src/app/frontend 21 | # ports: 22 | # - 3000:3000 23 | # backend: 24 | # build: backend 25 | # # command: python /usr/src/app/bin/atlas runserver 0.0.0.0:8000 26 | # # volumes: 27 | # # - backend:/usr/src/app/backend 28 | # ports: 29 | # - 8000:8000 30 | # depends_on: 31 | # - db 32 | 33 | volumes: 34 | postgres_data: 35 | redis_data: 36 | -------------------------------------------------------------------------------- /frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | node_modules 4 | config 5 | scripts 6 | .next 7 | -------------------------------------------------------------------------------- /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 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | /public/config.js 26 | 27 | /report.*.json 28 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | 3 | ENV PATH /usr/src/app/bin:/usr/src/app/node_modules/.bin:$PATH 4 | ENV NODE_ENV production 5 | 6 | ARG BUILD_REVISION 7 | ENV BUILD_REVISION $BUILD_REVISION 8 | 9 | WORKDIR /usr/src/app 10 | 11 | COPY package*.json ./ 12 | RUN npm ci 13 | 14 | COPY . . 15 | 16 | RUN npm run build 17 | 18 | ENTRYPOINT ["docker-entrypoint"] 19 | 20 | CMD server --port 3000 21 | -------------------------------------------------------------------------------- /frontend/bin/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 4 | 5 | set -e 6 | 7 | # build runtime configuration from environment 8 | generate-config --filename=${DIR}/../build/config.js 9 | 10 | exec "$@" 11 | -------------------------------------------------------------------------------- /frontend/bin/generate-config: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | const argv = require("yargs").argv; 5 | 6 | const filename = argv.filename || "public/config.js"; 7 | 8 | const config = { 9 | googleScopes: 10 | "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/admin.directory.user https://www.googleapis.com/auth/admin.directory.resource.calendar", 11 | googleClientId: process.env.GOOGLE_CLIENT_ID, 12 | googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:8080", 13 | googleDomain: process.env.GOOGLE_DOMAIN || "sentry.io", 14 | googleMapsKey: process.env.GOOGLE_MAPS_KEY || "", 15 | // has to be an absolute domain due to next.js 16 | // https://github.com/zeit/next.js/issues/1213 17 | apiEndpoint: process.env.API_ENDPOINT || "http://localhost:8080/graphql/", 18 | environment: process.env.NODE_ENV || "development", 19 | sentryDsn: process.env.SENTRY_DSN, 20 | version: process.env.BUILD_REVISION || "" 21 | }; 22 | 23 | fs.writeFile(filename, `window.ATLAS_CONFIG = ${JSON.stringify(config)};`, err => { 24 | if (err) { 25 | console.error(err); 26 | process.exit(1); 27 | } 28 | console.log(`Config written to ${filename}!`); 29 | }); 30 | -------------------------------------------------------------------------------- /frontend/bin/server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const express = require("express"); 4 | const path = require("path"); 5 | const argv = require("yargs").argv; 6 | 7 | const port = argv.port || 3000; 8 | 9 | const app = express(); 10 | 11 | app.use(express.static(path.join(__dirname, "..", "build"))); 12 | 13 | app.get("/*", function(req, res) { 14 | res.sendFile(path.join(__dirname, "..", "build", "index.html")); 15 | }); 16 | 17 | console.log(`Listening on port ${port}`); 18 | 19 | app.listen(argv.port || 3000); 20 | -------------------------------------------------------------------------------- /frontend/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: "gcr.io/cloud-builders/docker" 3 | entrypoint: "bash" 4 | args: ["-c", "docker pull us.gcr.io/$PROJECT_ID/$atlas-backend:latest || true"] 5 | - name: "gcr.io/cloud-builders/docker" 6 | args: 7 | [ 8 | "build", 9 | "--cache-from", 10 | "us.gcr.io/$PROJECT_ID/atlas-frontend:latest", 11 | "--build-arg", 12 | "BUILD_REVISION=$COMMIT_SHA", 13 | "-t", 14 | "us.gcr.io/$PROJECT_ID/atlas-frontend:$COMMIT_SHA", 15 | "-t", 16 | "us.gcr.io/$PROJECT_ID/atlas-frontend:latest", 17 | ".", 18 | ] 19 | images: 20 | - "us.gcr.io/$PROJECT_ID/atlas-frontend:$COMMIT_SHA" 21 | - "us.gcr.io/$PROJECT_ID/atlas-frontend:latest" 22 | options: 23 | substitution_option: "ALLOW_LOOSE" 24 | logsBucket: "gs://sentryio-cloudbuild-logs/getsentry/" 25 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ctrl/react-orgchart": "^1.1.1", 7 | "@emotion/core": "^10.0.10", 8 | "@emotion/styled": "^10.0.12", 9 | "@material-ui/core": "^4.5.1", 10 | "@material-ui/icons": "^4.9.1", 11 | "@rebass/grid": "^6.0.0", 12 | "@sentry/apm": "^5.20.1", 13 | "@sentry/browser": "^5.27.4", 14 | "@sentry/integrations": "^5.20.1", 15 | "@sentry/rrweb": "^0.1.2", 16 | "@sentry/tracing": "^5.27.4", 17 | "apollo-boost": "^0.4.7", 18 | "apollo-link": "^1.2.13", 19 | "apollo-upload-client": "^12.1.0", 20 | "babel-plugin-idx": "^2.4.0", 21 | "express": "^4.17.1", 22 | "formik": "^2.0.1-rc.13", 23 | "graphql": "^14.3.1", 24 | "graphql-tag": "^2.10.1", 25 | "idx": "^2.5.6", 26 | "js-cookie": "^2.2.0", 27 | "lodash": "^4.17.19", 28 | "moment": "^2.24.0", 29 | "performant-array-to-tree": "1.7.1", 30 | "react": "^16.8.6", 31 | "react-apollo": "^2.5.8", 32 | "react-avatar": "3.6.0", 33 | "react-card-flip": "^1.0.11", 34 | "react-dom": "^16.8.6", 35 | "react-loadable": "^5.5.0", 36 | "react-markdown": "^4.1.0", 37 | "react-redux": "^5.1.1", 38 | "react-router": "^3.2.1", 39 | "react-scripts": "^3.4.1", 40 | "react-select": "^3.1.0", 41 | "redux": "^3.7.2", 42 | "redux-thunk": "^2.3.0", 43 | "rrweb": "^0.7.33", 44 | "serve": "^11.3.2", 45 | "yup": "^0.27.0" 46 | }, 47 | "scripts": { 48 | "start": "bin/generate-config && PORT=8080 react-scripts start", 49 | "build": "react-scripts build", 50 | "test": "react-scripts test", 51 | "eject": "react-scripts eject" 52 | }, 53 | "proxy": "http://localhost:8000", 54 | "eslintConfig": { 55 | "extends": "react-app", 56 | "globals": { 57 | "AtlasConfig": true 58 | } 59 | }, 60 | "browserslist": { 61 | "production": [ 62 | ">0.2%", 63 | "not dead", 64 | "not op_mini all" 65 | ], 66 | "development": [ 67 | "last 1 chrome version", 68 | "last 1 firefox version", 69 | "last 1 safari version" 70 | ] 71 | }, 72 | "devDependencies": { 73 | "@apollo/react-testing": "^3.1.3", 74 | "@testing-library/jest-dom": "^5.3.0", 75 | "@testing-library/react": "^10.0.2", 76 | "graphql-tools": "^4.0.7", 77 | "prettier": "2.0.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Atlas", 3 | "name": "Atlas", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/actions/index.js: -------------------------------------------------------------------------------- 1 | import auth from "./auth"; 2 | 3 | export default { 4 | ...auth, 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/src/colors.js: -------------------------------------------------------------------------------- 1 | const $white = "#f7f7f8"; 2 | const $gray100 = "#FAFBFE"; 3 | const $gray200 = "#EFF2F7"; 4 | // eslint-disable-next-line 5 | const $gray300 = "#E5E9F2"; 6 | // eslint-disable-next-line 7 | const $gray400 = "#E0E6ED"; 8 | // eslint-disable-next-line 9 | const $gray500 = "#D3DCE6"; 10 | // eslint-disable-next-line 11 | const $gray600 = "#C0CCDA"; 12 | // eslint-disable-next-line 13 | const $gray700 = "#8492A6"; 14 | // eslint-disable-next-line 15 | const $gray800 = "#3C4858"; 16 | // eslint-disable-next-line 17 | const $gray900 = "#273444"; 18 | // eslint-disable-next-line 19 | const $black = "#1F2D3D"; 20 | 21 | const $blue = "#2684FF"; 22 | const $indigo = "#6e00ff"; 23 | const $purple = "#510FA8"; 24 | const $pink = "#f074ad"; 25 | const $red = "#FF5630"; 26 | const $orange = "#FFAB00"; 27 | const $yellow = "#ffcc00"; 28 | const $green = "#36B37E"; 29 | const $teal = "#00B8D9"; 30 | const $cyan = "#4bd6e5"; 31 | 32 | export default { 33 | background: "#3e3947", 34 | text: $white, 35 | 36 | linkText: $gray300, 37 | linkTextHover: $white, 38 | 39 | inputBackground: $white, 40 | inputBackgroundDisabled: "#ced4da", 41 | inputBackgroundSelected: $gray300, 42 | inputBackgroundFocused: $gray300, 43 | inputText: $black, 44 | inputTextSelected: $indigo, 45 | inputTextFocused: $black, 46 | inputBorder: "#ced4da", 47 | 48 | cardBackground: "#4a4455", 49 | cardBackgroundHover: "#585262", 50 | cardText: $white, 51 | 52 | primary: $indigo, 53 | primary100: "#F3EBFF", 54 | primary200: "#E8D6FF", 55 | primary300: "#D1ADFF", 56 | primary400: "#C599FF", 57 | primary500: "#AE70FF", 58 | 59 | black: $black, 60 | white: $white, 61 | 62 | secondary: $gray200, 63 | 64 | gray100: $gray100, 65 | gray200: $gray200, 66 | gray700: $gray700, 67 | gray900: $gray900, 68 | 69 | blue: $blue, 70 | indigo: $indigo, 71 | purple: $purple, 72 | pink: $pink, 73 | red: $red, 74 | orange: $orange, 75 | yellow: $yellow, 76 | green: $green, 77 | teal: $teal, 78 | cyan: $cyan, 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/src/components/Address.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default ({ 4 | office: { location, postalCode, regionCode, locality, administrativeArea }, 5 | }) => { 6 | return ( 7 | 8 |
{location || ""}
9 |
10 | {locality ? `${locality}, ` : ""} 11 | {administrativeArea || ""} 12 | {postalCode ? ` ${postalCode}, ` : ""} 13 | {regionCode} 14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/components/AuthenticatedPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import actions from "../actions"; 6 | import PageLoader from "../components/PageLoader"; 7 | 8 | class AuthenticatedPage extends Component { 9 | constructor(...params) { 10 | super(...params); 11 | this.state = { 12 | loading: true, 13 | }; 14 | } 15 | 16 | static contextTypes = { router: PropTypes.object.isRequired }; 17 | 18 | static getDerivedStateFromProps(props, state) { 19 | return { 20 | loading: !props.authenticated, 21 | }; 22 | } 23 | 24 | componentDidMount() { 25 | const { loadSession } = this.props; 26 | loadSession(); 27 | if (this.props.authenticated === false) { 28 | this.context.router.push({ 29 | pathname: "/login", 30 | query: { next: this.buildUrl() }, 31 | }); 32 | } 33 | } 34 | 35 | componentDidUpdate() { 36 | if (this.props.authenticated === false) { 37 | this.context.router.push({ 38 | pathname: "/login", 39 | query: { next: this.buildUrl() }, 40 | }); 41 | } 42 | } 43 | 44 | buildUrl() { 45 | let { location } = this.context.router; 46 | return `${location.pathname}${location.search || ""}`; 47 | } 48 | 49 | render() { 50 | if (this.state.loading) { 51 | return ; 52 | } 53 | return this.props.children; 54 | } 55 | } 56 | 57 | export default connect( 58 | ({ auth }) => ({ 59 | authenticated: auth.authenticated, 60 | }), 61 | { 62 | loadSession: actions.loadSession, 63 | } 64 | )(AuthenticatedPage); 65 | -------------------------------------------------------------------------------- /frontend/src/components/Avatar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { Avatar as ReactAvatar } from "react-avatar"; 4 | 5 | export default styled(({ className, user, size }) => { 6 | return ( 7 |
8 | {user.photo && !!user.photo.data ? ( 9 | 15 | ) : ( 16 | 17 | )} 18 |
19 | ); 20 | })` 21 | display: block; 22 | margin: 0 auto; 23 | width: ${(props) => props.size}px; 24 | height: ${(props) => props.size}px; 25 | margin-right: ${(props) => 26 | props.mr ? (props.mr !== true ? props.mr : "0.5rem") : "auto"}; 27 | margin-bottom: ${(props) => 28 | props.mb ? (props.mb !== true ? props.mb : "0.5rem") : "0"}; 29 | flex-shrink: 0; 30 | 31 | img, 32 | .sb-avatar, 33 | .sb-avatar > div { 34 | border-radius: 50%; 35 | width: 100%; 36 | height: 100%; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /frontend/src/components/Birthday.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import moment from "moment"; 3 | 4 | export default ({ dobMonth, dobDay }) => { 5 | if (!dobMonth || !dobDay) return null; 6 | const dob = moment(`${new Date().getFullYear()}-${dobMonth}-${dobDay}`, "YYYY-MM-DD"); 7 | return {dob.format("MMMM Do")}; 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/src/components/Button.js: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/core"; 2 | import styled from "@emotion/styled"; 3 | import { Link } from "react-router"; 4 | 5 | import colors from "../colors"; 6 | 7 | export const buttonStyles = (props) => css` 8 | display: inline-block; 9 | font-weight: 400; 10 | text-align: center; 11 | white-space: nowrap; 12 | vertical-align: middle; 13 | padding: 0.375rem 0.75rem; 14 | line-height: 1.5; 15 | border-radius: 0.25rem; 16 | margin-right: 0.5rem; 17 | cursor: pointer; 18 | background: ${colors.white}; 19 | color: ${colors.primary}; 20 | border-color: ${colors.primary}; 21 | transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out, 22 | border-color 0.15s ease-in-out, box-shadow 0.1s ease-in-out; 23 | 24 | svg { 25 | vertical-align: top; 26 | margin-right: 5px; 27 | } 28 | 29 | &:disabled { 30 | background: #ced4da; 31 | } 32 | &:hover { 33 | background: ${colors.primary}; 34 | border-color: ${colors.primary}; 35 | color: ${colors.white}; 36 | } 37 | ${props.priority === "danger" && 38 | ` 39 | color: ${colors.red}; 40 | border-color: ${colors.red}; 41 | 42 | &:hover { 43 | background: ${colors.red}; 44 | border-color: ${colors.red}; 45 | } 46 | } 47 | `} 48 | `; 49 | 50 | export const ButtonLink = styled(Link)` 51 | ${buttonStyles} 52 | `; 53 | 54 | export default styled.button` 55 | ${buttonStyles} 56 | `; 57 | -------------------------------------------------------------------------------- /frontend/src/components/Card.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import styled from "@emotion/styled"; 4 | 5 | import colors from "../colors"; 6 | 7 | export default styled( 8 | ({ 9 | className, 10 | to, 11 | children, 12 | noMargin = false, 13 | withPadding = false, 14 | slim = false, 15 | ...props 16 | }) => { 17 | return ( 18 |
19 | {to ? {children} : children} 20 |
21 | ); 22 | } 23 | )` 24 | background: ${colors.cardBackground}; 25 | color: ${colors.cardText}; 26 | padding: ${(props) => (props.slim ? "0.5rem" : "1rem")} 1rem 0; 27 | margin: 0 0 ${(props) => (props.noMargin ? 0 : "1.5rem")}; 28 | overflow: visible; 29 | border-radius: 4px; 30 | padding-bottom: ${(props) => 31 | props.withPadding ? (props.slim ? "0.5rem" : "1rem") : 0}; 32 | 33 | ${(props) => 34 | props.to && 35 | ` 36 | & > a { 37 | color: inherit; 38 | display: block; 39 | padding: ${props.slim ? "0.5rem 1rem" : "1rem 1rem"} 0; 40 | padding-bottom: ${props.withPadding ? (props.slim ? "0.5rem" : "1rem") : 0}; 41 | margin: ${props.slim ? "-0.5rem -1rem" : "-1rem -1rem"} 0; 42 | margin-bottom: ${props.withPadding ? (props.slim ? "-0.5rem" : "-1rem") : 0}; 43 | 44 | &:hover { 45 | background: ${colors.cardBackgroundHover}; 46 | } 47 | } 48 | `} 49 | ::after { 50 | content: ""; 51 | clear: both; 52 | display: table; 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /frontend/src/components/ChangeHeading.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | 4 | import PersonLink from "./PersonLink"; 5 | 6 | export default ({ change, avatarSize }) => { 7 | switch (change.objectType.toLowerCase()) { 8 | case "user": 9 | const user = change.objectUser; 10 | return ; 11 | case "office": 12 | const office = change.objectOffice; 13 | return {office.name}; 14 | case "department": 15 | const department = change.objectDepartment; 16 | return {department.name}; 17 | default: 18 | return ( 19 | 20 | `${change.objectType} - ${change.objectId}` 21 | 22 | ); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /frontend/src/components/Container.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export default styled.div` 4 | max-width: 65rem; 5 | margin: 0 auto; 6 | padding-left: 1rem; 7 | padding-right: 1rem; 8 | `; 9 | -------------------------------------------------------------------------------- /frontend/src/components/Content.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const ContentContainer = styled.main` 5 | margin: 0 0.75rem 1.5rem; 6 | flex-grow: 1; 7 | `; 8 | 9 | export default (props) => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/components/DaySchedule.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const DAY_SCHEDULE = [ 4 | ["NONE", "(no information)"], 5 | ["INOFFICE", "In Office"], 6 | ["WFH", "Work From Home"], 7 | ["OFF", "Off"], 8 | ]; 9 | 10 | export default ({ daySchedule }) => ( 11 | 12 | {DAY_SCHEDULE.find((p) => p[0] === (daySchedule || "NONE"))[1]} 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /frontend/src/components/DefinitionList.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export default styled.dl` 4 | margin: 0 0 0.5rem; 5 | padding-left: ${(props) => props.prefixWidth || 140}px; 6 | 7 | &::after { 8 | content: ""; 9 | clear: both; 10 | display: table; 11 | } 12 | 13 | dt { 14 | float: left; 15 | clear: left; 16 | margin-left: -${(props) => props.prefixWidth || 140}px; 17 | width: ${(props) => (props.prefixWidth ? props.prefixWidth - 20 : 120)}px; 18 | white-space: nowrap; 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | margin-bottom: 0.5rem; 22 | color: #999; 23 | } 24 | dd { 25 | margin-bottom: 0.5rem; 26 | } 27 | a { 28 | text-decoration: underline; 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import idx from "idx"; 4 | import { isEqual } from "lodash"; 5 | import * as Sentry from "@sentry/browser"; 6 | 7 | import InternalError from "./InternalError"; 8 | import NetworkError from "./NetworkError"; 9 | import NotFoundError from "./NotFoundError"; 10 | import * as errors from "../errors"; 11 | 12 | export default class ErrorBoundary extends Component { 13 | static contextTypes = { 14 | router: PropTypes.object, 15 | }; 16 | 17 | static propTypes = { 18 | children: PropTypes.node, 19 | location: PropTypes.object, 20 | }; 21 | 22 | constructor(...params) { 23 | super(...params); 24 | this.state = { error: null, location: null, eventId: null }; 25 | } 26 | 27 | componentWillReceiveProps(nextProps, nextContext) { 28 | let { router } = nextContext; 29 | if (!isEqual(this.state.location, router.location)) { 30 | this.setState({ error: null, location: null, eventId: null }); 31 | } 32 | super.componentWillReceiveProps && 33 | super.componentWillReceiveProps(nextProps, nextContext); 34 | } 35 | 36 | componentDidCatch(error, errorInfo) { 37 | Sentry.withScope((scope) => { 38 | scope.setExtras(errorInfo); 39 | const eventId = Sentry.captureException(error); 40 | this.setState({ 41 | eventId, 42 | error, 43 | location: { ...(idx(this.context.router, (_) => _.location) || {}) }, 44 | }); 45 | if (eventId) Sentry.showReportDialog(); 46 | }); 47 | } 48 | 49 | render() { 50 | let { error } = this.state; 51 | if (error) { 52 | switch (error.constructor) { 53 | case errors.ResourceNotFound: 54 | return ; 55 | case errors.ApiError: 56 | if (error.code === 401) { 57 | // XXX(dcramer): we can't seem to render here as error boundary doesn't recover 58 | window.location.href = `/login/?next=${encodeURIComponent( 59 | window.location.pathname 60 | )}`; 61 | return null; 62 | } else if (error.code === 502) { 63 | return ; 64 | } 65 | return ; 66 | case errors.NetworkError: 67 | return ; 68 | default: 69 | if (error.networkError) { 70 | return ; 71 | } 72 | return ; 73 | } 74 | } else { 75 | return this.props.children; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/components/ErrorMessage.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | 4 | const ErrorBox = styled.aside` 5 | background: #fff; 6 | padding: 10px 20px; 7 | `; 8 | 9 | export default ({ message }) => {message}; 10 | -------------------------------------------------------------------------------- /frontend/src/components/FlashCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactCardFlip from "react-card-flip"; 3 | import Content from "./Content"; 4 | import Avatar from "./Avatar"; 5 | import Card from "./Card"; 6 | import colors from "../colors"; 7 | 8 | export default function FlashCard(props) { 9 | const { person, isFlipped } = props; 10 | 11 | const style = { 12 | background: colors.cardBackgroundHover, 13 | width: "300px", 14 | height: "300px", 15 | margin: "0 auto", 16 | }; 17 | 18 | return ( 19 | 20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 |
28 | 29 |
30 |
31 |

{person.name}

32 | {person.handle && person.handle !== person.name && ( 33 |

"{person.handle}"

34 | )} 35 |

{person.title}

36 | {person.employeeType && 37 | person.employeeType.name && 38 | person.employeeType.id !== "FULL_TIME" && ( 39 |

({person.employeeType.name})

40 | )} 41 |
42 |
43 |
{person.pronouns}
44 |
45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/components/FormikEffect.js: -------------------------------------------------------------------------------- 1 | // https://github.com/jaredpalmer/formik-effect/issues/4 2 | import PropTypes from "prop-types"; 3 | 4 | import { Component } from "react"; 5 | import { debounce, isEqual } from "lodash"; 6 | import { connect } from "formik"; 7 | 8 | const SAVE_DELAY = 200; 9 | 10 | class FormikEffects extends Component { 11 | static propTypes = { 12 | onChange: PropTypes.func.isRequired, 13 | formik: PropTypes.object, 14 | }; 15 | 16 | onChange = debounce(this.props.onChange, SAVE_DELAY); 17 | 18 | componentDidUpdate(prevProps) { 19 | const { formik } = this.props; 20 | const { isValid } = formik; 21 | 22 | const hasChanged = !isEqual(prevProps.formik.values, formik.values); 23 | const shouldCallback = isValid && hasChanged; 24 | 25 | if (shouldCallback) { 26 | this.onChange(prevProps.formik.values, formik.values); 27 | } 28 | } 29 | 30 | render() { 31 | return null; 32 | } 33 | } 34 | 35 | export default connect(FormikEffects); 36 | -------------------------------------------------------------------------------- /frontend/src/components/IconLink.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import styled from "@emotion/styled"; 4 | 5 | import colors from "../colors"; 6 | 7 | export default styled(({ children, className, icon, onClick, style, to }) => { 8 | if (onClick) { 9 | return ( 10 | 11 | {icon} {children} 12 | 13 | ); 14 | } 15 | return ( 16 | 17 | {icon} {children} 18 | 19 | ); 20 | })` 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | vertical-align: middle; 25 | 26 | color: ${(props) => props.color || "hsla(0, 0%, 100%, 0.7)"}; 27 | transition: color 0.3s; 28 | 29 | &:hover { 30 | color: ${(props) => props.colorHover || props.color || colors.white}; 31 | } 32 | 33 | .MuiSvgIcon-root { 34 | font-size: 1.2em !important; 35 | ${(props) => props.children && "margin-right: 0.25em"}; 36 | } 37 | `; 38 | -------------------------------------------------------------------------------- /frontend/src/components/InternalError.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import config from "../config"; 5 | import Modal from "./Modal"; 6 | 7 | export default class InternalError extends Component { 8 | static propTypes = { 9 | error: PropTypes.object.isRequired, 10 | }; 11 | 12 | render() { 13 | let { error } = this.props; 14 | return ( 15 | 16 |

We hit an unexpected error while loading the page.

17 |

The following may provide you some recourse:

18 | 36 |
43 |

{"The exception reported was:"}

44 |
{error.stack}
45 |
46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/Map.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import config from "../config"; 5 | 6 | export default class Map extends Component { 7 | static propTypes = { 8 | width: PropTypes.oneOf([PropTypes.number, PropTypes.string]), 9 | height: PropTypes.oneOf([PropTypes.number, PropTypes.string]), 10 | }; 11 | 12 | static defaultProps = { 13 | width: 500, 14 | height: 500, 15 | }; 16 | 17 | constructor(props) { 18 | super(props); 19 | this.mapRef = React.createRef(); 20 | } 21 | 22 | onScriptLoad = () => { 23 | const map = new window.google.maps.Map(this.mapRef.current, this.props.options); 24 | this.props.onMapLoad(map); 25 | }; 26 | 27 | componentDidMount() { 28 | if (!window.google) { 29 | var s = document.createElement("script"); 30 | s.type = "text/javascript"; 31 | s.src = `https://maps.google.com/maps/api/js?key=${config.googleMapsKey}`; 32 | var x = document.getElementsByTagName("script")[0]; 33 | x.parentNode.insertBefore(s, x); 34 | // Below is important. 35 | //We cannot access google.maps until it's finished loading 36 | s.addEventListener("load", () => { 37 | this.onScriptLoad(); 38 | }); 39 | } else { 40 | this.onScriptLoad(); 41 | } 42 | } 43 | 44 | render() { 45 | return ( 46 |
51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import colors from "../colors"; 4 | 5 | export default class Modal extends Component { 6 | static propTypes = { 7 | children: PropTypes.node, 8 | title: PropTypes.string.isRequired, 9 | subtext: PropTypes.string, 10 | maxWidth: PropTypes.number, 11 | }; 12 | 13 | static defaultProps = { 14 | maxWidth: 800, 15 | }; 16 | 17 | render() { 18 | return ( 19 |
25 |
26 | Logo 27 |
28 |
38 | {this.props.subtext ? ( 39 |

{this.props.subtext}

40 | ) : null} 41 |

{this.props.title}

42 | {this.props.children} 43 |
44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/NetworkError.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import Modal from "./Modal"; 5 | 6 | export default class NetworkError extends Component { 7 | static propTypes = { 8 | error: PropTypes.object.isRequired, 9 | url: PropTypes.string, 10 | }; 11 | 12 | getHost(url) { 13 | let l = document.createElement("a"); 14 | l.href = url; 15 | return l.hostname; 16 | } 17 | 18 | render() { 19 | let { error, url } = this.props; 20 | if (!url) url = error.url; 21 | return ( 22 | 23 |

24 | There was a problem communicating with {this.getHost(url)}. 25 |

26 |

The following may provide you some recourse:

27 | 46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/components/NotFoundError.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Modal from "./Modal"; 4 | 5 | export default class NotFoundError extends Component { 6 | render() { 7 | return ( 8 | 9 |

10 | The resource you were trying to access was not found, or you do not have 11 | permission to view it. 12 |

13 |

The following may provide you some recourse:

14 | 36 |
37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/OfficeList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import { Query } from "react-apollo"; 4 | import styled from "@emotion/styled"; 5 | import { Settings } from "@material-ui/icons"; 6 | 7 | import Address from "../components/Address"; 8 | import colors from "../colors"; 9 | import IconLink from "../components/IconLink"; 10 | import PageLoader from "./PageLoader"; 11 | import SuperuserOnly from "../components/SuperuserOnly"; 12 | import { LIST_OFFICES_QUERY } from "../queries"; 13 | 14 | const OfficeListContainer = styled.section` 15 | li { 16 | display: block; 17 | margin-bottom: 1rem; 18 | } 19 | ul { 20 | margin: 1rem 0; 21 | padding: 0; 22 | } 23 | `; 24 | 25 | export default function OfficeList() { 26 | return ( 27 | 28 | {({ loading, error, data }) => { 29 | if (error) throw error; 30 | if (loading) return ; 31 | const { offices } = data; 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | {offices.map((o) => ( 46 | 47 | 55 | 56 | 57 | 65 | 66 | 67 | ))} 68 | 69 |
NamePeople 41 | 42 |
48 |
49 | {o.name} 50 |
51 | 52 |
53 | 54 |
{o.numPeople.toLocaleString()} 58 | } 60 | to={`/offices/${o.externalId}/update`} 61 | color={colors.linkText} 62 | style={{ fontSize: "0.9em" }} 63 | /> 64 |
70 |
71 | ); 72 | }} 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/components/OfficeMap.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Map from "./Map"; 4 | 5 | export default class OfficeMap extends Component { 6 | render() { 7 | const { office } = this.props; 8 | if (!office || !office.lat || !office.lng) return null; 9 | return ( 10 | { 17 | new window.google.maps.Marker({ 18 | map: map, 19 | position: { lat: +office.lat, lng: +office.lng }, 20 | title: office.name, 21 | }); 22 | }} 23 | {...this.props} 24 | /> 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/OrgChart.css: -------------------------------------------------------------------------------- 1 | .reactOrgChart-container { 2 | position: absolute; 3 | left: 0; 4 | right: 0; 5 | bottom: 0; 6 | top: 32px; 7 | zoom: 0.9; 8 | margin-top: 3rem; 9 | background-color: #fff; 10 | border-top: 1px solid #ddd; 11 | display: flex; 12 | flex: 1; 13 | background-size: 10px 10px; 14 | background-image: linear-gradient(to right, rgba(1, 1, 1, 0.03) 1px, transparent 1px), 15 | linear-gradient(to bottom, rgba(1, 1, 1, 0.03) 1px, transparent 1px); 16 | } 17 | .reactOrgChart { 18 | margin: 2px; 19 | padding: 2rem; 20 | display: block; 21 | overflow: hidden; 22 | cursor: all-scroll; 23 | flex-grow: 1; 24 | } 25 | 26 | .reactOrgChart .orgNodeChildGroup .node { 27 | border: solid 1px #000000; 28 | display: inline-block; 29 | padding: 4px; 30 | width: 100px; 31 | } 32 | 33 | .reactOrgChart .orgNodeChildGroup .nodeCell { 34 | text-align: center; 35 | } 36 | 37 | .reactOrgChart .orgNodeChildGroup .nodeCell .nodeItem { 38 | border: solid 1px #aaa; 39 | margin: 0 3px; 40 | border-radius: 3px; 41 | padding: 5px; 42 | width: 200px; 43 | display: inline-block; 44 | background: #fff; 45 | } 46 | .reactOrgChart .orgNodeChildGroup .nodeCell .nodeItem img { 47 | display: inline-block; 48 | width: 32px; 49 | } 50 | 51 | .reactOrgChart .orgNodeChildGroup .nodeGroupCell { 52 | vertical-align: top; 53 | } 54 | 55 | .reactOrgChart .orgNodeChildGroup .nodeGroupLineVerticalMiddle { 56 | height: 25px; 57 | width: 50%; 58 | border-right: solid 1px #aaa; 59 | } 60 | 61 | .reactOrgChart .nodeLineBorderTop { 62 | border-top: solid 1px #aaa; 63 | } 64 | 65 | .reactOrgChart table { 66 | border-collapse: collapse; 67 | border: none; 68 | margin: 0 auto; 69 | } 70 | 71 | .reactOrgChart td { 72 | padding: 0; 73 | } 74 | 75 | .reactOrgChart table.nodeLineTable { 76 | width: 100%; 77 | } 78 | 79 | .reactOrgChart table td.nodeCell { 80 | width: 50%; 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/components/PageLoader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Flex, Box } from "@rebass/grid/emotion"; 3 | import PropTypes from "prop-types"; 4 | 5 | import InternalError from "./InternalError"; 6 | 7 | export default class PageLoader extends Component { 8 | static propTypes = { 9 | isLoading: PropTypes.bool, 10 | error: PropTypes.object, 11 | loadingText: PropTypes.string, 12 | }; 13 | 14 | static defaultProps = { 15 | isLoading: true, 16 | }; 17 | 18 | render() { 19 | let { isLoading, error } = this.props; 20 | if (isLoading) { 21 | return ( 22 | 23 | {this.props.loadingText && ( 24 | 25 | {this.props.loadingText} 26 | 27 | )} 28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | ); 41 | } else if (error) { 42 | return ; 43 | } else { 44 | return null; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/components/PeopleList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import moment from "moment"; 3 | 4 | import Birthday from "./Birthday"; 5 | import PersonLink from "./PersonLink"; 6 | 7 | import { getColumnTitle } from "../utils/strings"; 8 | 9 | export const validColumns = new Set([ 10 | "anniversary", 11 | "birthday", 12 | "department", 13 | "team", 14 | "email", 15 | "reportsTo", 16 | "office", 17 | "dateStarted", 18 | "social.github", 19 | "social.linkedin", 20 | "social.twitter", 21 | "gamerTags.nintendo", 22 | "gamerTags.playstation", 23 | "gamerTags.steam", 24 | "gamerTags.xbox", 25 | ]); 26 | 27 | export const defaultColumns = ["department", "team", "office", "dateStarted"]; 28 | 29 | const getColumnValue = function (user, column) { 30 | switch (column) { 31 | case "department": 32 | return user.department && user.department.name; 33 | case "team": 34 | return user.team && user.team.name; 35 | case "office": 36 | return user.office && user.office.name; 37 | case "birthday": 38 | return ; 39 | case "anniversary": 40 | return moment(user.dateStarted, "YYYY-MM-DD").format("MMMM Do"); 41 | default: 42 | let value = user; 43 | column.split(".").forEach((c) => { 44 | if (!value) return; 45 | value = value[c]; 46 | }); 47 | return value ? "" + value : null; 48 | } 49 | }; 50 | 51 | export default function PeopleList({ users, columns = defaultColumns }) { 52 | let usedColumn = columns.filter((c) => validColumns.has(c)); 53 | 54 | return ( 55 | 56 | 57 | 58 | 59 | {usedColumn.map((c) => ( 60 | 61 | ))} 62 | 63 | 64 | 65 | {users.map((u) => ( 66 | 67 | 70 | {usedColumn.map((c) => ( 71 | 74 | ))} 75 | 76 | ))} 77 | 78 |
Name{getColumnTitle(c)}
68 | 69 | 72 | {getColumnValue(u, c)} 73 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/components/PeopleViewSelectors.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@rebass/grid/emotion"; 3 | import IconLink from "./IconLink"; 4 | import { AccountTree, RecentActors, TableChart, SportsEsports } from "@material-ui/icons"; 5 | 6 | export default function PeopleViewSelectors(props) { 7 | const boxes = []; 8 | 9 | if (props.current !== "people") { 10 | boxes.push({ 11 | icon: , 12 | link: "/people", 13 | }); 14 | } 15 | 16 | if (props.current !== "orgChart") { 17 | boxes.push({ 18 | icon: , 19 | link: "/orgChart", 20 | }); 21 | } 22 | 23 | if (props.current !== "flashcards") { 24 | boxes.push({ 25 | icon: , 26 | link: "/flashcards", 27 | }); 28 | } 29 | 30 | if (props.current !== "quiz") { 31 | boxes.push({ 32 | icon: , 33 | link: "/quiz", 34 | }); 35 | } 36 | 37 | return boxes.map(({ icon, link }) => { 38 | return ( 39 | 40 | 45 | 46 | ); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/Person.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { screen } from "@testing-library/react"; 3 | import "@testing-library/jest-dom/extend-expect"; 4 | 5 | import Person from "./Person"; 6 | import { RouterContext, mocks, mockRouter, render, tick } from "../utils/testing"; 7 | import { GET_PERSON_QUERY } from "../queries"; 8 | 9 | import apolloClient from "../utils/apollo"; 10 | 11 | test("can render valid profile as non-superuser", async () => { 12 | apolloClient.addMockedResponse({ 13 | request: { 14 | query: GET_PERSON_QUERY, 15 | variables: { 16 | email: "jane@example.com", 17 | humansOnly: false, 18 | includeHidden: false, 19 | }, 20 | }, 21 | result: { 22 | data: { 23 | users: { 24 | results: [ 25 | mocks.User({ 26 | email: "jane@example.com", 27 | name: "Jane Doe", 28 | isSuperuser: false, 29 | }), 30 | ], 31 | }, 32 | }, 33 | }, 34 | }); 35 | 36 | const router = mockRouter(); 37 | 38 | const currentUser = { 39 | id: "a-uuid", 40 | email: "jane@example.com", 41 | name: "Jane Doe", 42 | isSuperuser: false, 43 | hasOnboarded: true, 44 | photo: null, 45 | }; 46 | 47 | render( 48 | 49 | 50 | , 51 | { 52 | initialState: { 53 | auth: { 54 | user: currentUser, 55 | }, 56 | }, 57 | } 58 | ); 59 | 60 | await tick(); 61 | await tick(); 62 | 63 | expect(screen.getByTestId("name")).toHaveTextContent("Jane Doe"); 64 | }); 65 | -------------------------------------------------------------------------------- /frontend/src/components/PersonCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "@emotion/styled"; 3 | import { css } from "@emotion/core"; 4 | 5 | import Avatar from "./Avatar"; 6 | import Card from "./Card"; 7 | 8 | const PersonCardContainer = styled(Card)( 9 | (props) => css` 10 | ${props.horizontal && 11 | ` 12 | display: inline-block; 13 | `} 14 | 15 | a { 16 | overflow: hidden; 17 | ${props.horizontal 18 | ? ` 19 | display: flex; 20 | align-items: center; 21 | ` 22 | : ` 23 | text-align: center; 24 | `} 25 | } 26 | 27 | img { 28 | display: block; 29 | max-width: 100%; 30 | max-height: 100%; 31 | } 32 | 33 | aside { 34 | overflow: hidden; 35 | ${props.horizontal && 36 | ` 37 | flex-grow: 1; 38 | display: inline-block; 39 | `} 40 | h4 { 41 | margin-bottom: 0; 42 | margin-top: 0; 43 | white-space: nowrap; 44 | overflow: hidden; 45 | text-overflow: ellipsis; 46 | } 47 | 48 | small { 49 | height: 1.2em; 50 | display: block; 51 | white-space: nowrap; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | } 55 | } 56 | ` 57 | ); 58 | 59 | export default function ({ user, ...props }) { 60 | if (!user) return n/a; 61 | let avatarProps = props.horizontal ? { mr: "5px" } : { mb: true }; 62 | return ( 63 | 64 | 65 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /frontend/src/components/PersonLink.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import styled from "@emotion/styled"; 4 | 5 | import Avatar from "./Avatar"; 6 | 7 | const PersonLinkContainer = styled.article` 8 | display: flex; 9 | align-items: center; 10 | overflow: hidden; 11 | 12 | h4 { 13 | margin-bottom: 0; 14 | margin-top: 0; 15 | white-space: nowrap; 16 | overflow: hidden; 17 | text-overflow: ellipsis; 18 | } 19 | img { 20 | display: block; 21 | max-width: 100%; 22 | max-height: 100%; 23 | } 24 | aside { 25 | flex-grow: 1; 26 | display: inline-block; 27 | overflow: hidden; 28 | } 29 | small { 30 | display: block; 31 | white-space: nowrap; 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | } 35 | `; 36 | 37 | export default function ({ className, user, avatarSize = 32, noLink = false }) { 38 | if (!user) return n/a; 39 | return ( 40 | 41 | 42 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/Pronouns.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const PRONOUNS = [ 4 | ["NONE", "(no pronouns)"], 5 | ["HE_HIM", "he / him"], 6 | ["SHE_HER", "she / her"], 7 | ["THEY_THEM", "they / them"], 8 | ["OTHER", "other"], 9 | ["DECLINE", "decline to choose"], 10 | ]; 11 | 12 | export default ({ pronouns }) => ( 13 | {PRONOUNS.find((p) => p[0] === pronouns)[1]} 14 | ); 15 | -------------------------------------------------------------------------------- /frontend/src/components/SuperuserOnly.js: -------------------------------------------------------------------------------- 1 | import { connect } from "react-redux"; 2 | 3 | function SuperuserOnly({ children, user }) { 4 | if (!user || !user.isSuperuser) return null; 5 | return children; 6 | } 7 | 8 | export default connect(({ auth }) => ({ 9 | user: auth.user, 10 | }))(SuperuserOnly); 11 | -------------------------------------------------------------------------------- /frontend/src/components/TeamSelectField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import gql from "graphql-tag"; 4 | 5 | import apolloClient from "../utils/apollo"; 6 | import FieldWrapper from "./FieldWrapper"; 7 | 8 | export const LIST_TEAMS_QUERY = gql` 9 | query listTeams($query: String) { 10 | teams(query: $query, limit: 10) { 11 | name 12 | } 13 | } 14 | `; 15 | 16 | export default class TeamSelectField extends Component { 17 | static propTypes = { 18 | readonly: PropTypes.bool, 19 | name: PropTypes.string, 20 | label: PropTypes.string, 21 | exclude: PropTypes.string, 22 | }; 23 | 24 | static defaultProps = { 25 | name: "team", 26 | label: "Team", 27 | }; 28 | 29 | loadMatches = (inputValue, callback) => { 30 | apolloClient 31 | .query({ 32 | query: LIST_TEAMS_QUERY, 33 | variables: { 34 | query: inputValue, 35 | }, 36 | }) 37 | .then(({ data: { teams } }) => { 38 | callback( 39 | teams 40 | .filter((u) => !this.props.exclude || this.props.exclude !== u.id) 41 | .map((team) => ({ 42 | label: team.name, 43 | value: team.name, 44 | })) 45 | ); 46 | }); 47 | }; 48 | 49 | render() { 50 | return ( 51 | 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/config.js: -------------------------------------------------------------------------------- 1 | export const requiredKeys = [ 2 | "googleScopes", 3 | "googleClientId", 4 | "googleRedirectUri", 5 | "googleDomain", 6 | "googleMapsKey", 7 | "apiEndpoint", 8 | ]; 9 | 10 | function defaultKeys() { 11 | let out = {}; 12 | requiredKeys.forEach((k) => (out[k] = "")); 13 | return out; 14 | } 15 | 16 | export default { 17 | repoUrl: "http://github.com/getsentry/atlas/issues", 18 | ...defaultKeys(), 19 | ...(process.env.ATLAS_CONFIG || window.ATLAS_CONFIG || {}), 20 | }; 21 | -------------------------------------------------------------------------------- /frontend/src/errors.js: -------------------------------------------------------------------------------- 1 | class ApiError extends Error { 2 | constructor(msg, code) { 3 | super(msg); 4 | this.code = code; 5 | } 6 | } 7 | 8 | class ResourceNotFound extends Error { 9 | static code = 404; 10 | } 11 | 12 | class NetworkError extends Error { 13 | constructor(msg, code) { 14 | super(msg); 15 | this.code = code; 16 | } 17 | } 18 | 19 | export { ApiError, ResourceNotFound, NetworkError }; 20 | -------------------------------------------------------------------------------- /frontend/src/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Rubik-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Rubik-Bold.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Rubik-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Rubik-BoldItalic.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Rubik-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Rubik-Medium.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Rubik-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Rubik-MediumItalic.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/Rubik-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/atlas/1d0828923c00d1668df259d5b12c9d04e558f437/frontend/src/fonts/Rubik-Regular.ttf -------------------------------------------------------------------------------- /frontend/src/images/avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/images/google-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Router, browserHistory } from "react-router"; 3 | import { render } from "react-dom"; 4 | import { Provider } from "react-redux"; 5 | import * as Sentry from "@sentry/browser"; 6 | import { Integrations as TracingIntegrations } from "@sentry/tracing"; 7 | import SentryRRWeb from "@sentry/rrweb"; 8 | import { ApolloProvider } from "react-apollo"; 9 | 10 | import routes from "./routes"; 11 | import store from "./store"; 12 | import * as serviceWorker from "./serviceWorker"; 13 | import config from "./config"; 14 | import apolloClient from "./utils/apollo"; 15 | 16 | Sentry.init({ 17 | dsn: config.sentryDsn, 18 | environment: config.environment, 19 | release: config.version, 20 | tracesSampler: (samplingContext) => { 21 | const name = samplingContext.transactionContext.name; 22 | if (name.indexOf("/healthz") === 0) { 23 | return 0; 24 | } 25 | return 1.0; 26 | }, 27 | integrations: [ 28 | new TracingIntegrations.BrowserTracing({ 29 | tracingOrigins: ["localhost", "atlas.getsentry.net", /^\//], 30 | }), 31 | new SentryRRWeb(), 32 | ], 33 | }); 34 | Sentry.setTag("role", "frontend"); 35 | 36 | // If you want your app to work offline and load faster, you can change 37 | // unregister() to register() below. Note this comes with some pitfalls. 38 | // Learn more about service workers: https://bit.ly/CRA-PWA 39 | serviceWorker.unregister(); 40 | 41 | render( 42 | 43 | 44 | 45 | 46 | , 47 | document.getElementById("root") 48 | ); 49 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminChanges.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import { Query } from "react-apollo"; 4 | import moment from "moment"; 5 | 6 | import Card from "../components/Card"; 7 | import PageLoader from "../components/PageLoader"; 8 | import ChangeHeading from "../components/ChangeHeading"; 9 | import PersonLink from "../components/PersonLink"; 10 | import { LIST_CHANGES_QUERY } from "../queries"; 11 | 12 | export default () => ( 13 |
14 | 15 |

Changes

16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 34 | {({ loading, error, data }) => { 35 | if (error) throw error; 36 | if (loading) return ; 37 | const { changes } = data; 38 | return changes.map((c) => ( 39 | 40 | 43 | 46 | 49 | 52 | 53 | )); 54 | }} 55 | 56 | 57 |
EntityChangesAuthorTimestamp
41 | 42 | 44 | v{c.version} 45 | 47 | {c.user ? : n/a} 48 | 50 | {moment(c.timestamp).format("lll")} 51 |
58 |
59 |
60 | ); 61 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminDepartments.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import { Flex, Box } from "@rebass/grid/emotion"; 4 | import { Query } from "react-apollo"; 5 | 6 | import Card from "../components/Card"; 7 | import PageLoader from "../components/PageLoader"; 8 | import { LIST_DEPARTMENTS_QUERY } from "../queries"; 9 | 10 | export default () => ( 11 |
12 | 13 |

Departments

14 |
15 | 16 | 22 | {({ loading, error, data }) => { 23 | if (error) throw error; 24 | if (loading) return ; 25 | const { departments } = data; 26 | return departments.map((d) => ( 27 |
28 | 29 | 30 | 31 | {!!d.costCenter && `${d.costCenter}-`} 32 | {d.name} 33 | 34 | 35 | 36 | {d.numPeople > 0 && d.numPeople.toLocaleString()} 37 | 38 | 39 |
40 | )); 41 | }} 42 |
43 |
44 |
45 | ); 46 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminLayout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import { Flex, Box } from "@rebass/grid/emotion"; 4 | import styled from "@emotion/styled"; 5 | 6 | import colors from "../colors"; 7 | import Card from "../components/Card"; 8 | import Content from "../components/Content"; 9 | import Layout from "../components/Layout"; 10 | 11 | const activeClassName = "nav-item-active"; 12 | 13 | const NavigationLink = styled(Link)` 14 | margin-left: -0.25rem; 15 | margin-right: -0.25rem; 16 | padding: 0.25rem; 17 | border-radius: 0.25rem; 18 | display: block; 19 | 20 | &:hover { 21 | color: ${colors.white}; 22 | } 23 | 24 | &.${activeClassName} { 25 | color: ${colors.white}; 26 | background: ${colors.black}; 27 | } 28 | `; 29 | 30 | NavigationLink.defaultProps = { activeClassName }; 31 | 32 | export default ({ children }) => ( 33 | 34 | 35 | 36 | 37 | 38 |

People

39 | Audit Profiles 40 | 41 | Import/Export 42 | 43 |
44 | 45 |

Departments

46 | All Departments 47 | 48 | Create Department 49 | 50 |
51 | 52 |

Teams

53 | All Teams 54 |
55 | 56 |

Changes

57 | All Changes 58 |
59 |
60 | 61 | {children} 62 | 63 |
64 |
65 |
66 | ); 67 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminTeams.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | import { Flex, Box } from "@rebass/grid/emotion"; 4 | import { Query } from "react-apollo"; 5 | 6 | import Card from "../components/Card"; 7 | import PageLoader from "../components/PageLoader"; 8 | import { LIST_TEAMS_QUERY } from "../queries"; 9 | 10 | export default () => ( 11 |
12 | 13 |

Teams

14 |
15 | 16 | 22 | {({ loading, error, data }) => { 23 | if (error) throw error; 24 | if (loading) return ; 25 | const { teams } = data; 26 | return teams.map((d) => ( 27 |
28 | 29 | 30 | {d.name} 31 | 32 | 33 | {d.numPeople > 0 && d.numPeople.toLocaleString()} 34 | 35 | 36 |
37 | )); 38 | }} 39 |
40 |
41 |
42 | ); 43 | -------------------------------------------------------------------------------- /frontend/src/pages/AdminUpdateTeam.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Query } from "react-apollo"; 4 | import { ErrorMessage } from "formik"; 5 | 6 | import { ButtonLink } from "../components/Button"; 7 | import Card from "../components/Card"; 8 | import DefinitionList from "../components/DefinitionList"; 9 | import { LIST_TEAMS_QUERY } from "../queries"; 10 | 11 | export default class extends Component { 12 | static contextTypes = { router: PropTypes.object.isRequired }; 13 | 14 | render() { 15 | return ( 16 | 17 | {({ loading, data: { teams } }) => { 18 | //if (error) return ; 19 | if (loading) return
Loading
; 20 | if (!teams.length) 21 | return ; 22 | const team = teams[0]; 23 | return ( 24 | 25 | 26 | 27 |
ID
28 |
{team.id}
29 | 30 |
Name
31 |
{team.name}
32 |
33 |
34 | 35 | 36 | 37 | Delete 38 | 39 | 40 |
41 | ); 42 | }} 43 |
44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/pages/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/pages/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import config, { requiredKeys } from "../config"; 4 | 5 | import Card from "../components/Card"; 6 | import ErrorBoundary from "../components/ErrorBoundary"; 7 | 8 | function MissingConfiguration({ keys }) { 9 | return ( 10 | 11 |

Missing Configuration

12 |

You are missing configuration for the following required parameters:

13 |
    14 | {keys.map((k) => ( 15 |
  • {k}
  • 16 | ))} 17 |
18 |
19 | ); 20 | } 21 | 22 | export default class App extends Component { 23 | render() { 24 | const missingConfig = requiredKeys.filter((c) => !config[c]); 25 | if (missingConfig.length) { 26 | return ; 27 | } 28 | 29 | return {this.props.children}; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/pages/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/pages/HealthCheck.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | export default class HealthCheck extends Component { 4 | render() { 5 | return
OK!
; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/pages/HealthCheck.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import HealthCheck from "./HealthCheck"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { fireEvent, screen } from "@testing-library/react"; 3 | 4 | import Login from "./Login"; 5 | import { RouterContext, mockRouter, render, tick } from "../utils/testing"; 6 | import { LOGIN_MUTATION } from "../actions/auth"; 7 | 8 | import apolloClient from "../utils/apollo"; 9 | 10 | test("can render and log-in successfully", async () => { 11 | apolloClient.addMockedResponse({ 12 | request: { 13 | query: LOGIN_MUTATION, 14 | variables: { googleAuthCode: "abcdef" }, 15 | }, 16 | result: { 17 | data: { 18 | login: { 19 | ok: true, 20 | errors: [], 21 | token: "gauth-token", 22 | user: { 23 | id: "a-uuid", 24 | email: "jane@example.com", 25 | name: "jane", 26 | isSuperuser: false, 27 | hasOnboarded: true, 28 | photo: null, 29 | }, 30 | }, 31 | }, 32 | }, 33 | }); 34 | 35 | const router = mockRouter(); 36 | 37 | const { store } = render( 38 | 39 | 40 | 41 | ); 42 | 43 | fireEvent.click(screen.getByText("Sign in with Google")); 44 | // TODO(dcramer): why are we having to tick multiple times? 45 | await tick(); 46 | await tick(); 47 | await tick(); 48 | const state = store.getState(); 49 | expect(state.auth.authenticated).toBe(true); 50 | expect(router.push.mock.calls.length).toBe(1); 51 | expect(router.push.mock.calls[0][0]).toStrictEqual({ pathname: "/" }); 52 | }); 53 | -------------------------------------------------------------------------------- /frontend/src/pages/Offices.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Card from "../components/Card"; 4 | import Content from "../components/Content"; 5 | import Layout from "../components/Layout"; 6 | import OfficeList from "../components/OfficeList"; 7 | 8 | export default () => ( 9 | 10 | 11 | 12 |

Offices

13 | 14 |
15 |
16 |
17 | ); 18 | -------------------------------------------------------------------------------- /frontend/src/pages/Onboarding.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import Card from "../components/Card"; 5 | import Content from "../components/Content"; 6 | import Layout from "../components/Layout"; 7 | import UpdatePersonForm from "../components/UpdatePersonForm"; 8 | 9 | const Onboarding = ({ user }) => ( 10 | 11 | 12 | 13 |

Welcome to Atlas

14 |

Please help us fill in some details about yourself.

15 |

16 | 17 | Note: All fields are optional, so only share what you're comfortable with. 18 | 19 |

20 |
21 | {!!user && } 22 |
23 |
24 | ); 25 | 26 | export default connect(({ auth }) => ({ 27 | user: auth.user, 28 | }))(Onboarding); 29 | -------------------------------------------------------------------------------- /frontend/src/pages/OrgChart.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Query } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | import styled from "@emotion/styled"; 5 | import { Link } from "react-router"; 6 | 7 | import OrgChart from "../components/OrgChart"; 8 | import Layout from "../components/Layout"; 9 | import PageLoader from "../components/PageLoader"; 10 | import PersonCard from "../components/PersonCard"; 11 | 12 | export const LIST_ALL_PEOPLE_QUERY = gql` 13 | query listAllPeople { 14 | users(offset: 0, limit: 1000, titlesOnly: true, humansOnly: true) { 15 | results { 16 | id 17 | name 18 | email 19 | title 20 | photo { 21 | data 22 | width 23 | height 24 | mimeType 25 | } 26 | reportsTo { 27 | id 28 | } 29 | } 30 | } 31 | } 32 | `; 33 | 34 | const listToTree = (list) => { 35 | var map = {}, 36 | node, 37 | roots = [], 38 | i; 39 | for (i = 0; i < list.length; i += 1) { 40 | map[list[i].id] = i; // initialize the map 41 | list[i].children = []; // initialize the children 42 | } 43 | for (i = 0; i < list.length; i += 1) { 44 | node = list[i]; 45 | if (node.reportsTo) { 46 | // if you have dangling branches check that map[node.parentId] exists 47 | try { 48 | list[map[node.reportsTo.id]].children.push(node); 49 | } catch (err) { 50 | console.error("failed to insert node into tree (missing parent)", node); 51 | } 52 | } else { 53 | roots.push(node); 54 | } 55 | } 56 | return roots.filter((n) => n.children.length); 57 | }; 58 | 59 | const Node = ({ node }) => { 60 | return ; 61 | }; 62 | 63 | export default () => ( 64 | 65 | 66 | {({ loading, error, data }) => { 67 | if (error) throw error; 68 | if (loading) return ; 69 | const { users } = data; 70 | const tree = listToTree(users.results); 71 | return ( 72 | <> 73 | 74 | Interactive Org Chart 75 | 76 | 77 | 78 | ); 79 | }} 80 | 81 | 82 | ); 83 | 84 | const NavContainer = styled.div` 85 | /* Line nav up with the first org chart element */ 86 | margin: 20px 40px; 87 | `; 88 | -------------------------------------------------------------------------------- /frontend/src/pages/OrgChartInteractive.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Query } from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | import { OrgChart } from "@ctrl/react-orgchart"; 5 | import { arrayToTree } from "performant-array-to-tree"; 6 | import styled from "@emotion/styled"; 7 | 8 | import colors from "../colors"; 9 | import Layout from "../components/Layout"; 10 | import PageLoader from "../components/PageLoader"; 11 | import avatarPersonnel from "../images/avatar.svg"; 12 | 13 | export const LIST_ALL_PEOPLE_QUERY = gql` 14 | query listAllPeople { 15 | users(offset: 0, limit: 1000, titlesOnly: true, humansOnly: true) { 16 | results { 17 | id 18 | name 19 | email 20 | title 21 | photo { 22 | data 23 | width 24 | height 25 | mimeType 26 | } 27 | reportsTo { 28 | id 29 | } 30 | } 31 | } 32 | } 33 | `; 34 | 35 | export default () => ( 36 | 37 | 38 | {({ loading, error, data }) => { 39 | if (error) throw error; 40 | if (loading) return ; 41 | const { users } = data; 42 | const formattedUsers = users.results.map((x) => { 43 | const avatar = 44 | (x.photo && x.photo.data && `data:image/jpeg;base64,${x.photo.data}`) || 45 | avatarPersonnel; 46 | 47 | return { 48 | id: x.id, 49 | entity: { 50 | ...x, 51 | avatar, 52 | }, 53 | parentId: x.reportsTo && x.reportsTo.id, 54 | }; 55 | }); 56 | let trees = arrayToTree(formattedUsers, { dataField: null }); 57 | 58 | if (trees.length > 1) { 59 | trees = trees.filter((tree) => tree.children.length > 0); 60 | } 61 | 62 | return ( 63 | 64 | 65 | 66 | ); 67 | }} 68 | 69 | 70 | ); 71 | 72 | const Container = styled.div` 73 | height: calc(100vh - 200px); 74 | width: 100vw; 75 | `; 76 | -------------------------------------------------------------------------------- /frontend/src/pages/Profile.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Layout from "../components/Layout"; 4 | import Person from "../components/Person"; 5 | 6 | export default class extends Component { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/pages/UpdateOffice.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Content from "../components/Content"; 4 | import Layout from "../components/Layout"; 5 | import UpdateOfficeForm from "../components/UpdateOfficeForm"; 6 | 7 | export default class extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/pages/UpdateProfile.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Content from "../components/Content"; 4 | import Layout from "../components/Layout"; 5 | import UpdatePersonForm from "../components/UpdatePersonForm"; 6 | 7 | export default class extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import { LOAD_GAPI, LOGIN, LOGOUT } from "../types"; 2 | 3 | import * as Sentry from "@sentry/browser"; 4 | 5 | const initialState = { 6 | authenticated: null, 7 | user: null, 8 | token: null, 9 | googleAuthInstance: null, 10 | }; 11 | 12 | export default (state = initialState, action) => { 13 | switch (action.type) { 14 | case LOAD_GAPI: 15 | return { 16 | ...state, 17 | googleAuthInstance: action.payload, 18 | }; 19 | case LOGIN: 20 | Sentry.setUser({ 21 | id: action.payload.user.id, 22 | email: action.payload.user.email, 23 | }); 24 | 25 | return { 26 | ...state, 27 | token: action.payload.token, 28 | user: action.payload.user, 29 | authenticated: true, 30 | }; 31 | case LOGOUT: 32 | Sentry.setUser({}); 33 | 34 | return { 35 | ...state, 36 | token: null, 37 | authenticated: false, 38 | user: null, 39 | }; 40 | default: 41 | return state; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import auth from "./auth"; 3 | 4 | export default combineReducers({ 5 | auth, 6 | }); 7 | -------------------------------------------------------------------------------- /frontend/src/store.js: -------------------------------------------------------------------------------- 1 | import thunk from "redux-thunk"; 2 | import { createStore, applyMiddleware, compose } from "redux"; 3 | 4 | import reducer from "./reducers"; 5 | 6 | export const initStore = (initialState = {}) => { 7 | return createStore( 8 | reducer, 9 | initialState, 10 | compose( 11 | applyMiddleware(thunk), 12 | window.__REDUX_DEVTOOLS_EXTENSION__ 13 | ? window.__REDUX_DEVTOOLS_EXTENSION__() 14 | : (f) => f 15 | ) 16 | ); 17 | }; 18 | 19 | export default initStore(); 20 | -------------------------------------------------------------------------------- /frontend/src/types.js: -------------------------------------------------------------------------------- 1 | export const LOAD_GAPI = "load_gapi"; 2 | export const LOGIN = "login"; 3 | export const LOGOUT = "logout"; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/apollo.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache } from "apollo-boost"; 2 | import { ApolloLink, concat } from "apollo-link"; 3 | import { createUploadLink } from "apollo-upload-client"; 4 | 5 | import config from "../config"; 6 | import { getCookie } from "./cookie"; 7 | 8 | let apolloClient = null; 9 | 10 | const defaultOptions = { 11 | watchQuery: { 12 | fetchPolicy: "no-cache", 13 | errorPolicy: "ignore", 14 | }, 15 | query: { 16 | fetchPolicy: "no-cache", 17 | errorPolicy: "all", 18 | }, 19 | }; 20 | 21 | const getToken = () => { 22 | let token = null; 23 | if (typeof document !== "undefined") { 24 | token = "Token " + getCookie("token"); 25 | } 26 | return token; 27 | }; 28 | 29 | export function createClient(initialState) { 30 | const authMiddleware = new ApolloLink((operation, forward) => { 31 | // add the authorization to the headers 32 | operation.setContext({ 33 | headers: { 34 | authorization: getToken(), 35 | }, 36 | }); 37 | 38 | return forward(operation); 39 | }); 40 | 41 | const httpLink = createUploadLink({ 42 | uri: config.apiEndpoint, // Server URL (must be absolute) 43 | credentials: "same-origin", // Additional fetch() options like `credentials` or `headers` 44 | }); 45 | 46 | return new ApolloClient({ 47 | link: concat(authMiddleware, httpLink), 48 | cache: new InMemoryCache().restore(initialState || {}), 49 | defaultOptions, 50 | }); 51 | } 52 | 53 | export function initApollo(initialState) { 54 | if (!apolloClient) { 55 | apolloClient = createClient(initialState); 56 | } 57 | 58 | return apolloClient; 59 | } 60 | 61 | export default initApollo(); 62 | -------------------------------------------------------------------------------- /frontend/src/utils/cookie.js: -------------------------------------------------------------------------------- 1 | // resource for handling cookies taken from here: 2 | // https://github.com/carlos-peru/next-with-api/blob/master/lib/session.js 3 | 4 | import cookie from "js-cookie"; 5 | 6 | export const setCookie = (key, value, options) => { 7 | cookie.set(key, value, { 8 | path: "/", 9 | ...options, 10 | }); 11 | }; 12 | 13 | export const removeCookie = (key) => { 14 | cookie.remove(key); 15 | }; 16 | 17 | export const getCookie = (key, req) => { 18 | return cookie.get(key); 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/utils/csv.js: -------------------------------------------------------------------------------- 1 | export const convertValue = (value) => { 2 | if (value === false) { 3 | return "false"; 4 | } else if (value === true) { 5 | return "true"; 6 | } else if (value === 0) { 7 | return "0"; 8 | } else if (!value) { 9 | return ""; 10 | } else { 11 | return '"' + value.replace(/"/g, '""') + '"'; 12 | } 13 | }; 14 | 15 | export const downloadCsv = (rows, filename) => { 16 | let data = rows.map((row) => row.map(convertValue).join(",")).join("\r\n"); 17 | 18 | if (navigator.msSaveBlob) { 19 | navigator.msSaveBlob(data, filename); 20 | } else { 21 | //In FF link must be added to DOM to be clicked 22 | let link = document.createElement("a"); 23 | let blob = new Blob(["\ufeff", data]); 24 | link.href = window.URL.createObjectURL(blob); 25 | link.setAttribute("download", filename); 26 | document.body.appendChild(link); 27 | link.click(); 28 | document.body.removeChild(link); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /frontend/src/utils/loadScript.js: -------------------------------------------------------------------------------- 1 | const cache = {}; 2 | 3 | export default (d, s, id, jsSrc) => { 4 | return new Promise((resolve, reject) => { 5 | if (cache[id]) return resolve(cache[id]); 6 | const element = d.getElementsByTagName(s)[0]; 7 | const fjs = element; 8 | let js = element; 9 | js = d.createElement(s); 10 | js.id = id; 11 | js.type = "text/javascript"; 12 | js.src = jsSrc; 13 | if (fjs && fjs.parentNode) { 14 | fjs.parentNode.insertBefore(js, fjs); 15 | } else { 16 | d.head.appendChild(js); 17 | } 18 | let timeoutTimer = setTimeout( 19 | () => reject(new Error(`Timed out loading ${id}`)), 20 | 5000 21 | ); 22 | js.onload = function () { 23 | clearTimeout(timeoutTimer); 24 | resolve(js); 25 | }; 26 | cache[id] = js; 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/src/utils/shuffle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Not a true random shuffle but a modified Fisher-Yates that prevents the last 3 | * element from becoming the first. Therefore it won't do anything when 4 | * a.length < 3. 5 | */ 6 | export const reshuffle = (a) => { 7 | if (!a.length) return a; 8 | for (let i = a.length - 2; i > 0; i--) { 9 | const j = Math.floor(Math.random() * (i + 1)); 10 | [a[i], a[j]] = [a[j], a[i]]; 11 | } 12 | // Final swap 13 | const last = a.length - 1; 14 | const i = Math.ceil(Math.random() * last); 15 | [a[i], a[last]] = [a[last], a[i]]; 16 | return a; 17 | }; 18 | 19 | /** 20 | * TODO the initial shuffle should be truly random. 21 | * True random shuffle. 22 | */ 23 | export const shuffle = (a) => { 24 | if (!a.length) return a; 25 | for (let i = a.length - 2; i > 0; i--) { 26 | const j = Math.floor(Math.random() * (i + 1)); 27 | [a[i], a[j]] = [a[j], a[i]]; 28 | } 29 | // Final swap 30 | const last = a.length - 1; 31 | const i = Math.ceil(Math.random() * last); 32 | [a[i], a[last]] = [a[last], a[i]]; 33 | return a; 34 | }; 35 | 36 | export const listOfIntegers = (length) => { 37 | return Array(length) 38 | .fill() 39 | .map((_, i) => i); 40 | }; 41 | -------------------------------------------------------------------------------- /frontend/src/utils/strings.js: -------------------------------------------------------------------------------- 1 | export const getColumnTitle = function (column) { 2 | switch (column) { 3 | case "dateStarted": 4 | return "Start Date"; 5 | default: 6 | let columnPieces = column.split("."); 7 | let columnName = columnPieces[columnPieces.length - 1]; 8 | return columnName.substr(0, 1).toUpperCase() + columnName.substr(1); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atlas", 3 | "version": "0.1.0", 4 | "description": "## Install", 5 | "main": "index.js", 6 | "dependencies": { 7 | "concurrently": "^5.2.0" 8 | }, 9 | "devDependencies": { 10 | "eslint": "^6.8.0", 11 | "eslint-config-react-app": "^4.0.0", 12 | "eslint-plugin-flowtype": "^3.7.0", 13 | "eslint-plugin-import": "^2.22.0", 14 | "eslint-plugin-jsx-a11y": "^6.2.1", 15 | "eslint-plugin-react": "^7.13.0", 16 | "eslint-plugin-react-hooks": "^1.6.0" 17 | }, 18 | "scripts": { 19 | "start": "concurrently \"npm:start-backend\" \"npm:start-frontend\"", 20 | "start-backend": "atlas runserver", 21 | "start-frontend": "cd frontend && npm start", 22 | "test": "cd frontend && npm test" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/getsentry/atlas.git" 27 | }, 28 | "author": "David Cramer (https://github.com/dcramer)", 29 | "license": "Apache-2.0", 30 | "bugs": { 31 | "url": "https://github.com/getsentry/atlas/issues" 32 | }, 33 | "homepage": "https://github.com/getsentry/atlas#readme", 34 | "volta": { 35 | "node": "12.17.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | python_files = test_*.py 3 | addopts = --tb=short -p no:doctest 4 | norecursedirs = build dist docs htmlcov .* {args} 5 | looponfailroots = backend 6 | timeout = 30 7 | DJANGO_SETTINGS_MODULE = atlas.settings 8 | 9 | [flake8] 10 | max-line-length = 100 11 | ignore = C901,E203,E266,E501,W503,E402,E302 12 | max-complexity = 18 13 | select = B,C,E,F,W,T4,B9 14 | exclude = .git,*/migrations/*,*/node_modules/* 15 | 16 | [bdist_wheel] 17 | python-tag = py38 18 | 19 | [coverage:run] 20 | omit = 21 | */migrations/* 22 | source = 23 | backend 24 | --------------------------------------------------------------------------------