├── backend ├── python │ ├── app │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── graphql │ │ │ │ ├── __init__.py │ │ │ │ ├── queries │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── test_story_query.py │ │ │ │ └── mutations │ │ │ │ │ ├── __init__.py │ │ │ │ │ └── test_comment_mutation.py │ │ │ ├── services │ │ │ │ └── __init__.py │ │ │ └── helpers │ │ │ │ ├── db_helpers.py │ │ │ │ ├── comment_helpers.py │ │ │ │ └── story_helpers.py │ │ ├── resources │ │ │ ├── token.py │ │ │ ├── file_dto.py │ │ │ ├── update_user_dto.py │ │ │ ├── user_dto.py │ │ │ ├── auth_dto.py │ │ │ └── create_user_dto.py │ │ ├── graphql │ │ │ ├── queries │ │ │ │ ├── file_query.py │ │ │ │ ├── comment_query.py │ │ │ │ ├── language_query.py │ │ │ │ ├── user_query.py │ │ │ │ └── story_query.py │ │ │ ├── types │ │ │ │ ├── language_type.py │ │ │ │ ├── file_type.py │ │ │ │ ├── comment_type.py │ │ │ │ └── user_type.py │ │ │ ├── service.py │ │ │ ├── mutations │ │ │ │ ├── file_mutation.py │ │ │ │ ├── language_mutation.py │ │ │ │ └── comment_mutation.py │ │ │ ├── view.py │ │ │ └── __init__.py │ │ ├── models │ │ │ ├── story_translation_content_status.py │ │ │ ├── file.py │ │ │ ├── language.py │ │ │ ├── story_content.py │ │ │ ├── comment.py │ │ │ ├── user.py │ │ │ ├── __init__.py │ │ │ ├── story_content_all.py │ │ │ ├── story.py │ │ │ ├── story_translation_content.py │ │ │ ├── story_translation.py │ │ │ ├── story_translation_content_all.py │ │ │ ├── comment_all.py │ │ │ ├── story_all.py │ │ │ ├── user_all.py │ │ │ └── story_translation_all.py │ │ ├── services │ │ │ ├── implementations │ │ │ │ ├── utils.py │ │ │ │ ├── language_service.py │ │ │ │ └── email_service.py │ │ │ └── interfaces │ │ │ │ ├── email_service.py │ │ │ │ ├── file_service.py │ │ │ │ ├── language_service.py │ │ │ │ └── comment_service.py │ │ ├── config.py │ │ ├── conftest.py │ │ └── __init__.py │ ├── .dockerignore │ ├── migrations │ │ ├── README │ │ ├── script.py.mako │ │ ├── versions │ │ │ ├── b6b3b28741b9_add_date_uploaded_to_stories.py │ │ │ ├── 5a1dbdb0d816_add_email_to_user_table.py │ │ │ ├── 1b392386c3bd_add_korean_amharic_and_yiddish_to_.py │ │ │ ├── c29196f789b5_add_user_auth_id_unique_constraint.py │ │ │ ├── e59c40e297d1_differentiate_approved_languages_.py │ │ │ ├── 91e8d22078f4_add_additional_experiences_column_to_.py │ │ │ ├── 04407ca2b4ef_add_activity_time_columns.py │ │ │ ├── 04653332ed3a_add_story_test_changes.py │ │ │ ├── 08f4eaf160d3_soft_delete_users.py │ │ │ └── 92ace1398b9f_update_soft_delete_user_index_names.py │ │ ├── alembic.ini │ │ └── env.py │ ├── tools │ │ ├── migrate_and_pytest.sh │ │ └── seed_users.py │ ├── Procfile │ ├── aml.ini │ ├── Dockerfile │ ├── server.py │ ├── wsgi.py │ └── requirements.txt ├── .firebaserc └── firebase.json ├── frontend ├── .git-ftp-include ├── .dockerignore ├── src │ ├── react-app-env.d.ts │ ├── components │ │ ├── homepage │ │ │ ├── Filter.css │ │ │ ├── MyWorkFilter.tsx │ │ │ ├── WelcomeModal.tsx │ │ │ └── StoryList.tsx │ │ ├── pages │ │ │ ├── NotFound.tsx │ │ │ ├── AdminPage.tsx │ │ │ └── CompleteProfilePage.tsx │ │ ├── utils │ │ │ ├── DropdownIndicator.tsx │ │ │ ├── InfoAlert.tsx │ │ │ ├── Paginator.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── ConfirmationModal.tsx │ │ │ └── ButtonRadioGroup.tsx │ │ ├── auth │ │ │ ├── PrivateRoute.tsx │ │ │ ├── LoginImage.tsx │ │ │ ├── RefreshCredentials.tsx │ │ │ ├── Logout.tsx │ │ │ └── ResetPassword.tsx │ │ ├── onboarding │ │ │ ├── Block.tsx │ │ │ ├── FailUserModal.tsx │ │ │ └── TestFeedbackModal.tsx │ │ ├── admin │ │ │ ├── Statistic.tsx │ │ │ ├── ManageStoryTests.tsx │ │ │ ├── EmptyTable.tsx │ │ │ ├── ManageUsers.tsx │ │ │ └── ManageStories.tsx │ │ ├── translation │ │ │ ├── EditableCell.tsx │ │ │ ├── FontSizeSlider.tsx │ │ │ └── Autosave.tsx │ │ ├── review │ │ │ ├── ExistingComment.tsx │ │ │ └── ApproveAll.tsx │ │ └── navigation │ │ │ └── UserModal.tsx │ ├── assets │ │ ├── login-background-image.png │ │ ├── user-icon.svg │ │ ├── checkmark.svg │ │ └── comment_no_number.svg │ ├── theme │ │ ├── components │ │ │ ├── Link.ts │ │ │ ├── Disabled.ts │ │ │ ├── StoryCard.ts │ │ │ ├── Filter.ts │ │ │ ├── Heading.ts │ │ │ ├── Textarea.ts │ │ │ ├── DatePicker.scss │ │ │ ├── Select.ts │ │ │ ├── Text.ts │ │ │ ├── Badge.ts │ │ │ └── Button.ts │ │ ├── colors.ts │ │ └── index.ts │ ├── constants │ │ ├── Roles.ts │ │ ├── AuthConstants.ts │ │ ├── Languages.ts │ │ ├── Levels.ts │ │ ├── Stage.ts │ │ └── Enums.ts │ ├── setupTests.ts │ ├── App.test.tsx │ ├── utils │ │ ├── hooks │ │ │ └── useQueryParams.tsx │ │ ├── DeepCopyUtils.ts │ │ ├── DebounceUtils.ts │ │ ├── LocalStorageUtils.ts │ │ ├── StageUtils.ts │ │ ├── StatusUtils.ts │ │ └── FileUtils.ts │ ├── APIClients │ │ ├── mutations │ │ │ ├── LanguageMutations.ts │ │ │ ├── CommentMutations.ts │ │ │ ├── AuthMutations.ts │ │ │ └── UserMutations.ts │ │ └── queries │ │ │ ├── LanguageQueries.ts │ │ │ ├── FileQueries.ts │ │ │ ├── CommentQueries.ts │ │ │ └── UserQueries.ts │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── contexts │ │ └── AuthContext.ts │ └── index.css ├── .prettierrc.js ├── .firebaserc ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── planet-read-logo.ico │ ├── manifest.json │ └── index.html ├── Dockerfile ├── firebase.json ├── .eslintrc.js ├── tsconfig.json ├── README.md └── package.json ├── secret.config ├── .gitignore ├── .htaccess ├── setup.sh ├── utils.sh ├── docker-compose.testing.yml ├── hooks └── post-merge ├── .github ├── workflows │ ├── firebase-hosting-merge.yml │ └── firebase-hosting-pull-request.yml └── PULL_REQUEST_TEMPLATE.md ├── docker-compose.yml └── update_secret_files.py /backend/python/app/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/python/app/tests/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/python/app/tests/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.git-ftp-include: -------------------------------------------------------------------------------- 1 | !frontend/build/ 2 | -------------------------------------------------------------------------------- /backend/python/app/tests/graphql/queries/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /backend/python/.dockerignore: -------------------------------------------------------------------------------- 1 | venv 2 | **/__pycache__ 3 | -------------------------------------------------------------------------------- /backend/python/app/tests/graphql/mutations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/python/migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /backend/python/tools/migrate_and_pytest.sh: -------------------------------------------------------------------------------- 1 | flask db upgrade 2 | pytest 3 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "all", 3 | }; 4 | -------------------------------------------------------------------------------- /backend/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "planet-read-uwbp" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "planet-read-uwbp" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwblueprint/planet-read/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/components/homepage/Filter.css: -------------------------------------------------------------------------------- 1 | datalist { 2 | display: flex; 3 | justify-content: space-between; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/planet-read-logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwblueprint/planet-read/HEAD/frontend/public/planet-read-logo.ico -------------------------------------------------------------------------------- /secret.config: -------------------------------------------------------------------------------- 1 | ROOT_ENV_FILE=.env 2 | BACKEND_ENV_FILE=backend/.env 3 | FIREBASE_SERVICE_ACCOUNT=backend/python/firebaseServiceAccount.json -------------------------------------------------------------------------------- /frontend/src/assets/login-background-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwblueprint/planet-read/HEAD/frontend/src/assets/login-background-image.png -------------------------------------------------------------------------------- /frontend/src/theme/components/Link.ts: -------------------------------------------------------------------------------- 1 | const Link = { 2 | baseStyle: { 3 | textDecoration: "underline", 4 | }, 5 | }; 6 | 7 | export default Link; 8 | -------------------------------------------------------------------------------- /backend/python/Procfile: -------------------------------------------------------------------------------- 1 | web: python -m tools.insert_test_data erase & wait -n; flask db upgrade & wait -n; python -m tools.insert_test_data & wait -n; python wsgi.py -------------------------------------------------------------------------------- /frontend/src/constants/Roles.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const roleOptions = [{ value: "Translator" }, { value: "Reviewer" }]; 3 | -------------------------------------------------------------------------------- /frontend/src/constants/AuthConstants.ts: -------------------------------------------------------------------------------- 1 | const AUTHENTICATED_USER_KEY = `${window.location.hostname}:AUTHENTICATED_USER`; 2 | 3 | export default AUTHENTICATED_USER_KEY; 4 | -------------------------------------------------------------------------------- /backend/python/app/tests/helpers/db_helpers.py: -------------------------------------------------------------------------------- 1 | def db_session_add_commit_obj(db, obj): 2 | db.session.add(obj) 3 | assert db.session.commit() == None 4 | return obj 5 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Disabled.ts: -------------------------------------------------------------------------------- 1 | const Disabled = { 2 | baseStyle: { 3 | opacity: 0.35, 4 | pointerEvents: "none", 5 | }, 6 | }; 7 | export default Disabled; 8 | -------------------------------------------------------------------------------- /backend/python/aml.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = wsgi 3 | 4 | master = true 5 | processes = 5 6 | 7 | socket = aml.sock 8 | chmod-socket = 660 9 | vacuum = true 10 | 11 | die-on-term = true -------------------------------------------------------------------------------- /frontend/src/assets/user-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /backend/python/app/resources/token.py: -------------------------------------------------------------------------------- 1 | class Token: 2 | def __init__(self, access_token, refresh_token): 3 | self.access_token = access_token 4 | self.refresh_token = refresh_token 5 | -------------------------------------------------------------------------------- /backend/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/constants/Languages.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const scriptDirectionOptions = [ 3 | { value: "Left-to-right" }, 4 | { value: "Right-to-left" }, 5 | ]; 6 | -------------------------------------------------------------------------------- /frontend/src/constants/Levels.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const levelOptions = [ 3 | { value: "1" }, 4 | { value: "2" }, 5 | { value: "3" }, 6 | { value: "4" }, 7 | ]; 8 | -------------------------------------------------------------------------------- /frontend/src/constants/Stage.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | export const stageOptions = [ 3 | { value: "In Translation" }, 4 | { value: "In Review" }, 5 | { value: "Completed" }, 6 | ]; 7 | -------------------------------------------------------------------------------- /backend/python/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | 3 | COPY requirements.txt ./ 4 | RUN pip install -r requirements.txt 5 | 6 | COPY . /app 7 | 8 | WORKDIR /app 9 | 10 | EXPOSE 5000 11 | CMD ["python", "wsgi.py"] 12 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14.17.0-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install 7 | 8 | COPY . ./ 9 | RUN yarn fix 10 | 11 | EXPOSE 3000 12 | ENTRYPOINT ["yarn", "start"] 13 | -------------------------------------------------------------------------------- /backend/python/app/resources/file_dto.py: -------------------------------------------------------------------------------- 1 | class FileDTO(object): 2 | def __init__(self, path): 3 | if not path: 4 | raise ValueError("Invalid path value, must not be empty") 5 | 6 | self.path = path 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/.env 3 | **/build 4 | **/venv 5 | **/__pycache__ 6 | **/.pytest_cache 7 | **/*.log 8 | **/firebaseServiceAccount.json 9 | **/.DS_Store 10 | **/nodemailer.config.ts 11 | .vscode 12 | package-lock.json 13 | package.json 14 | *.docx 15 | -------------------------------------------------------------------------------- /backend/python/app/resources/update_user_dto.py: -------------------------------------------------------------------------------- 1 | class UpdateUserDTO: 2 | def __init__(self, first_name, last_name, email, role): 3 | self.first_name = first_name 4 | self.last_name = last_name 5 | self.email = email 6 | self.role = role 7 | -------------------------------------------------------------------------------- /frontend/src/assets/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/constants/Enums.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | export enum Role { 4 | Reviewer, 5 | Translator, 6 | } 7 | 8 | export enum StoryAssignStage { 9 | INITIAL, 10 | SUCCESS, 11 | CANCELLED, 12 | REMOVED, 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | 2 | RewriteEngine On 3 | RewriteBase / 4 | RewriteRule ^index\.html$ - [L] 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteCond %{REQUEST_FILENAME} !-d 7 | RewriteCond %{REQUEST_FILENAME} !-l 8 | RewriteRule . /index.html [L] 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/components/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@chakra-ui/react"; 3 | 4 | const NotFound = () => { 5 | return ( 6 | 7 | 404 Not Found 🙁 8 | 9 | ); 10 | }; 11 | 12 | export default NotFound; 13 | -------------------------------------------------------------------------------- /backend/python/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | # note: VS Code's Python extension might falsely report an unresolved import 6 | from app import create_app 7 | 8 | load_dotenv() 9 | config_name = os.getenv("FLASK_CONFIG") or "development" 10 | application = create_app(config_name) 11 | -------------------------------------------------------------------------------- /frontend/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import App from "./App"; 4 | 5 | test("renders learn react link", () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/theme/components/StoryCard.ts: -------------------------------------------------------------------------------- 1 | const StoryCard = { 2 | baseStyle: { 3 | background: "gray.100", 4 | borderRadius: "20px", 5 | flexDirection: "row", 6 | flexWrap: "nowrap", 7 | margin: "15px 0px 15px 0px", 8 | padding: "10px", 9 | minWidth: "1000px", 10 | }, 11 | }; 12 | export default StoryCard; 13 | -------------------------------------------------------------------------------- /backend/python/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | # note: VS Code's Python extension might falsely report an unresolved import 6 | from server import application 7 | 8 | if __name__ == "__main__": 9 | load_dotenv() 10 | port = int(os.getenv("PORT", 5000)) 11 | application.run(host="0.0.0.0", port=port) 12 | -------------------------------------------------------------------------------- /backend/python/app/graphql/queries/file_query.py: -------------------------------------------------------------------------------- 1 | from ...middlewares.auth import require_authorization_by_role_gql 2 | from ..service import services 3 | 4 | 5 | @require_authorization_by_role_gql({"User", "Admin"}) 6 | def resolve_file_by_id(root, info, id): 7 | file = services["file"].get_file_path(id) 8 | return services["file"].download_file(file.path) 9 | -------------------------------------------------------------------------------- /backend/python/app/graphql/types/language_type.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class LanguageDTO(graphene.ObjectType): 5 | id = graphene.Int(required=True) 6 | language = graphene.String(required=True) 7 | is_rtl = graphene.Boolean(required=True) 8 | 9 | 10 | class LanguageIsRTLDTO(graphene.ObjectType): 11 | is_rtl = graphene.Boolean(required=True) 12 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Filter.ts: -------------------------------------------------------------------------------- 1 | const Filter = { 2 | baseStyle: { 3 | boxShadow: "0 0 12px -9px rgba(0, 0, 0, 0.7)", 4 | flexDirection: "column", 5 | justifyContent: "flex-start", 6 | marginRight: "10px", 7 | minWidth: "292px", 8 | minHeight: "80vh", 9 | padding: "50px 50px 50px 50px", 10 | }, 11 | }; 12 | export default Filter; 13 | -------------------------------------------------------------------------------- /frontend/src/utils/hooks/useQueryParams.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | 4 | // A custom hook to parse URL query strings 5 | const useQueryParams = () => { 6 | const { search } = useLocation(); 7 | 8 | return React.useMemo(() => new URLSearchParams(search), [search]); 9 | }; 10 | 11 | export default useQueryParams; 12 | -------------------------------------------------------------------------------- /frontend/src/utils/DeepCopyUtils.ts: -------------------------------------------------------------------------------- 1 | const deepCopy = (lines: Object) => { 2 | // This is a funky method to make deep copies on objects with primative values 3 | // https://javascript.plainenglish.io/how-to-deep-copy-objects-and-arrays-in-javascript-7c911359b089 4 | // Should probably go under some util 5 | return JSON.parse(JSON.stringify(lines)); 6 | }; 7 | 8 | export default deepCopy; 9 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Add My Language", 3 | "name": "Add My Language", 4 | "icons": [ 5 | { 6 | "src": "planet-read-logo.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/APIClients/mutations/LanguageMutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export type Languages = string[]; 4 | 5 | export const ADD_LANGUAGE = gql` 6 | mutation AddLanguage($language: String!, $isRtl: Boolean!) { 7 | addLanguage(language: $language, isRtl: $isRtl) { 8 | ok 9 | } 10 | } 11 | `; 12 | 13 | export type AddLanguageResponse = { 14 | ok: boolean; 15 | }; 16 | -------------------------------------------------------------------------------- /frontend/src/components/utils/DropdownIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box } from "@chakra-ui/react"; 3 | 4 | const DropdownIndicator = () => { 5 | return ( 6 | 12 | ); 13 | }; 14 | 15 | export default DropdownIndicator; 16 | -------------------------------------------------------------------------------- /backend/python/app/models/story_translation_content_status.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class StoryTranslationContentStatus(enum.Enum): 5 | name = "story_translation_content_status" 6 | DEFAULT = "DEFAULT" 7 | ACTION_REQUIRED = "ACTION_REQUIRED" 8 | APPROVED = "APPROVED" 9 | TEST_CORRECT = "TEST_CORRECT" 10 | TEST_INCORRECT = "TEST_INCORRECT" 11 | TEST_PARTIALLY_CORRECT = "TEST_PARTIALLY_CORRECT" 12 | -------------------------------------------------------------------------------- /backend/python/app/graphql/types/file_type.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class FileDTO(graphene.ObjectType): 5 | id = graphene.Int() 6 | path = graphene.String(required=True) 7 | 8 | 9 | class CreateFileDTO(graphene.InputObjectType): 10 | path = graphene.String(required=True) 11 | 12 | 13 | class DownloadFileDTO(graphene.ObjectType): 14 | file = graphene.String(required=True) 15 | ext = graphene.String(required=True) 16 | -------------------------------------------------------------------------------- /backend/python/app/graphql/queries/comment_query.py: -------------------------------------------------------------------------------- 1 | from ...middlewares.auth import require_authorization_by_role_gql 2 | from ..service import services 3 | 4 | 5 | @require_authorization_by_role_gql({"User", "Admin"}) 6 | def resolve_comments_by_story_translation( 7 | root, info, story_translation_id, resolved=None 8 | ): 9 | return services["comment"].get_comments_by_story_translation( 10 | story_translation_id, resolved 11 | ) 12 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Heading.ts: -------------------------------------------------------------------------------- 1 | const Heading = { 2 | baseStyle: { 3 | padding: "5px 0px 10px 0px", 4 | }, 5 | sizes: { 6 | lg: { 7 | fontSize: "30px", 8 | }, 9 | md: { 10 | fontSize: "24px", 11 | }, 12 | }, 13 | variants: { 14 | light: { 15 | fontWeight: "400", 16 | }, 17 | subtitle: { 18 | fontWeight: "500", 19 | color: "gray.400", 20 | }, 21 | }, 22 | }; 23 | export default Heading; 24 | -------------------------------------------------------------------------------- /backend/python/app/graphql/queries/language_query.py: -------------------------------------------------------------------------------- 1 | from ...middlewares.auth import require_authorization_by_role_gql 2 | from ..service import services 3 | 4 | 5 | @require_authorization_by_role_gql({"User", "Admin"}) 6 | def resolve_languages(root, info): 7 | return services["language"].get_languages() 8 | 9 | 10 | @require_authorization_by_role_gql({"User", "Admin"}) 11 | def resolve_is_rtl(root, info, language): 12 | return services["language"].get_is_rtl(language) 13 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /frontend/src/APIClients/queries/LanguageQueries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export type Languages = string[]; 4 | 5 | export const GET_LANGUAGES = gql` 6 | query GetLanguages { 7 | languages 8 | } 9 | `; 10 | 11 | export const getLanguagesQuery = { 12 | fieldName: "getLanguages", 13 | string: GET_LANGUAGES, 14 | }; 15 | 16 | export const IS_RTL = (language: string) => gql` 17 | query IsRtl { 18 | isRtl ( 19 | language: "${language}" 20 | ) { 21 | isRtl 22 | } 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | vault_path_replacement_str="s|^vault_path=.*|vault_path=\"$1\"|g" 4 | default_branch_replacement_str="s|^default_branch=.*|default_branch=\"$2\"|g" 5 | 6 | # MacOS 7 | if [[ $OSTYPE =~ darwin.* ]]; then 8 | sed -i "" -e $vault_path_replacement_str ./hooks/post-merge 9 | sed -i "" -e $default_branch_replacement_str ./hooks/post-merge 10 | else 11 | sed -i $vault_path_replacement_str ./hooks/post-merge 12 | sed -i $default_branch_replacement_str ./hooks/post-merge 13 | fi 14 | cp ./hooks/post-merge ./.git/hooks/post-merge 15 | -------------------------------------------------------------------------------- /backend/python/app/services/implementations/utils.py: -------------------------------------------------------------------------------- 1 | from ...models import db 2 | 3 | 4 | def handle_exceptions(f): 5 | from functools import wraps 6 | 7 | @wraps(f) 8 | def wrapper(*args, **kwargs): 9 | try: 10 | db.session.execute("SELECT 1;") 11 | except: 12 | db.session.rollback() 13 | 14 | try: 15 | return f(*args, **kwargs) 16 | except Exception as error: 17 | self = args[0] 18 | self.logger.error(error) 19 | db.session.rollback() 20 | raise error 21 | 22 | return wrapper 23 | -------------------------------------------------------------------------------- /frontend/src/utils/DebounceUtils.ts: -------------------------------------------------------------------------------- 1 | // Inspiration from https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 2 | function debounce(func: (...args: any[]) => any, wait: number) { 3 | let timeout: NodeJS.Timeout | null; 4 | 5 | return function executedFunction(...args: any[]) { 6 | const later = () => { 7 | timeout = null; 8 | func(...args); 9 | }; 10 | 11 | if (timeout != null) { 12 | clearTimeout(timeout); 13 | } 14 | 15 | timeout = setTimeout(later, wait); 16 | }; 17 | } 18 | 19 | export { debounce as default }; 20 | -------------------------------------------------------------------------------- /backend/python/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | ecmaVersion: 2020, 6 | sourceType: "module", 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | project: "./tsconfig.json", 11 | createDefaultProgram: true, 12 | }, 13 | settings: { 14 | react: { 15 | version: "detect", 16 | }, 17 | }, 18 | extends: ["airbnb-typescript", "prettier", "plugin:prettier/recommended"], 19 | rules: { 20 | "prettier/prettier": ["error", { endOfLine: "auto" }], 21 | "prefer-destructuring": ["error", { object: true, array: false }], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /frontend/src/theme/colors.ts: -------------------------------------------------------------------------------- 1 | const colors = { 2 | blue: { 3 | 50: "#E5F0FF", 4 | 100: "#1D6CA5", 5 | // necessary for button color scheme 6 | 500: "#1D6CA5", 7 | }, 8 | orange: { 9 | 50: "#FFE3B4", 10 | 100: "#C89234", 11 | }, 12 | green: { 13 | 50: "#EEFCEE", 14 | 100: "#6E8E6E", 15 | }, 16 | red: { 17 | 50: "#FFD4D4", 18 | 100: "#E53E3E", 19 | }, 20 | purple: { 21 | 50: "#E0D4FF", 22 | }, 23 | gray: { 24 | 50: "#F9FAFC", 25 | 100: "#F1F4F7", 26 | 200: "#DCDCDC", 27 | 300: "#CECECE", 28 | 400: "#606062", 29 | 500: "#1C1C1E", 30 | }, 31 | }; 32 | 33 | export default colors; 34 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Textarea.ts: -------------------------------------------------------------------------------- 1 | const Textarea = { 2 | variants: { 3 | translationEditable: { 4 | border: "1px solid", 5 | borderColor: "gray.300", 6 | borderRadius: "3px", 7 | color: "gray.500", 8 | fontSize: "12px", 9 | padding: "10px", 10 | margin: "12px", 11 | }, 12 | maxCharsReached: { 13 | border: "1px solid red", 14 | borderRadius: "3px", 15 | margin: "12px", 16 | padding: "10px", 17 | }, 18 | disabled: { 19 | opacity: "100%", 20 | backgroundColor: "white", 21 | cursor: "not-allowed", 22 | }, 23 | }, 24 | }; 25 | export default Textarea; 26 | -------------------------------------------------------------------------------- /frontend/src/components/auth/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | 4 | import AuthContext from "../../contexts/AuthContext"; 5 | 6 | type PrivateRouteProps = { 7 | component: React.FC; 8 | path: string; 9 | exact: boolean; 10 | }; 11 | 12 | const PrivateRoute = ({ component, exact, path }: PrivateRouteProps) => { 13 | const { authenticatedUser } = useContext(AuthContext); 14 | 15 | return authenticatedUser ? ( 16 | 17 | ) : ( 18 | 19 | ); 20 | }; 21 | 22 | export default PrivateRoute; 23 | -------------------------------------------------------------------------------- /backend/python/app/graphql/service.py: -------------------------------------------------------------------------------- 1 | """ 2 | ..services.implementations can only be leveraged once the 3 | flask app has started. Create dummy services here that will be 4 | updated with live app loggers during __init__.py 5 | """ 6 | from ..services.implementations.comment_service import CommentService 7 | from ..services.implementations.language_service import LanguageService 8 | from ..services.implementations.story_service import StoryService 9 | from ..services.implementations.user_service import UserService 10 | 11 | services = { 12 | "comment": CommentService(), 13 | "language": LanguageService(), 14 | "story": StoryService(), 15 | "user": UserService(), 16 | } 17 | -------------------------------------------------------------------------------- /backend/python/app/tests/graphql/mutations/test_comment_mutation.py: -------------------------------------------------------------------------------- 1 | def test_create_comment(): 2 | pass 3 | 4 | 5 | def test_update_comment_by_id(): 6 | pass 7 | 8 | 9 | def test_update_comment_by_id_invalid_id(): 10 | pass 11 | 12 | 13 | def test_update_comment_by_id_user_is_not_comment_author(): 14 | pass 15 | 16 | 17 | def test_update_comments(): 18 | pass 19 | 20 | 21 | def test_update_comments_invalid_ids(): 22 | pass 23 | 24 | 25 | def test_update_comments_user_is_not_comments_author(): 26 | # will it save the ones that they are the author to? 27 | # and not do anything about the rest? 28 | # or not save anything at all? 29 | pass 30 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "downlevelIteration": true, 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "strict": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "module": "esnext", 18 | "moduleResolution": "node", 19 | "resolveJsonModule": true, 20 | "isolatedModules": true, 21 | "noEmit": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } -------------------------------------------------------------------------------- /frontend/src/components/onboarding/Block.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Heading, Text } from "@chakra-ui/react"; 4 | 5 | export type BlockProps = { 6 | header: string; 7 | text: string; 8 | }; 9 | 10 | const Block = ({ header, text }: BlockProps) => { 11 | return ( 12 | 13 | 14 | 20 | {header} 21 | 22 | {text} 23 | 24 | ); 25 | }; 26 | 27 | export default Block; 28 | -------------------------------------------------------------------------------- /frontend/src/APIClients/queries/FileQueries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export type File = { 4 | file: string; 5 | ext: string; 6 | }; 7 | 8 | export const GET_FILE = gql` 9 | query GetFile($id: Int!) { 10 | fileById(id: $id) { 11 | ext 12 | file 13 | } 14 | } 15 | `; 16 | 17 | export const getFileQuery = { 18 | fieldName: "fileById", 19 | string: GET_FILE, 20 | }; 21 | 22 | export const EXPORT_STORY_TRANSLATION = gql` 23 | query ExportStoryTranslation($id: Int!) { 24 | exportStoryTranslation(id: $id) { 25 | ext 26 | file 27 | } 28 | } 29 | `; 30 | 31 | export const exportStoryTranslationQuery = { 32 | fieldName: "exportStoryTranslation", 33 | string: EXPORT_STORY_TRANSLATION, 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import "./index.css"; 5 | import { ColorModeScript } from "@chakra-ui/react"; 6 | 7 | import App from "./App"; 8 | import reportWebVitals from "./reportWebVitals"; 9 | import theme from "./theme"; 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | , 16 | document.getElementById("root"), 17 | ); 18 | 19 | // If you want to start measuring performance in your app, pass a function 20 | // to log results (for example: reportWebVitals(console.log)) 21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 22 | reportWebVitals(); 23 | -------------------------------------------------------------------------------- /frontend/src/components/admin/Statistic.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Box, Heading, Text } from "@chakra-ui/react"; 4 | 5 | export type StatisticProps = { 6 | header: string; 7 | num: number; 8 | }; 9 | 10 | const Statistic = ({ header, num }: StatisticProps) => { 11 | return ( 12 | 23 | 24 | {header} 25 | 26 | {num} 27 | 28 | ); 29 | }; 30 | 31 | export default Statistic; 32 | -------------------------------------------------------------------------------- /utils.sh: -------------------------------------------------------------------------------- 1 | if [[ "$1" = "pytest" ]] 2 | then 3 | if [[ "$2" = "" ]] 4 | then 5 | docker exec -it planet-read_py-backend_1 pytest 6 | else 7 | if [[ "$3" = "" ]] 8 | then 9 | # TODO improvement - parse from backend/ path location 10 | # right now, has to be from app/... 11 | docker exec -it planet-read_py-backend_1 pytest $2 12 | else 13 | # super detailed, match a test name pattern 14 | docker exec -it planet-read_py-backend_1 pytest $2 -k "$3" -vv 15 | fi 16 | fi 17 | elif [[ "$1" = "enter-db" ]] 18 | then 19 | ROOT_PASSWORD=$(grep MYSQL_ROOT_PASSWORD .env | cut -d '=' -f2) 20 | docker exec -it planet-read_db_1 mysql -u root -p$ROOT_PASSWORD 21 | else 22 | echo "Invalid command." 23 | fi 24 | -------------------------------------------------------------------------------- /docker-compose.testing.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | py-backend: 5 | command: /app/tools/wait-for-it.sh test-db:3306 -- ./tools/migrate_and_pytest.sh 6 | build: 7 | context: ./backend/python 8 | dockerfile: Dockerfile 9 | ports: 10 | - 5000:5000 11 | dns: 12 | - 8.8.8.8 13 | volumes: 14 | - file_uploads:/var/lib/file_uploads 15 | depends_on: 16 | - test-db 17 | env_file: 18 | - ./.env 19 | test-db: 20 | image: mysql:8.0.25 21 | command: --default-authentication-plugin=mysql_native_password --innodb-use-native-aio=0 22 | ports: 23 | - 3307:3306 24 | volumes: 25 | - mysql_test_data:/var/lib/mysql_test/ 26 | env_file: 27 | - ./.env 28 | 29 | volumes: 30 | mysql_test_data: 31 | file_uploads: 32 | -------------------------------------------------------------------------------- /frontend/src/components/utils/InfoAlert.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Alert, AlertIcon } from "@chakra-ui/react"; 3 | import { MdInfoOutline } from "react-icons/md"; 4 | 5 | export type AlertProps = { 6 | message: string; 7 | colour?: string; 8 | height?: string; 9 | }; 10 | 11 | const InfoAlert = ({ 12 | message, 13 | colour = "gray.200", 14 | height = "50px", 15 | }: AlertProps) => { 16 | return ( 17 | 27 | 28 | {message} 29 | 30 | ); 31 | }; 32 | 33 | export default InfoAlert; 34 | -------------------------------------------------------------------------------- /frontend/src/contexts/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export type AuthenticatedUser = { 4 | id: string; 5 | firstName: string; 6 | lastName: string; 7 | email: string; 8 | role: "Admin" | "User"; 9 | accessToken: string; 10 | approvedLanguagesTranslation: string; 11 | approvedLanguagesReview: string; 12 | } | null; 13 | 14 | type AuthContextType = { 15 | authenticatedUser: AuthenticatedUser; 16 | setAuthenticatedUser: (_authenticatedUser: AuthenticatedUser) => void; 17 | }; 18 | 19 | const AuthContext = createContext({ 20 | authenticatedUser: null, 21 | /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ 22 | setAuthenticatedUser: (_authenticatedUser: AuthenticatedUser): void => {}, 23 | }); 24 | 25 | export default AuthContext; 26 | -------------------------------------------------------------------------------- /backend/python/app/services/interfaces/email_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IEmailService(ABC): 5 | """ 6 | EmailService interface for handling email related functionality 7 | """ 8 | 9 | @abstractmethod 10 | def send_email(self, to, subject, body): 11 | """ 12 | Sends email with given parameters 13 | 14 | :param to: recipient's email 15 | :type to: str 16 | :param subject: email subject 17 | :type subject: str 18 | :param body: email body as html 19 | :type body: str 20 | :return: a dict that contains fields like id, threadId, labelIds 21 | of the sent email 22 | :rtype: dict 23 | :raises Exception: if email was not sent successfully 24 | """ 25 | pass 26 | -------------------------------------------------------------------------------- /backend/python/app/resources/user_dto.py: -------------------------------------------------------------------------------- 1 | class UserDTO: 2 | def __init__( 3 | self, 4 | id, 5 | first_name, 6 | last_name, 7 | email, 8 | role, 9 | resume=None, 10 | profile_pic=None, 11 | approved_languages_translation=None, 12 | approved_languages_review=None, 13 | additional_experiences=None, 14 | ): 15 | self.id = id 16 | self.first_name = first_name 17 | self.last_name = last_name 18 | self.email = email 19 | self.role = role 20 | self.resume = resume 21 | self.profile_pic = profile_pic 22 | self.approved_languages_translation = approved_languages_translation 23 | self.approved_languages_review = approved_languages_review 24 | self.additional_experiences = additional_experiences 25 | -------------------------------------------------------------------------------- /backend/python/app/models/file.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect 2 | from sqlalchemy.orm.properties import ColumnProperty 3 | 4 | from . import db 5 | 6 | 7 | class File(db.Model): 8 | __tablename__ = "files" 9 | 10 | id = db.Column(db.Integer, primary_key=True, nullable=False) 11 | path = db.Column(db.String(255), nullable=False) 12 | 13 | def to_dict(self, include_relationships=False): 14 | cls = type(self) 15 | mapper = inspect(cls) 16 | formatted = {} 17 | for column in mapper.attrs: 18 | field = column.key 19 | attr = getattr(self, field) 20 | if isinstance(column, ColumnProperty): 21 | formatted[field] = attr 22 | elif include_relationships: 23 | formatted[field] = [obj.to_dict() for obj in attr] 24 | return formatted 25 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 4 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 5 | "Helvetica Neue", sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | :root { 16 | --dark-blue: #1d6ca5; 17 | --interactive-grey: #606062; 18 | } 19 | 20 | ::-webkit-scrollbar { 21 | width: 8px; 22 | } 23 | 24 | ::-webkit-scrollbar-track { 25 | background: #ededed; 26 | } 27 | 28 | ::-webkit-scrollbar-thumb { 29 | background: #c4c4c4; 30 | } 31 | 32 | ::-webkit-scrollbar-thumb:hover { 33 | /* Don't know what colour we want here */ 34 | background: #b3b3b3; 35 | } 36 | -------------------------------------------------------------------------------- /backend/python/app/graphql/queries/user_query.py: -------------------------------------------------------------------------------- 1 | from ...middlewares.auth import require_authorization_by_role_gql 2 | from ..service import services 3 | 4 | 5 | @require_authorization_by_role_gql({"Admin"}) 6 | def resolve_users(root, info, isTranslators, language, level, name_or_email): 7 | return services["user"].get_users(isTranslators, language, level, name_or_email) 8 | 9 | 10 | @require_authorization_by_role_gql({"User", "Admin"}) 11 | def resolve_user_by_id(root, info, id): 12 | return services["user"].get_user_by_id(id) 13 | 14 | 15 | @require_authorization_by_role_gql({"User", "Admin"}) 16 | def resolve_user_by_email(root, info, email): 17 | return services["user"].get_user_by_email(email) 18 | 19 | 20 | """ 21 | Required queries: 22 | userById(id: ID!): UserDTO! 23 | userByEmail(email: String!): UserDTO! 24 | users: [UserDTO!]!user 25 | """ 26 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/b6b3b28741b9_add_date_uploaded_to_stories.py: -------------------------------------------------------------------------------- 1 | """add date_uploaded to stories 2 | 3 | Revision ID: b6b3b28741b9 4 | Revises: 04407ca2b4ef 5 | Create Date: 2021-12-21 19:21:11.449605 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "b6b3b28741b9" 13 | down_revision = "04407ca2b4ef" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column("stories", sa.Column("date_uploaded", sa.DateTime(), nullable=False)) 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | op.drop_column("stories", "date_uploaded") 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /backend/python/app/services/interfaces/file_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IFileService(ABC): 5 | """ 6 | A class to handle CRUD functionality for file entities 7 | """ 8 | 9 | @abstractmethod 10 | def get_file_path(self, id): 11 | """Return a File and its contents based on id 12 | 13 | :param id: File id 14 | :return: FileDTO, contains path and id of file 15 | :rtype: FileDTO 16 | :raises Exception: id retrieval fails or base64 encoding fails 17 | """ 18 | pass 19 | 20 | @abstractmethod 21 | def create_file(self, file): 22 | """Create a new File object 23 | 24 | :param file: dictionary of file fields 25 | :return: dictionary of File object 26 | :rtype: dictionary 27 | :raises Exception: if file fields are invalid 28 | """ 29 | pass 30 | -------------------------------------------------------------------------------- /backend/python/app/config.py: -------------------------------------------------------------------------------- 1 | class Config(object): 2 | """ 3 | Common configurations 4 | """ 5 | 6 | # put any configurations here that are common across all environments 7 | # list of available configs: https://flask.palletsprojects.com/en/1.1.x/config/ 8 | 9 | 10 | class DevelopmentConfig(Config): 11 | """ 12 | Development configurations 13 | """ 14 | 15 | DEBUG = True 16 | SQLALCHEMY_ECHO = True 17 | 18 | 19 | class ProductionConfig(Config): 20 | """ 21 | Production configurations 22 | """ 23 | 24 | DEBUG = False 25 | 26 | 27 | class TestingConfig(Config): 28 | """ 29 | Testing configurations 30 | """ 31 | 32 | DEBUG = True 33 | SQLALCHEMY_ECHO = True 34 | TESTING = True 35 | 36 | 37 | app_config = { 38 | "development": DevelopmentConfig, 39 | "production": ProductionConfig, 40 | "testing": TestingConfig, 41 | } 42 | -------------------------------------------------------------------------------- /backend/python/app/graphql/mutations/file_mutation.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import graphene 4 | from graphene_file_upload.scalars import Upload 5 | 6 | from ...middlewares.auth import require_authorization_by_role_gql 7 | from ..service import services 8 | from ..types.file_type import CreateFileDTO, FileDTO 9 | 10 | 11 | class CreateFile(graphene.Mutation): 12 | class Arguments: 13 | file = Upload(required=True) 14 | 15 | ok = graphene.Boolean() 16 | file = graphene.Field(lambda: FileDTO) 17 | 18 | @require_authorization_by_role_gql({"User", "Admin"}) 19 | def mutate(root, info, file=None): 20 | try: 21 | res = services["file"].create_file(file) 22 | return CreateFile(file=res, ok=True) 23 | except Exception as e: 24 | error_message = getattr(e, "message", None) 25 | raise Exception(error_message if error_message else str(e)) 26 | -------------------------------------------------------------------------------- /hooks/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # update secret files if git pull on master resulted in new changes being merged locally 3 | 4 | branch=`git symbolic-ref HEAD` 5 | root_dir=`git rev-parse --show-toplevel` 6 | # must replace with actual vault_path and default_branch, can run setup.sh 7 | vault_path="kv/planet-read" 8 | default_branch="main" 9 | 10 | if [ $branch = "refs/heads/${default_branch}" ]; then 11 | if [ -f "${root_dir}/update_secret_files.py" ]; then 12 | vault kv get -format=json $vault_path | python "${root_dir}/update_secret_files.py" 13 | if [ $? -eq 0 ]; then 14 | echo "Successfully pulled secrets from Vault" 15 | else 16 | echo "An error occurred while pulling secrets from Vault" 17 | fi 18 | else 19 | echo "To automatically update secrets after git pull on default branch, place update_secret_files.py in repo root directory" 20 | fi 21 | fi 22 | -------------------------------------------------------------------------------- /frontend/src/components/auth/LoginImage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Flex, Heading, Image, Spacer } from "@chakra-ui/react"; 3 | import Logo from "../../assets/planet-read-logo.svg"; 4 | import LoginBackgroundImage from "../../assets/login-background-image.png"; 5 | 6 | const LoginImage = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | Add my Language 14 | 15 | 16 | 17 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default LoginImage; 28 | -------------------------------------------------------------------------------- /backend/python/app/resources/auth_dto.py: -------------------------------------------------------------------------------- 1 | from .token import Token 2 | from .user_dto import UserDTO 3 | 4 | 5 | class AuthDTO(Token, UserDTO): 6 | def __init__( 7 | self, 8 | access_token, 9 | refresh_token, 10 | id, 11 | first_name, 12 | last_name, 13 | email, 14 | role, 15 | resume, 16 | profile_pic, 17 | approved_languages_translation, 18 | approved_languages_review, 19 | additional_experiences, 20 | ): 21 | Token.__init__(self, access_token, refresh_token) 22 | UserDTO.__init__( 23 | self, 24 | id, 25 | first_name, 26 | last_name, 27 | email, 28 | role, 29 | resume, 30 | profile_pic, 31 | approved_languages_translation, 32 | approved_languages_review, 33 | additional_experiences, 34 | ) 35 | -------------------------------------------------------------------------------- /frontend/src/components/auth/RefreshCredentials.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import AuthContext from "../../contexts/AuthContext"; 4 | import { 5 | REFRESH, 6 | RefreshResponse, 7 | } from "../../APIClients/mutations/AuthMutations"; 8 | import authAPIClient from "../../APIClients/AuthAPIClient"; 9 | 10 | const RefreshCredentials = () => { 11 | const { setAuthenticatedUser } = useContext(AuthContext); 12 | 13 | const [refresh] = useMutation<{ refresh: RefreshResponse }>(REFRESH); 14 | 15 | const onRefreshClick = async () => { 16 | const result = await authAPIClient.refresh(refresh); 17 | if (!result) { 18 | setAuthenticatedUser(null); 19 | } 20 | }; 21 | 22 | return ( 23 | 24 | Refresh Credentials 25 | 26 | ); 27 | }; 28 | 29 | export default RefreshCredentials; 30 | -------------------------------------------------------------------------------- /backend/python/app/models/language.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, inspect 2 | from sqlalchemy.dialects.mysql import TEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | 5 | from . import db 6 | 7 | 8 | class Language(db.Model): 9 | __tablename__ = "languages" 10 | 11 | id = db.Column(db.Integer, primary_key=True, nullable=False) 12 | language = db.Column(TEXT, nullable=False) 13 | is_rtl = db.Column(Boolean, default=False) 14 | 15 | def to_dict(self, include_relationships=False): 16 | cls = type(self) 17 | mapper = inspect(cls) 18 | formatted = {} 19 | for column in mapper.attrs: 20 | field = column.key 21 | attr = getattr(self, field) 22 | if isinstance(column, ColumnProperty): 23 | formatted[field] = attr 24 | elif include_relationships: 25 | formatted[field] = [obj.to_dict() for obj in attr] 26 | return formatted 27 | -------------------------------------------------------------------------------- /backend/python/app/tests/helpers/comment_helpers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | 3 | from ...models.comment import Comment 4 | from ...models.comment_all import CommentAll 5 | 6 | 7 | class CreateCommentDTO: 8 | def __init__(self, story_translation_content_id, content): 9 | self.story_translation_content_id = story_translation_content_id 10 | self.content = content 11 | 12 | 13 | class UpdateCommentRequestDTO(Mapping): 14 | def __init__(self, comment): 15 | self.id = comment.id 16 | self.resolved = comment.resolved 17 | self.content = comment.content 18 | self._dict = dict(id=comment.id, content=comment.content) 19 | 20 | # These functions are necessary for bulk_update_mappings to work 21 | def __getitem__(self, key): 22 | return self._dict[key] 23 | 24 | def __iter__(self): 25 | return iter(self._dict) 26 | 27 | def __len__(self): 28 | return len(self._dict) 29 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/5a1dbdb0d816_add_email_to_user_table.py: -------------------------------------------------------------------------------- 1 | """add email to user table 2 | 3 | Revision ID: 5a1dbdb0d816 4 | Revises: e59c40e297d1 5 | Create Date: 2021-10-13 02:19:05.769253 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "5a1dbdb0d816" 13 | down_revision = "e59c40e297d1" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column("users", sa.Column("email", sa.String(length=100), nullable=False)) 21 | op.create_unique_constraint(None, "users", ["email"]) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_constraint(u"email", "users", type_="unique") 28 | op.drop_column("users", "email") 29 | # ### end Alembic commands ### 30 | -------------------------------------------------------------------------------- /frontend/src/components/auth/Logout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Text } from "@chakra-ui/react"; 3 | import { Icon } from "@chakra-ui/icon"; 4 | import { MdIosShare } from "react-icons/md"; 5 | import AuthContext from "../../contexts/AuthContext"; 6 | import authAPIClient from "../../APIClients/AuthAPIClient"; 7 | 8 | const Logout = () => { 9 | /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ 10 | const { authenticatedUser, setAuthenticatedUser } = useContext(AuthContext); 11 | 12 | const onLogOutClick = () => { 13 | authAPIClient.logout(); 14 | setAuthenticatedUser(null); 15 | }; 16 | 17 | return ( 18 | 19 | 26 | Sign out 27 | 28 | ); 29 | }; 30 | 31 | export default Logout; 32 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-merge.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on merge to main 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | paths: 11 | - "frontend/**" 12 | 13 | defaults: 14 | run: 15 | working-directory: frontend 16 | 17 | jobs: 18 | build_and_deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - run: echo "REACT_APP_BACKEND_URL=${{ secrets.REACT_APP_BACKEND_URL_PROD }}" > .env 23 | - run: rm -rf node_modules && yarn install && yarn build 24 | - uses: FirebaseExtended/action-hosting-deploy@v0 25 | with: 26 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 27 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_PLANET_READ_UWBP }}" 28 | channelId: live 29 | projectId: planet-read-uwbp 30 | entryPoint: ./frontend 31 | -------------------------------------------------------------------------------- /backend/python/app/graphql/mutations/language_mutation.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from ...middlewares.auth import require_authorization_by_role_gql 4 | from ..service import services 5 | from ..types.language_type import LanguageDTO 6 | 7 | 8 | class AddLanguage(graphene.Mutation): 9 | class Arguments: 10 | language = graphene.String(required=True) 11 | is_rtl = graphene.Boolean(required=True) 12 | 13 | ok = graphene.Boolean() 14 | language = graphene.Field(lambda: LanguageDTO) 15 | 16 | @require_authorization_by_role_gql({"Admin"}) 17 | def mutate(root, info, language, is_rtl=False): 18 | try: 19 | new_language = services["language"].add_language( 20 | language=language, is_rtl=is_rtl 21 | ) 22 | return AddLanguage(ok=True, language=new_language) 23 | except Exception as e: 24 | error_message = getattr(e, "message", None) 25 | raise Exception(error_message if error_message else str(e)) 26 | -------------------------------------------------------------------------------- /.github/workflows/firebase-hosting-pull-request.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the Firebase CLI 2 | # https://github.com/firebase/firebase-tools 3 | 4 | name: Deploy to Firebase Hosting on PR in frontend 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - "frontend/**" 10 | 11 | defaults: 12 | run: 13 | working-directory: frontend 14 | 15 | jobs: 16 | build_and_preview: 17 | if: "${{ github.event.pull_request.head.repo.full_name == github.repository }}" 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - run: echo "REACT_APP_BACKEND_URL=${{ secrets.REACT_APP_BACKEND_URL_PREVIEW }}" > .env 22 | - run: rm -rf node_modules && yarn install && yarn build 23 | - uses: FirebaseExtended/action-hosting-deploy@v0 24 | with: 25 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 26 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_PLANET_READ_UWBP }}" 27 | projectId: planet-read-uwbp 28 | entryPoint: ./frontend 29 | -------------------------------------------------------------------------------- /backend/python/app/graphql/types/comment_type.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class CreateCommentDTO(graphene.InputObjectType): 5 | story_translation_content_id = graphene.Int(required=True) 6 | content = graphene.String(required=True) 7 | 8 | 9 | class UpdateCommentRequestDTO(graphene.InputObjectType): 10 | id = graphene.Int(required=True) 11 | resolved = graphene.Boolean() 12 | content = graphene.String() 13 | 14 | 15 | class CommentResponseDTO(graphene.ObjectType): 16 | id = graphene.Int() 17 | story_translation_content_id = graphene.Int(required=True) 18 | user_id = graphene.Int(required=True) 19 | comment_index = graphene.Int() 20 | time = graphene.DateTime(required=True) 21 | resolved = graphene.Boolean(required=True) 22 | content = graphene.String(required=True) 23 | line_index = graphene.Int() 24 | 25 | 26 | class UpdateCommentResponseDTO(graphene.ObjectType): 27 | id = graphene.Int(required=True) 28 | resolved = graphene.Boolean() 29 | content = graphene.String() 30 | -------------------------------------------------------------------------------- /backend/python/app/services/interfaces/language_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class ILanguageService(ABC): 5 | """ 6 | A class to handle CRUD functionality for languages 7 | """ 8 | 9 | @abstractmethod 10 | def get_languages(self): 11 | """ 12 | Get a list of languages currently used in the platform 13 | """ 14 | pass 15 | 16 | @abstractmethod 17 | def add_language(self, language, is_rtl): 18 | """ 19 | Add a new language to the db 20 | :param str language: new language 21 | :param is_rtl bool: indicates if the languages reads/writes right to left 22 | :rtype: LanguageDTO 23 | :raises Exception: if language fails to save 24 | """ 25 | pass 26 | 27 | @abstractmethod 28 | def get_is_rtl(self, language): 29 | """ 30 | Return bool indicating if language is from right to left 31 | :param language: language to be checked 32 | :rtype: bool 33 | """ 34 | -------------------------------------------------------------------------------- /frontend/src/theme/components/DatePicker.scss: -------------------------------------------------------------------------------- 1 | @import "node_modules/react-datepicker/src/stylesheets/datepicker.scss"; 2 | 3 | .react-datepicker-wrapper, 4 | .react-datepicker__input-container, 5 | .react-datepicker__input-container input { 6 | align-items: center; 7 | display: inline-block; 8 | font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 9 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 10 | "Helvetica Neue", sans-serif; 11 | font-size: 16px; 12 | height: 40px; 13 | width: 100%; 14 | } 15 | 16 | .react-datepicker__input-container input { 17 | border: 1px solid #dcdcdc; // #DCDCDC corresponds with gray.200 18 | border-radius: 0.375rem; 19 | padding-left: 10px; 20 | } 21 | 22 | .react-datepicker__month-container { 23 | color: #969eac; 24 | font-family: "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 25 | "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 26 | "Helvetica Neue", sans-serif; 27 | font-size: 12px; 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | 3 | import colors from "./colors"; 4 | import Badge from "./components/Badge"; 5 | import Button from "./components/Button"; 6 | import Disabled from "./components/Disabled"; 7 | import Filter from "./components/Filter"; 8 | import Heading from "./components/Heading"; 9 | import Link from "./components/Link"; 10 | import Select from "./components/Select"; 11 | import StoryCard from "./components/StoryCard"; 12 | import Text from "./components/Text"; 13 | import Textarea from "./components/Textarea"; 14 | 15 | const customTheme = extendTheme({ 16 | fonts: { 17 | heading: `"Noto Sans"`, 18 | body: `"Noto Sans"`, 19 | }, 20 | config: { 21 | useSystemColorMode: false, 22 | initialColorMode: "light", 23 | }, 24 | colors, 25 | components: { 26 | Badge, 27 | Button, 28 | Disabled, 29 | Filter, 30 | Heading, 31 | Link, 32 | Text, 33 | Textarea, 34 | Select, 35 | StoryCard, 36 | }, 37 | }); 38 | 39 | export default customTheme; 40 | -------------------------------------------------------------------------------- /frontend/src/assets/comment_no_number.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /backend/python/app/models/story_content.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect, select 2 | from sqlalchemy_utils import create_view 3 | 4 | from . import db 5 | from .story_content_all import StoryContentAll 6 | 7 | stmt = select([StoryContentAll]).where(StoryContentAll.is_deleted == False) 8 | story_contents_active = create_view("story_contents", stmt, db.Model.metadata) 9 | 10 | 11 | class StoryContent(db.Model): 12 | def __init__(self, **kwargs): 13 | super(StoryContent, self).__init__(**kwargs) 14 | self.is_deleted = False 15 | 16 | __table__ = story_contents_active 17 | 18 | def to_dict(self, include_relationships=False): 19 | cls = type(self) 20 | mapper = inspect(cls) 21 | formatted = {} 22 | for column in mapper.attrs: 23 | field = column.key 24 | attr = getattr(self, field) 25 | if isinstance(column, ColumnProperty): 26 | formatted[field] = attr 27 | elif include_relationships: 28 | formatted[field] = [obj.to_dict() for obj in attr] 29 | return formatted 30 | -------------------------------------------------------------------------------- /frontend/src/utils/LocalStorageUtils.ts: -------------------------------------------------------------------------------- 1 | // Get a string value from localStorage as an object 2 | export const getLocalStorageObj = (localStorageKey: string) => { 3 | const stringifiedObj = localStorage.getItem(localStorageKey); 4 | let object = null; 5 | 6 | if (stringifiedObj) { 7 | try { 8 | object = JSON.parse(stringifiedObj); 9 | } catch (error) { 10 | object = null; 11 | } 12 | } 13 | 14 | return object; 15 | }; 16 | 17 | // Get a property of an object value from localStorage 18 | export const getLocalStorageObjProperty = ( 19 | localStorageKey: string, 20 | property: string, 21 | ) => { 22 | const object = getLocalStorageObj(localStorageKey); 23 | if (!object) return object; 24 | 25 | return object[property]; 26 | }; 27 | 28 | // Set a property of an object value in localStorage 29 | export const setLocalStorageObjProperty = ( 30 | localStorageKey: string, 31 | property: string, 32 | value: string, 33 | ) => { 34 | const object = getLocalStorageObj(localStorageKey); 35 | object[property] = value; 36 | localStorage.setItem(localStorageKey, JSON.stringify(object)); 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Select.ts: -------------------------------------------------------------------------------- 1 | import { StylesConfig } from "react-select"; 2 | 3 | const Select = { 4 | baseStyle: {}, 5 | variants: { 6 | commentsFilter: { 7 | field: { 8 | fontSize: "14px", 9 | textAlignLast: "center", 10 | textTransform: "uppercase", 11 | color: "blue.100", 12 | fontWeight: "600", 13 | }, 14 | icon: { 15 | color: "blue.100", 16 | }, 17 | }, 18 | }, 19 | }; 20 | 21 | export const colourStyles: StylesConfig = { 22 | control: (styles) => ({ 23 | ...styles, 24 | backgroundColor: "#F1F4F7", // #F1F4F7 is gray.100 25 | border: "none", 26 | }), 27 | option: (styles, { isSelected }) => { 28 | return { 29 | ...styles, 30 | color: "black", 31 | backgroundColor: isSelected ? "#F1F4F7" : "white", // #F1F4F7 is gray.100 32 | "&:hover": { 33 | backgroundColor: "#F1F4F7", // #F1F4F7 is gray.100 34 | }, 35 | }; 36 | }, 37 | indicatorSeparator: (styles) => ({ ...styles, display: "none" }), 38 | placeholder: (styles) => ({ ...styles, color: "black" }), 39 | }; 40 | 41 | export default Select; 42 | -------------------------------------------------------------------------------- /frontend/src/components/admin/ManageStoryTests.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Box, Heading, Flex } from "@chakra-ui/react"; 3 | import { useQuery } from "@apollo/client"; 4 | import { 5 | buildStoryTranslationTestsQuery, 6 | StoryTranslationTest, 7 | } from "../../APIClients/queries/StoryQueries"; 8 | import StoryTestsTable from "./StoryTestsTable"; 9 | 10 | const ManageStoryTests = () => { 11 | const [storyTests, setStoryTests] = useState([]); 12 | 13 | const query = buildStoryTranslationTestsQuery("", 0, "", "", true); 14 | 15 | const { data } = useQuery(query.string, { 16 | fetchPolicy: "cache-and-network", 17 | onCompleted: () => { 18 | const result = [...data[query.fieldName]]; 19 | setStoryTests(result); 20 | }, 21 | }); 22 | 23 | return ( 24 | 25 | 26 | 27 | Tests 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default ManageStoryTests; 36 | -------------------------------------------------------------------------------- /backend/python/app/models/comment.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect, select 2 | from sqlalchemy.orm.properties import ColumnProperty 3 | from sqlalchemy_utils import create_view 4 | 5 | from . import db 6 | from .comment_all import CommentAll 7 | from .user import User # unused import required for foreign key 8 | 9 | stmt = select([CommentAll]).where(CommentAll.is_deleted == False) 10 | comments_active = create_view("comments", stmt, db.Model.metadata) 11 | 12 | 13 | class Comment(db.Model): 14 | def __init__(self, **kwargs): 15 | super(Comment, self).__init__(**kwargs) 16 | self.is_deleted = False 17 | 18 | __table__ = comments_active 19 | 20 | def to_dict(self, include_relationships=False): 21 | cls = type(self) 22 | mapper = inspect(cls) 23 | formatted = {} 24 | for column in mapper.attrs: 25 | field = column.key 26 | attr = getattr(self, field) 27 | if isinstance(column, ColumnProperty): 28 | formatted[field] = attr 29 | elif include_relationships: 30 | formatted[field] = [obj.to_dict() for obj in attr] 31 | return formatted 32 | -------------------------------------------------------------------------------- /backend/python/app/models/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect, select 2 | from sqlalchemy.dialects.mysql import TEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | from sqlalchemy_utils import create_view 5 | 6 | from . import db 7 | from .file import File # unused import required for foreign key 8 | from .user_all import UserAll 9 | 10 | query = select([UserAll]).where(UserAll.is_deleted == False) 11 | users_active = create_view("users", query, db.Model.metadata) 12 | 13 | 14 | class User(db.Model): 15 | def __init__(self, **kwargs): 16 | super(User, self).__init__(**kwargs) 17 | self.is_deleted = False 18 | 19 | __table__ = users_active 20 | 21 | def to_dict(self, include_relationships=False): 22 | cls = type(self) 23 | mapper = inspect(cls) 24 | formatted = {} 25 | for column in mapper.attrs: 26 | field = column.key 27 | attr = getattr(self, field) 28 | if isinstance(column, ColumnProperty): 29 | formatted[field] = attr 30 | elif include_relationships: 31 | formatted[field] = [obj.to_dict() for obj in attr] 32 | return formatted 33 | -------------------------------------------------------------------------------- /backend/python/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask_migrate import Migrate 4 | from flask_sqlalchemy import SQLAlchemy 5 | 6 | db = SQLAlchemy() 7 | migrate = Migrate(compare_type=True) 8 | erase_db_and_sync = False 9 | 10 | 11 | def init_app(app): 12 | from .comment import Comment 13 | from .comment_all import CommentAll 14 | from .file import File 15 | from .language import Language 16 | from .story import Story 17 | from .story_all import StoryAll 18 | from .story_content import StoryContent 19 | from .story_content_all import StoryContentAll 20 | from .story_translation import StoryTranslation 21 | from .story_translation_all import StoryTranslationAll 22 | from .story_translation_content import StoryTranslationContent 23 | from .story_translation_content_all import StoryTranslationContentAll 24 | from .user import User 25 | from .user_all import UserAll 26 | 27 | app.app_context().push() 28 | db.init_app(app) 29 | migrate.init_app(app, db) 30 | 31 | if erase_db_and_sync: 32 | # drop tables 33 | db.reflect() 34 | db.drop_all() 35 | 36 | # recreate tables 37 | db.create_all() 38 | -------------------------------------------------------------------------------- /backend/python/app/models/story_content_all.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, inspect 2 | from sqlalchemy.dialects.mysql import LONGTEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | 5 | from . import db 6 | 7 | 8 | class StoryContentAll(db.Model): 9 | 10 | __tablename__ = "story_contents_all" 11 | 12 | id = db.Column(db.Integer, primary_key=True, nullable=False) 13 | story_id = db.Column( 14 | db.Integer, db.ForeignKey("stories_all.id"), index=True, nullable=False 15 | ) 16 | line_index = db.Column(db.Integer, nullable=False) 17 | content = db.Column(LONGTEXT, nullable=False) 18 | is_deleted = db.Column(Boolean, default=False, nullable=False) 19 | 20 | def to_dict(self, include_relationships=False): 21 | cls = type(self) 22 | mapper = inspect(cls) 23 | formatted = {} 24 | for column in mapper.attrs: 25 | field = column.key 26 | attr = getattr(self, field) 27 | if isinstance(column, ColumnProperty): 28 | formatted[field] = attr 29 | elif include_relationships: 30 | formatted[field] = [obj.to_dict() for obj in attr] 31 | return formatted 32 | -------------------------------------------------------------------------------- /backend/python/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic,flask_migrate 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | 52 | [post_write_hooks] 53 | # format using "black" 54 | hooks=black 55 | black.type = console_scripts 56 | black.entrypoint = black -------------------------------------------------------------------------------- /backend/python/migrations/versions/1b392386c3bd_add_korean_amharic_and_yiddish_to_.py: -------------------------------------------------------------------------------- 1 | """add Korean, Amharic and Yiddish to languages 2 | 3 | Revision ID: 1b392386c3bd 4 | Revises: 26b7c9ffcb2b 5 | Create Date: 2022-04-02 17:06:51.489927 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "1b392386c3bd" 14 | down_revision = "26b7c9ffcb2b" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.execute("INSERT INTO languages (language, is_rtl) VALUES ('Korean', false);") 22 | op.execute("INSERT INTO languages (language, is_rtl) VALUES ('Amharic', false);") 23 | op.execute("INSERT INTO languages (language, is_rtl) VALUES ('Yiddish', true);") 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.execute("DELETE FROM languages WHERE language = 'Korean';") 30 | op.execute("DELETE FROM languages WHERE language = 'Amharic';") 31 | op.execute("DELETE FROM languages WHERE language = 'Yiddish';") 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /backend/python/app/models/story.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import inspect, select 4 | from sqlalchemy.orm.properties import ColumnProperty 5 | from sqlalchemy_utils import create_view 6 | 7 | from . import db 8 | from .story_all import StoryAll 9 | 10 | stmt = select([StoryAll]).where(StoryAll.is_deleted == False) 11 | stories_active = create_view("stories", stmt, db.Model.metadata) 12 | 13 | 14 | class Story(db.Model): 15 | def __init__(self, **kwargs): 16 | super(Story, self).__init__(**kwargs) 17 | self.is_deleted = False 18 | if self.is_test is None: 19 | self.is_test = False 20 | if self.date_uploaded is None: 21 | self.date_uploaded = datetime.utcnow() 22 | 23 | __table__ = stories_active 24 | 25 | def to_dict(self, include_relationships=False): 26 | cls = type(self) 27 | mapper = inspect(cls) 28 | formatted = {} 29 | for column in mapper.attrs: 30 | field = column.key 31 | attr = getattr(self, field) 32 | if isinstance(column, ColumnProperty): 33 | formatted[field] = attr 34 | elif include_relationships: 35 | formatted[field] = [obj.to_dict() for obj in attr] 36 | return formatted 37 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/c29196f789b5_add_user_auth_id_unique_constraint.py: -------------------------------------------------------------------------------- 1 | """add user auth_id unique constraint 2 | 3 | Revision ID: c29196f789b5 4 | Revises: 3f6fff20884c 5 | Create Date: 2021-09-06 16:30:17.662080 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "c29196f789b5" 14 | down_revision = "3f6fff20884c" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column( 22 | "users", 23 | "auth_id", 24 | existing_type=mysql.TEXT(), 25 | type_=sa.String(length=100), 26 | existing_nullable=False, 27 | ) 28 | op.create_unique_constraint(None, "users", ["auth_id"]) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade(): 33 | ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_constraint(u"auth_id", "users", type_="unique") 35 | op.alter_column( 36 | "users", 37 | "auth_id", 38 | existing_type=sa.String(length=100), 39 | type_=mysql.TEXT(), 40 | existing_nullable=False, 41 | ) 42 | ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /backend/python/app/models/story_translation_content.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect, select 2 | from sqlalchemy.orm.properties import ColumnProperty 3 | from sqlalchemy_utils import create_view 4 | 5 | from . import db 6 | from .story_translation_content_all import StoryTranslationContentAll 7 | 8 | stmt = select([StoryTranslationContentAll]).where( 9 | StoryTranslationContentAll.is_deleted == False 10 | ) 11 | story_translation_contents_active = create_view( 12 | "story_translation_contents", stmt, db.Model.metadata 13 | ) 14 | 15 | 16 | class StoryTranslationContent(db.Model): 17 | def __init__(self, **kwargs): 18 | super(StoryTranslationContent, self).__init__(**kwargs) 19 | self.is_deleted = False 20 | 21 | __table__ = story_translation_contents_active 22 | 23 | def to_dict(self, include_relationships=False): 24 | cls = type(self) 25 | mapper = inspect(cls) 26 | formatted = {} 27 | for column in mapper.attrs: 28 | field = column.key 29 | attr = getattr(self, field) 30 | if isinstance(column, ColumnProperty): 31 | formatted[field] = attr 32 | elif include_relationships: 33 | formatted[field] = [obj.to_dict() for obj in attr] 34 | return formatted 35 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Text.ts: -------------------------------------------------------------------------------- 1 | const Text = { 2 | variants: { 3 | cell: { 4 | backgroundColor: "gray.100", 5 | borderRadius: "3px", 6 | fontSize: "12px", 7 | padding: "10px", 8 | margin: "12px", 9 | width: "100%", 10 | }, 11 | cellHeader: { 12 | borderRadius: "3px", 13 | margin: "12px", 14 | width: "100%", 15 | }, 16 | lineIndex: { 17 | fontSize: "16px", 18 | margin: "12px", 19 | minWidth: "40px", 20 | }, 21 | statusHeader: { 22 | fontSize: "16px", 23 | padding: "12px", 24 | width: "150px", 25 | }, 26 | comment: { 27 | fontSize: "14px", 28 | }, 29 | saveStatus: { 30 | color: "gray.400", 31 | fontSize: "16px", 32 | marginLeft: "10px", 33 | }, 34 | previewModalLineIndex: { 35 | minWidth: "30px", 36 | margin: "10px", 37 | }, 38 | previewModalTranslationContent: { 39 | border: "1px solid", 40 | borderColor: "gray.300", 41 | borderRadius: "3px", 42 | color: "gray.500", 43 | fontSize: "16px", 44 | padding: "10px", 45 | margin: "12px", 46 | width: "100%", 47 | }, 48 | link: { 49 | color: "blue.500", 50 | cursor: "pointer", 51 | }, 52 | }, 53 | }; 54 | export default Text; 55 | -------------------------------------------------------------------------------- /backend/python/app/models/story_translation.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import inspect, select 2 | from sqlalchemy.orm.properties import ColumnProperty 3 | from sqlalchemy_utils import create_view 4 | 5 | from . import db 6 | from .story_translation_all import StoryTranslationAll 7 | from .user import User # unused import required for foreign key 8 | 9 | stmt = select([StoryTranslationAll]).where(StoryTranslationAll.is_deleted == False) 10 | story_translations_active = create_view("story_translations", stmt, db.Model.metadata) 11 | 12 | 13 | class StoryTranslation(db.Model): 14 | def __init__(self, **kwargs): 15 | super(StoryTranslation, self).__init__(**kwargs) 16 | self.is_deleted = False 17 | if self.is_test is None: 18 | self.is_test = False 19 | 20 | __table__ = story_translations_active 21 | 22 | def to_dict(self, include_relationships=False): 23 | cls = type(self) 24 | mapper = inspect(cls) 25 | formatted = {} 26 | for column in mapper.attrs: 27 | field = column.key 28 | attr = getattr(self, field) 29 | if isinstance(column, ColumnProperty): 30 | formatted[field] = attr 31 | elif include_relationships: 32 | formatted[field] = [obj.to_dict() for obj in attr] 33 | return formatted 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Notion ticket link 2 | 3 | 4 | 5 | [Ticket Name](https://www.notion.so/uwblueprintexecs/Task-Board-db95cd7b93f245f78ee85e3a8a6a316d) 6 | 7 | 8 | 9 | ## Implementation description 10 | 11 | - 12 | 13 | 14 | 15 | ## Steps to test 16 | 17 | 1. 18 | 19 | 20 | 21 | ## What should reviewers focus on? 22 | 23 | - 24 | 25 | ## Checklist 26 | 27 | - [ ] My PR name is descriptive and in imperative tense 28 | - [ ] My commit messages are descriptive and in imperative tense. My commits are atomic and trivial commits are squashed or fixup'd into non-trivial commits 29 | - [ ] For backend changes, I have run the appropriate linters: `docker exec -it planet-read_py-backend_1 /bin/bash -c "black . && isort --profile black ."` and I have generated new migrations: `flask db migrate -m ""` 30 | - [ ] I have requested a review from the PL, as well as other devs who have background knowledge on this PR or who will be building on top of this PR 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | frontend: 5 | build: 6 | context: ./frontend 7 | dockerfile: Dockerfile 8 | volumes: 9 | - ./frontend:/app 10 | - /app/node_modules 11 | ports: 12 | - 3000:3000 13 | environment: 14 | - CHOKIDAR_USEPOLLING=true 15 | env_file: 16 | - ./.env 17 | py-backend: 18 | restart: always 19 | build: 20 | context: ./backend/python 21 | dockerfile: Dockerfile 22 | ports: 23 | - 5000:5000 24 | dns: 25 | - 8.8.8.8 26 | volumes: 27 | - ./backend/python:/app 28 | - file_uploads:/var/lib/file_uploads 29 | depends_on: 30 | - db 31 | env_file: 32 | - ./.env 33 | db: 34 | image: mysql:8.0.25 35 | command: --default-authentication-plugin=mysql_native_password --innodb-use-native-aio=0 36 | ports: 37 | - 3306:3306 38 | volumes: 39 | - mysql_data:/var/lib/mysql/ 40 | env_file: 41 | - ./.env 42 | test-db: 43 | image: mysql:8.0.25 44 | command: --default-authentication-plugin=mysql_native_password --innodb-use-native-aio=0 45 | ports: 46 | - 3307:3306 47 | volumes: 48 | - mysql_data:/var/lib/mysql_test/ 49 | env_file: 50 | - ./.env 51 | 52 | volumes: 53 | mysql_data: 54 | mysql_test_data: 55 | file_uploads: 56 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/e59c40e297d1_differentiate_approved_languages_.py: -------------------------------------------------------------------------------- 1 | """differentiate approved languages translation and review 2 | 3 | Revision ID: e59c40e297d1 4 | Revises: c29196f789b5 5 | Create Date: 2021-09-11 20:04:28.013850 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "e59c40e297d1" 14 | down_revision = "c29196f789b5" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column( 22 | "users", 23 | "approved_languages", 24 | existing_type=sa.JSON(), 25 | new_column_name="approved_languages_translation", 26 | ) 27 | op.add_column( 28 | "users", sa.Column("approved_languages_review", sa.JSON(), nullable=True) 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_column("users", "approved_languages_review") 36 | op.alter_column( 37 | "users", 38 | "approved_languages_translation", 39 | existing_type=sa.JSON(), 40 | new_column_name="approved_languages", 41 | ) 42 | # ### end Alembic commands ### 43 | -------------------------------------------------------------------------------- /frontend/src/components/admin/EmptyTable.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Button, Flex, Heading, Text, Tr, Td } from "@chakra-ui/react"; 4 | 5 | export type EmptyTableProps = { 6 | filters: { (data: string | null): void }[]; 7 | }; 8 | 9 | const EmptyTable = ({ filters }: EmptyTableProps) => { 10 | const clearFilters = () => { 11 | filters.forEach((setFilter) => { 12 | setFilter(null); 13 | }); 14 | }; 15 | return ( 16 | 17 | 18 | 25 | 31 | 32 | We can't seem to find a match :( 33 | 34 | Would you like to clear up some filters? 35 | 36 | Clear Filters 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default EmptyTable; 46 | -------------------------------------------------------------------------------- /frontend/src/APIClients/mutations/CommentMutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const CREATE_COMMMENT = gql` 4 | mutation CreateComment($commentData: CreateCommentDTO!) { 5 | createComment(commentData: $commentData) { 6 | ok 7 | comment { 8 | id 9 | userId 10 | commentIndex 11 | time 12 | resolved 13 | content 14 | lineIndex 15 | storyTranslationContentId 16 | } 17 | } 18 | } 19 | `; 20 | 21 | export type CreateCommentResponse = { 22 | ok: boolean; 23 | comment: { 24 | id: number; 25 | userId: number; 26 | commentIndex: number; 27 | time: string; 28 | resolved: boolean; 29 | content: string; 30 | lineIndex: number; 31 | storyTranslationContentId: number; 32 | }; 33 | }; 34 | 35 | export const UPDATE_COMMENT_BY_ID = gql` 36 | mutation UpdateCommentById($commentData: UpdateCommentRequestDTO!) { 37 | updateCommentById(commentData: $commentData) { 38 | ok 39 | comment { 40 | id 41 | commentIndex 42 | resolved 43 | content 44 | } 45 | } 46 | } 47 | `; 48 | 49 | export type UpdateCommentResponse = { 50 | ok: boolean; 51 | comment: { 52 | id: number; 53 | commentIndex: number; 54 | resolved: boolean; 55 | content: string; 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /backend/python/app/graphql/view.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from graphene_file_upload.flask import FileUploadGraphQLView 5 | 6 | 7 | class ResponseCookieGraphQLView(FileUploadGraphQLView): 8 | """ 9 | Sets response cookie for refresh mutation requests 10 | """ 11 | 12 | def dispatch_request(self): 13 | response = super(ResponseCookieGraphQLView, self).dispatch_request() 14 | data = self.parse_body() 15 | 16 | # The following mutations MUST request refreshToken 17 | refresh_token_mutations = ["Refresh", "Login", "SignUp"] 18 | if ( 19 | "operationName" in data.keys() 20 | and data["operationName"] in refresh_token_mutations 21 | ): 22 | # get refreshToken from response 23 | response_json = json.loads(response.response[0])["data"] 24 | mutation_name = data["operationName"].lower() 25 | if response_json[mutation_name]: 26 | refresh_token = response_json[mutation_name]["refreshToken"] 27 | 28 | # set response cookie 29 | response.set_cookie( 30 | "refreshToken", 31 | value=refresh_token, 32 | httponly=True, 33 | secure=(os.getenv("FLASK_CONFIG") == "production"), 34 | ) 35 | 36 | return response 37 | -------------------------------------------------------------------------------- /frontend/src/APIClients/queries/CommentQueries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | import { QueryInformation } from "./StoryQueries"; 3 | 4 | const COMMENT_FIELDS = ` 5 | id 6 | userId 7 | commentIndex 8 | content 9 | storyTranslationContentId 10 | resolved 11 | time 12 | lineIndex 13 | `; 14 | 15 | export type CommentResponse = { 16 | id: number; 17 | userId: number; 18 | commentIndex: number; 19 | time: string; 20 | resolved: boolean; 21 | content: string; 22 | lineIndex: number; 23 | storyTranslationContentId: number; 24 | }; 25 | 26 | export const buildCommentsQuery = ( 27 | storyTranslationId: number, 28 | resolved: boolean | null, 29 | ): QueryInformation => { 30 | let queryString = gql` 31 | query CommentsByStoryTranslation { 32 | commentsByStoryTranslation( storyTranslationId: ${storyTranslationId} ){ 33 | ${COMMENT_FIELDS} 34 | } 35 | } 36 | `; 37 | 38 | if (resolved === true || resolved === false) { 39 | queryString = gql` 40 | query CommentsByStoryTranslation { 41 | commentsByStoryTranslation( storyTranslationId: ${storyTranslationId}, resolved: ${resolved} ){ 42 | ${COMMENT_FIELDS} 43 | } 44 | } 45 | `; 46 | } 47 | 48 | return { 49 | fieldName: "commentsByStoryTranslation", 50 | string: queryString, 51 | } as QueryInformation; 52 | }; 53 | -------------------------------------------------------------------------------- /backend/python/app/services/interfaces/comment_service.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class ICommentService(ABC): 5 | """ 6 | A class to handle CRUD functionality for comments 7 | """ 8 | 9 | @abstractmethod 10 | def create_comment(self, comment): 11 | """Create a new Comment object 12 | 13 | :param comment: dictionary of comment fields 14 | :return: dictionary of Comment object 15 | :rtype: dictionary 16 | :raises Exception: if comment fields are invalid 17 | """ 18 | pass 19 | 20 | @abstractmethod 21 | def get_comments_by_story_translation(self, story_translation_id): 22 | """Return a list of Comments from a given StoryTranslation 23 | 24 | :param story_translation_id: story translation id 25 | :return: list of comments 26 | :rtype: list of CommentResponseDTO 27 | :raises Exception: if id of comment to be updated does not exist 28 | """ 29 | pass 30 | 31 | @abstractmethod 32 | def update_comment(self, updated_comment): 33 | """Update an existing Comment object 34 | 35 | :param updated_comment: UpdateCommentRequestDTO 36 | :return: updated comment object 37 | :rtype: CommentResponseDTO 38 | :raises Exception: if id of comment to be updated does not exist 39 | """ 40 | pass 41 | -------------------------------------------------------------------------------- /frontend/src/utils/StageUtils.ts: -------------------------------------------------------------------------------- 1 | import { StoryTranslation } from "../APIClients/queries/StoryQueries"; 2 | import { convertStringTitleCase } from "./Utils"; 3 | 4 | export const convertStageTitleCase = (stage: string) => { 5 | switch (stage) { 6 | case "TRANSLATE": 7 | return "In Translation"; 8 | case "REVIEW": 9 | return "In Review"; 10 | case "PUBLISH": 11 | return "Completed"; 12 | default: 13 | return convertStringTitleCase(stage); 14 | } 15 | }; 16 | 17 | export const convertTitleCaseToStage = (stage: string) => { 18 | switch (stage) { 19 | case "In Translation": 20 | return "TRANSLATE"; 21 | case "In Review": 22 | return "REVIEW"; 23 | case "Completed": 24 | return "PUBLISH"; 25 | default: 26 | return stage; 27 | } 28 | }; 29 | 30 | export const getStoryTranslationProgress = (translation: StoryTranslation) => { 31 | const stage = convertStageTitleCase(translation.stage); 32 | if (translation.stage === "PUBLISH") { 33 | return stage; 34 | } 35 | 36 | const numProgressLines = 37 | translation.stage === "TRANSLATE" 38 | ? translation.numTranslatedLines 39 | : translation.numApprovedLines; 40 | 41 | const progress = (numProgressLines / translation.numContentLines) * 100; 42 | const percentage = Math.round(Number.isNaN(progress) ? 0 : progress); 43 | return `${stage} (${percentage}%)`; 44 | }; 45 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/91e8d22078f4_add_additional_experiences_column_to_.py: -------------------------------------------------------------------------------- 1 | """add additional_experiences column to user 2 | 3 | Revision ID: 91e8d22078f4 4 | Revises: 92ace1398b9f 5 | Create Date: 2021-12-01 03:38:15.575681 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "91e8d22078f4" 13 | down_revision = "92ace1398b9f" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "users_all", sa.Column("additional_experiences", sa.JSON(), nullable=True) 22 | ) 23 | op.execute("DROP VIEW users") 24 | active_users_view = """ 25 | CREATE VIEW users 26 | AS 27 | SELECT * from users_all 28 | WHERE is_deleted=0; 29 | """ 30 | op.execute(active_users_view) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.execute("DROP VIEW users") 37 | op.drop_column("users_all", "additional_experiences") 38 | active_users_view = """ 39 | CREATE VIEW users 40 | AS 41 | SELECT * from users_all 42 | WHERE is_deleted=0; 43 | """ 44 | op.execute(active_users_view) 45 | # ### end Alembic commands ### 46 | -------------------------------------------------------------------------------- /frontend/src/components/translation/EditableCell.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | 3 | import { Textarea } from "@chakra-ui/react"; 4 | 5 | export type EditableCellProps = { 6 | text: string; 7 | storyTranslationContentId: number; 8 | lineIndex: number; 9 | fontSize: string; 10 | maxChars: number; 11 | onChange: (newContent: string, lineIndex: number, maxChars: number) => void; 12 | isRtl?: boolean; 13 | }; 14 | 15 | const EditableCell = ({ 16 | text, 17 | lineIndex, 18 | fontSize, 19 | maxChars, 20 | onChange, 21 | isRtl = false, 22 | }: EditableCellProps) => { 23 | const textareaRef = useRef(null); 24 | const [height, setHeight] = useState("auto"); 25 | 26 | useEffect(() => { 27 | const { scrollHeight, clientHeight } = textareaRef.current; 28 | if (scrollHeight > clientHeight) setHeight(`${scrollHeight}px`); 29 | else setHeight("auto"); 30 | }); 31 | 32 | return ( 33 | onChange(event.target.value, lineIndex, maxChars)} 45 | /> 46 | ); 47 | }; 48 | 49 | export default EditableCell; 50 | -------------------------------------------------------------------------------- /frontend/src/components/utils/Paginator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Icon } from "@chakra-ui/icon"; 3 | import { MdChevronLeft, MdChevronRight } from "react-icons/md"; 4 | import { Flex, IconButton, Text } from "@chakra-ui/react"; 5 | 6 | export type PaginatorProps = { 7 | first: number; 8 | last: number; 9 | total: number; 10 | hasPreviousPage: boolean; 11 | hasNextPage: boolean; 12 | onPreviousPage: () => void; 13 | onNextPage: () => void; 14 | }; 15 | 16 | const Paginator = ({ 17 | first, 18 | last, 19 | total, 20 | hasPreviousPage, 21 | hasNextPage, 22 | onPreviousPage, 23 | onNextPage, 24 | }: PaginatorProps) => { 25 | return ( 26 | 27 | 28 | 29 | {first}-{last} 30 | {" "} 31 | of {total} 32 | 33 | } 38 | width="fit-content" 39 | isDisabled={!hasPreviousPage} 40 | /> 41 | } 46 | width="fit-content" 47 | isDisabled={!hasNextPage} 48 | /> 49 | 50 | ); 51 | }; 52 | 53 | export default Paginator; 54 | -------------------------------------------------------------------------------- /frontend/src/components/utils/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Progress, Text } from "@chakra-ui/react"; 3 | import { truncate } from "../../utils/Utils"; 4 | 5 | type ProgressBarProps = { 6 | percentageComplete: number; 7 | type: string; 8 | fontSize: string; 9 | }; 10 | 11 | const ProgressBar = ({ 12 | percentageComplete, 13 | type, 14 | fontSize, 15 | }: ProgressBarProps) => { 16 | let progressFontSize; 17 | let progressWidth; 18 | let progressMargin; 19 | switch (fontSize) { 20 | case "12px": 21 | progressFontSize = "10px"; 22 | progressWidth = "130px"; 23 | progressMargin = "10px"; 24 | break; 25 | case "16px": 26 | progressFontSize = "12px"; 27 | progressWidth = "155px"; 28 | progressMargin = "15px"; 29 | break; 30 | case "24px": 31 | progressFontSize = "16px"; 32 | progressWidth = "210px"; 33 | progressMargin = "20px"; 34 | break; 35 | default: 36 | break; 37 | } 38 | 39 | return ( 40 | 41 | 42 | {type} Progress: {truncate(percentageComplete, 2)}% 43 | 44 | 45 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default ProgressBar; 57 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Badge.ts: -------------------------------------------------------------------------------- 1 | const Badge = { 2 | baseStyle: { 3 | borderRadius: "24px", 4 | fontSize: "14px", 5 | padding: "6px 10px", 6 | margin: "0 5px", 7 | textTransform: "capitalize", 8 | textAlign: "center", 9 | }, 10 | variants: { 11 | pending: { 12 | backgroundColor: "gray.100", 13 | color: "gray.400", 14 | }, 15 | action: { 16 | backgroundColor: "orange.50", 17 | color: "orange.100", 18 | }, 19 | approved: { 20 | backgroundColor: "green.50", 21 | color: "green.100", 22 | }, 23 | correct: { 24 | backgroundColor: "green.50", 25 | color: "green.100", 26 | }, 27 | partiallyCorrect: { 28 | backgroundColor: "orange.50", 29 | color: "orange.100", 30 | }, 31 | incorrect: { 32 | backgroundColor: "red.50", 33 | color: "red.100", 34 | }, 35 | stage: { 36 | backgroundColor: "blue.50", 37 | }, 38 | role: { 39 | backgroundColor: "blue.50", 40 | }, 41 | language: { 42 | backgroundColor: "gray.200", 43 | }, 44 | filter: { 45 | backgroundColor: "none", 46 | borderRadius: "8px", 47 | border: "1px solid", 48 | borderColor: "gray.200", 49 | color: "blue.100", 50 | marginLeft: "0", 51 | marginRight: "10px", 52 | }, 53 | }, 54 | sizes: { 55 | s: { 56 | fontSize: "xs", 57 | marginLeft: "0px", 58 | marginRight: "15px", 59 | }, 60 | }, 61 | }; 62 | export default Badge; 63 | -------------------------------------------------------------------------------- /frontend/src/components/translation/FontSizeSlider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Flex, 5 | Slider, 6 | SliderTrack, 7 | SliderFilledTrack, 8 | SliderThumb, 9 | } from "@chakra-ui/react"; 10 | 11 | type FontSizeSliderProps = { 12 | setFontSize: (val: string) => void; 13 | }; 14 | 15 | const FontSizeSlider = ({ setFontSize }: FontSizeSliderProps) => { 16 | const handleSliderChange = (val: number) => { 17 | switch (val) { 18 | case 0: 19 | setFontSize("12px"); 20 | break; 21 | case 1: 22 | setFontSize("16px"); 23 | break; 24 | case 2: 25 | setFontSize("24px"); 26 | break; 27 | default: 28 | setFontSize("12px"); 29 | break; 30 | } 31 | }; 32 | 33 | return ( 34 | 35 | 36 | Aa 37 | Aa 38 | Aa 39 | 40 | handleSliderChange(val)} 47 | > 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default FontSizeSlider; 59 | -------------------------------------------------------------------------------- /backend/python/app/models/story_translation_content_all.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, Enum, inspect 2 | from sqlalchemy.dialects.mysql import LONGTEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | 5 | from . import db 6 | from .story_translation_content_status import StoryTranslationContentStatus 7 | 8 | 9 | class StoryTranslationContentAll(db.Model): 10 | __tablename__ = "story_translation_contents_all" 11 | id = db.Column(db.Integer, primary_key=True, nullable=False) 12 | story_translation_id = db.Column( 13 | db.Integer, 14 | db.ForeignKey("story_translations_all.id"), 15 | index=True, 16 | nullable=False, 17 | ) 18 | line_index = db.Column(db.Integer, nullable=False) 19 | status = db.Column( 20 | Enum(StoryTranslationContentStatus), 21 | server_default=StoryTranslationContentStatus.DEFAULT.value, 22 | nullable=False, 23 | ) 24 | translation_content = db.Column(LONGTEXT, nullable=False) 25 | is_deleted = db.Column(Boolean, default=False, nullable=False) 26 | 27 | def to_dict(self, include_relationships=False): 28 | cls = type(self) 29 | mapper = inspect(cls) 30 | formatted = {} 31 | for column in mapper.attrs: 32 | field = column.key 33 | attr = getattr(self, field) 34 | if isinstance(column, ColumnProperty): 35 | formatted[field] = attr 36 | elif include_relationships: 37 | formatted[field] = [obj.to_dict() for obj in attr] 38 | return formatted 39 | -------------------------------------------------------------------------------- /frontend/src/utils/StatusUtils.ts: -------------------------------------------------------------------------------- 1 | // Change status mysql enum to title case 2 | function convertStatusTitleCase(status: string) { 3 | const statusEnum = status.replace("StoryTranslationContentStatus.", ""); 4 | switch (statusEnum) { 5 | case "DEFAULT": 6 | return "Pending"; 7 | case "APPROVED": 8 | return "Approved"; 9 | case "ACTION_REQUIRED": 10 | return "Action"; 11 | case "TEST_CORRECT": 12 | return "Correct"; 13 | case "TEST_INCORRECT": 14 | return "Incorrect"; 15 | case "TEST_PARTIALLY_CORRECT": 16 | return "Partially Correct"; 17 | default: 18 | return statusEnum.replace(/_/g, " "); 19 | } 20 | } 21 | 22 | function getStatusVariant(status: string | undefined) { 23 | switch (status) { 24 | case "Pending": 25 | return "pending"; 26 | case "Approved": 27 | return "approved"; 28 | case "Action": 29 | return "action"; 30 | case "Correct": 31 | return "correct"; 32 | case "Partially Correct": 33 | return "partiallyCorrect"; 34 | case "Incorrect": 35 | return "incorrect"; 36 | default: 37 | return status?.toLowerCase(); 38 | } 39 | } 40 | 41 | const getLevelVariant = (level: number) => { 42 | switch (level) { 43 | case 1: 44 | return "green.50"; 45 | case 2: 46 | return "red.50"; 47 | case 3: 48 | return "purple.50"; 49 | case 4: 50 | return "orange.50"; 51 | default: 52 | return "green.50"; 53 | } 54 | }; 55 | 56 | export { convertStatusTitleCase, getStatusVariant, getLevelVariant }; 57 | -------------------------------------------------------------------------------- /frontend/src/APIClients/queries/UserQueries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | const USER_FIELDS = ` 4 | id 5 | firstName 6 | lastName 7 | role 8 | email 9 | resume 10 | profilePic 11 | approvedLanguagesTranslation 12 | approvedLanguagesReview 13 | `; 14 | 15 | export type User = { 16 | id: string; 17 | firstName: string; 18 | lastName: string; 19 | email: string; 20 | resume: number; 21 | approvedLanguagesTranslation: string; 22 | approvedLanguagesReview: string; 23 | additionalExperiences: string; 24 | } | null; 25 | 26 | export type AdditionalExperiences = { 27 | languageExperience: string; 28 | educationalQualification: string; 29 | }; 30 | 31 | export const GET_USER = (id: number) => 32 | gql` 33 | query GetUser { 34 | userById( 35 | id: ${id} 36 | ) { 37 | ${USER_FIELDS} 38 | additionalExperiences 39 | } 40 | } 41 | `; 42 | 43 | export const buildUsersQuery = ( 44 | isTranslators: boolean, 45 | language?: string, 46 | level?: number, 47 | nameOrEmail?: string, 48 | ) => { 49 | let queryParams = language ? `language: "${language}", ` : ""; 50 | queryParams += level ? `level: ${level}, ` : ""; 51 | queryParams += nameOrEmail ? `nameOrEmail: "${nameOrEmail}", ` : ""; 52 | 53 | const queryString = gql` 54 | query Users { 55 | users( isTranslators: ${isTranslators} ${queryParams} ){ 56 | ${USER_FIELDS} 57 | } 58 | } 59 | `; 60 | 61 | return { 62 | fieldName: "users", 63 | string: queryString, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /backend/python/app/models/comment_all.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, inspect 2 | from sqlalchemy.dialects.mysql import DATETIME, LONGTEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | 5 | from . import db 6 | from .story_translation_content_all import StoryTranslationContentAll 7 | 8 | 9 | class CommentAll(db.Model): 10 | 11 | __tablename__ = "comments_all" 12 | 13 | id = db.Column(db.Integer, primary_key=True, nullable=False) 14 | story_translation_content_id = db.Column( 15 | db.Integer, 16 | db.ForeignKey("story_translation_contents_all.id"), 17 | index=True, 18 | nullable=False, 19 | ) 20 | story_translation_content = db.relationship(StoryTranslationContentAll) 21 | user_id = db.Column(db.Integer, db.ForeignKey("users_all.id"), nullable=False) 22 | comment_index = db.Column(db.Integer, nullable=False) 23 | time = db.Column(DATETIME, nullable=False) 24 | resolved = db.Column(db.Boolean, nullable=False) 25 | content = db.Column(LONGTEXT, nullable=False) 26 | is_deleted = db.Column(Boolean, default=False, nullable=False) 27 | 28 | def to_dict(self, include_relationships=False): 29 | cls = type(self) 30 | mapper = inspect(cls) 31 | formatted = {} 32 | for column in mapper.attrs: 33 | field = column.key 34 | attr = getattr(self, field) 35 | if isinstance(column, ColumnProperty): 36 | formatted[field] = attr 37 | elif include_relationships: 38 | formatted[field] = [obj.to_dict() for obj in attr] 39 | return formatted 40 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/04407ca2b4ef_add_activity_time_columns.py: -------------------------------------------------------------------------------- 1 | """Add activity time columns 2 | 3 | Revision ID: 04407ca2b4ef 4 | Revises: 04653332ed3a 5 | Create Date: 2021-12-20 00:13:17.086241 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "04407ca2b4ef" 13 | down_revision = "04653332ed3a" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.add_column( 21 | "story_translations_all", 22 | sa.Column("reviewer_last_activity", sa.DateTime(), nullable=True), 23 | ) 24 | op.add_column( 25 | "story_translations_all", 26 | sa.Column("translator_last_activity", sa.DateTime(), nullable=True), 27 | ) 28 | active_translations_view = """ 29 | CREATE OR REPLACE VIEW story_translations 30 | AS 31 | SELECT * from story_translations_all 32 | WHERE is_deleted=0; 33 | """ 34 | op.execute(active_translations_view) 35 | # ### end Alembic commands ### 36 | 37 | 38 | def downgrade(): 39 | # ### commands auto generated by Alembic - please adjust! ### 40 | op.drop_column("story_translations_all", "translator_last_activity") 41 | op.drop_column("story_translations_all", "reviewer_last_activity") 42 | active_translations_view = """ 43 | CREATE OR REPLACE VIEW story_translations 44 | AS 45 | SELECT * from story_translations_all 46 | WHERE is_deleted=0; 47 | """ 48 | op.execute(active_translations_view) 49 | # ### end Alembic commands ### 50 | -------------------------------------------------------------------------------- /backend/python/requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.5.2 2 | appdirs==1.4.4 3 | black==20.8b1 4 | CacheControl==0.12.6 5 | cachetools==4.2.1 6 | certifi==2020.12.5 7 | cffi==1.14.5 8 | chardet==4.0.0 9 | click==7.1.2 10 | dnspython==1.16.0 11 | firebase-admin==4.5.3 12 | Flask==1.1.2 13 | Flask-Cors==3.0.10 14 | Flask-GraphQL==2.0.1 15 | Flask-Migrate==3.0.1 16 | Flask-MySQL==1.5.2 17 | Flask-SQLAlchemy==2.4.4 18 | graphene==2.1.8 19 | graphene-file-upload==1.3.0 20 | google-api-core==1.26.3 21 | google-api-python-client==2.2.0 22 | google-auth==1.29.0 23 | google-auth-httplib2==0.1.0 24 | googleapis-common-protos==1.53.0 25 | httplib2==0.19.1 26 | google-cloud-core==1.6.0 27 | google-cloud-firestore==2.1.0 28 | google-cloud-storage==1.37.1 29 | google-crc32c==1.1.2 30 | google-resumable-media==1.2.0 31 | googleapis-common-protos==1.53.0 32 | grpcio==1.37.0 33 | httplib2==0.19.1 34 | idna==2.10 35 | isort==5.8.0 36 | itsdangerous==1.1.0 37 | Jinja2==2.11.3 38 | Mako==1.1.4 39 | MarkupSafe==1.1.1 40 | mysqlclient==2.0.3 41 | msgpack==1.0.2 42 | mypy-extensions==0.4.3 43 | packaging==20.9 44 | pathspec==0.8.1 45 | proto-plus==1.18.1 46 | protobuf==3.15.8 47 | psycopg2==2.8.6 48 | pyasn1==0.4.8 49 | pyasn1-modules==0.2.8 50 | pycparser==2.20 51 | pyparsing==2.4.7 52 | pytest==6.2.4 53 | python-dateutil==2.8.1 54 | python-docx==0.8.11 55 | python-dotenv==0.15.0 56 | python-editor==1.0.4 57 | pytz==2021.1 58 | regex==2020.11.13 59 | requests==2.25.1 60 | rsa==4.7.2 61 | six==1.15.0 62 | SQLAlchemy==1.3.22 63 | SQLAlchemy-Enum34==2.0.0 64 | SQLAlchemy-Utils==0.37.8 65 | toml==0.10.2 66 | typed-ast==1.4.2 67 | typing-extensions==3.7.4.3 68 | uritemplate==3.0.1 69 | urllib3==1.26.5 70 | Werkzeug==1.0.1 71 | -------------------------------------------------------------------------------- /backend/python/app/models/story_all.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import Boolean, DateTime, inspect 4 | from sqlalchemy.dialects.mysql import LONGTEXT, TEXT 5 | from sqlalchemy.orm.properties import ColumnProperty 6 | 7 | from . import db 8 | from .story_content_all import StoryContentAll 9 | 10 | 11 | class StoryAll(db.Model): 12 | 13 | __tablename__ = "stories_all" 14 | 15 | id = db.Column(db.Integer, primary_key=True, nullable=False) 16 | title = db.Column(TEXT, nullable=False) 17 | description = db.Column(LONGTEXT, nullable=False) 18 | youtube_link = db.Column(TEXT, nullable=False) 19 | level = db.Column(db.Integer, nullable=False) 20 | # Note: translated_languages should be an enum array, but postgres 21 | # has a weird relationship with enums and we're going to switch to 22 | # mysql anyways. 23 | translated_languages = db.Column(db.JSON) 24 | contents = db.relationship(StoryContentAll) 25 | is_test = db.Column(Boolean, default=False, nullable=False) 26 | date_uploaded = db.Column(DateTime, default=datetime.utcnow(), nullable=False) 27 | is_deleted = db.Column(Boolean, default=False, nullable=False) 28 | 29 | def to_dict(self, include_relationships=False): 30 | cls = type(self) 31 | mapper = inspect(cls) 32 | formatted = {} 33 | for column in mapper.attrs: 34 | field = column.key 35 | attr = getattr(self, field) 36 | if isinstance(column, ColumnProperty): 37 | formatted[field] = attr 38 | elif include_relationships: 39 | formatted[field] = [obj.to_dict() for obj in attr] 40 | return formatted 41 | -------------------------------------------------------------------------------- /backend/python/app/services/implementations/language_service.py: -------------------------------------------------------------------------------- 1 | from flask import current_app 2 | 3 | from ...models import db 4 | from ...models.language import Language 5 | from ..interfaces.language_service import ILanguageService 6 | from .utils import handle_exceptions 7 | 8 | 9 | class LanguageService(ILanguageService): 10 | def __init__(self, logger=current_app.logger): 11 | self.logger = logger 12 | 13 | @handle_exceptions 14 | def get_languages(self): 15 | try: 16 | languages = Language.query.order_by(Language.language.asc()).all() 17 | return [language.language for language in languages] 18 | except Exception as error: 19 | self.logger.error(error) 20 | raise error 21 | 22 | @handle_exceptions 23 | def add_language(self, language, is_rtl): 24 | try: 25 | new_language = Language(language=language, is_rtl=is_rtl) 26 | db.session.add(new_language) 27 | db.session.commit() 28 | return new_language 29 | except Exception as error: 30 | self.logger.error(str(error)) 31 | raise error 32 | 33 | @handle_exceptions 34 | def get_is_rtl(self, language): 35 | try: 36 | to_check = Language.query.filter_by(language=language).first() 37 | 38 | if not to_check: 39 | raise Exception( 40 | "Language {language} not found".format(language=language) 41 | ) 42 | else: 43 | return {"is_rtl": to_check.is_rtl} 44 | except Exception as error: 45 | self.logger.error(str(error)) 46 | raise error 47 | -------------------------------------------------------------------------------- /backend/python/app/graphql/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import current_app 4 | 5 | from ..models import db 6 | from ..services.implementations.auth_service import AuthService 7 | from ..services.implementations.comment_service import CommentService 8 | from ..services.implementations.email_service import EmailService 9 | from ..services.implementations.file_service import FileService 10 | from ..services.implementations.story_service import StoryService 11 | from ..services.implementations.user_service import UserService 12 | from .schema import schema 13 | from .service import services 14 | from .view import ResponseCookieGraphQLView 15 | 16 | 17 | def init_app(app): 18 | app.add_url_rule( 19 | "/graphql", 20 | view_func=ResponseCookieGraphQLView.as_view( 21 | "graphql", 22 | schema=schema, 23 | graphiql=os.getenv("FLASK_CONFIG") == "development", 24 | context={"session": db.session}, 25 | ), 26 | ) 27 | 28 | services["comment"] = CommentService(current_app.logger) 29 | services["file"] = FileService(current_app.logger) 30 | services["story"] = StoryService(current_app.logger) 31 | services["user"] = UserService(current_app.logger) 32 | 33 | email_service = EmailService( 34 | current_app.logger, 35 | { 36 | "refresh_token": os.getenv("EMAIL_REFRESH_TOKEN"), 37 | "token_uri": "https://oauth2.googleapis.com/token", 38 | "client_id": os.getenv("EMAIL_CLIENT_ID"), 39 | "client_secret": os.getenv("EMAIL_CLIENT_SECRET"), 40 | }, 41 | os.getenv("EMAIL_USER"), 42 | "Add My Language", 43 | ) 44 | services["auth"] = AuthService(current_app.logger, services["user"], email_service) 45 | -------------------------------------------------------------------------------- /backend/python/app/tests/helpers/story_helpers.py: -------------------------------------------------------------------------------- 1 | from ...models.story import Story 2 | from ...models.story_translation_all import StoryTranslationAll 3 | from ...models.user_all import UserAll 4 | from .db_helpers import db_session_add_commit_obj 5 | 6 | 7 | class StoryRequestDTO: 8 | def __init__( 9 | self, 10 | title, 11 | description, 12 | youtube_link, 13 | level, 14 | translated_languages, 15 | ): 16 | self.title = title 17 | self.description = description 18 | self.youtube_link = youtube_link 19 | self.level = level 20 | self.translated_languages = translated_languages 21 | 22 | 23 | def assert_story_equals_model(story_response, story_model, graphql_response=True): 24 | assert story_response["title"] == story_model.title 25 | assert story_response["description"] == story_model.description 26 | assert story_response["level"] == story_model.level 27 | 28 | if graphql_response: 29 | assert story_response["youtubeLink"] == story_model.youtube_link 30 | assert story_response["translatedLanguages"] == story_model.translated_languages 31 | else: 32 | assert story_response["youtube_link"] == story_model.youtube_link 33 | assert ( 34 | story_response["translated_languages"] == story_model.translated_languages 35 | ) 36 | 37 | 38 | def guarantee_story_table_not_empty(db): 39 | if Story.query.count() == 0: 40 | obj = Story( 41 | title="East of Eden", 42 | description="Follow the intertwined destinies of two families whose generations reenact the poisonous rivalry of Cain and Abel.", 43 | youtube_link="https://www.youtube.com/watch?v=redECmF7wh8", 44 | level=4, 45 | ) 46 | db.session.add(obj) 47 | assert db.session.commit() == None 48 | -------------------------------------------------------------------------------- /frontend/src/APIClients/mutations/AuthMutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const LOGIN = gql` 4 | mutation Login($email: String!, $password: String!) { 5 | login(email: $email, password: $password) { 6 | id 7 | firstName 8 | lastName 9 | email 10 | role 11 | approvedLanguagesTranslation 12 | approvedLanguagesReview 13 | accessToken 14 | refreshToken 15 | } 16 | } 17 | `; 18 | 19 | export const LOGIN_WITH_GOOGLE = gql` 20 | mutation LoginWithGoogle($tokenId: String!) { 21 | loginWithGoogle(tokenId: $tokenId) { 22 | id 23 | firstName 24 | lastName 25 | email 26 | role 27 | approvedLanguagesTranslation 28 | approvedLanguagesReview 29 | accessToken 30 | refreshToken 31 | } 32 | } 33 | `; 34 | 35 | export const REFRESH = gql` 36 | mutation Refresh { 37 | refresh { 38 | accessToken 39 | refreshToken 40 | ok 41 | } 42 | } 43 | `; 44 | 45 | export interface RefreshResponse { 46 | accessToken: string; 47 | refreshToken: string; 48 | ok: boolean; 49 | } 50 | 51 | export const RESET_PASSWORD = gql` 52 | mutation ResetPassword($email: String!) { 53 | resetPassword(email: $email) { 54 | ok 55 | } 56 | } 57 | `; 58 | 59 | export interface ResetPasswordResponse { 60 | ok: boolean; 61 | } 62 | 63 | export const SIGNUP = gql` 64 | mutation SignUp( 65 | $firstName: String! 66 | $lastName: String! 67 | $email: String! 68 | $password: String! 69 | ) { 70 | signup( 71 | firstName: $firstName 72 | lastName: $lastName 73 | email: $email 74 | password: $password 75 | ) { 76 | id 77 | firstName 78 | lastName 79 | email 80 | role 81 | accessToken 82 | refreshToken 83 | } 84 | } 85 | `; 86 | -------------------------------------------------------------------------------- /backend/python/app/resources/create_user_dto.py: -------------------------------------------------------------------------------- 1 | class CreateUserWithEmailDTO: 2 | def __init__( 3 | self, 4 | first_name, 5 | last_name, 6 | email, 7 | role, 8 | password, 9 | resume=None, 10 | profile_pic=None, 11 | approved_languages_translation=None, 12 | approved_languages_review=None, 13 | additional_experiences=None, 14 | ): 15 | self.first_name = first_name 16 | self.last_name = last_name 17 | self.email = email 18 | self.role = role 19 | self.password = password 20 | self.resume = resume 21 | self.profile_pic = profile_pic 22 | self.approved_languages_translation = approved_languages_translation 23 | self.approved_languages_review = approved_languages_review 24 | self.additional_experiences = additional_experiences 25 | self.sign_up_method = "PASSWORD" 26 | 27 | 28 | class CreateUserWithGoogleDTO: 29 | def __init__( 30 | self, 31 | first_name, 32 | last_name, 33 | role, 34 | email, 35 | auth_id, 36 | on_firebase, 37 | resume=None, 38 | profile_pic=None, 39 | approved_languages_translation=None, 40 | approved_languages_review=None, 41 | additional_experiences=None, 42 | ): 43 | self.first_name = first_name 44 | self.last_name = last_name 45 | self.role = role 46 | self.email = email 47 | self.auth_id = auth_id 48 | self.resume = resume 49 | self.profile_pic = profile_pic 50 | self.approved_languages_translation = approved_languages_translation 51 | self.approved_languages_review = approved_languages_review 52 | self.additional_experiences = additional_experiences 53 | self.on_firebase = on_firebase 54 | self.sign_up_method = "GOOGLE" 55 | -------------------------------------------------------------------------------- /frontend/src/components/pages/AdminPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header, { AdminPageOption } from "../navigation/Header"; 3 | import ManageStoryTranslations from "../admin/ManageStoryTranslations"; 4 | import ManageStories from "../admin/ManageStories"; 5 | import ManageUsers from "../admin/ManageUsers"; 6 | import ManageStoryTests from "../admin/ManageStoryTests"; 7 | import useQueryParams from "../../utils/hooks/useQueryParams"; 8 | 9 | const AdminPage = () => { 10 | const queryParams = useQueryParams(); 11 | 12 | const getPageOptionFromURL = () => { 13 | const tab: string | null = queryParams.get("tab"); 14 | if (!tab || Number.isNaN(parseInt(tab, 10))) { 15 | return AdminPageOption.StoryTranslations; 16 | } 17 | 18 | const index = parseInt(tab, 10); 19 | return index >= AdminPageOption.StoryTranslations && 20 | index <= AdminPageOption.Tests 21 | ? index 22 | : -1; 23 | }; 24 | 25 | const adminPageOption = getPageOptionFromURL(); 26 | if (adminPageOption === -1) { 27 | window.location.href = "#/"; 28 | } 29 | const bodyComponent = (adminPgOption: AdminPageOption) => { 30 | switch (adminPgOption) { 31 | case AdminPageOption.StoryTranslations: 32 | return ; 33 | case AdminPageOption.Stories: 34 | return ; 35 | case AdminPageOption.Translators: 36 | return ; 37 | case AdminPageOption.Reviewers: 38 | return ; 39 | case AdminPageOption.Tests: 40 | return ; 41 | default: 42 | return ; 43 | } 44 | }; 45 | return ( 46 | <> 47 | 48 | {bodyComponent(adminPageOption)} 49 | > 50 | ); 51 | }; 52 | 53 | export default AdminPage; 54 | -------------------------------------------------------------------------------- /frontend/src/components/homepage/MyWorkFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useEffect } from "react"; 2 | import { Box, Flex, Divider, Heading, useStyleConfig } from "@chakra-ui/react"; 3 | 4 | import ButtonRadioGroup from "../utils/ButtonRadioGroup"; 5 | import { StoryCardProps } from "./StoryCard"; 6 | 7 | export type StoriesFilterFn = (story: StoryCardProps) => boolean; 8 | 9 | export type MyWorkFilterProps = { 10 | setFilter: Dispatch>; 11 | isDefaultInReview: boolean; 12 | }; 13 | 14 | const MyWorkFilter = ({ setFilter, isDefaultInReview }: MyWorkFilterProps) => { 15 | const generateFilterFn = (stage: string) => { 16 | return (story: StoryCardProps) => story?.stage === stage; 17 | }; 18 | 19 | useEffect(() => { 20 | if (isDefaultInReview) { 21 | setFilter(() => generateFilterFn("REVIEW")); 22 | } else { 23 | setFilter(() => generateFilterFn("TRANSLATE")); 24 | } 25 | }, []); 26 | 27 | const handleStageChange = (stage: string) => { 28 | let filterStage = "PUBLISH"; 29 | if (stage === "In Translation") { 30 | filterStage = "TRANSLATE"; 31 | } else if (stage === "In Review") { 32 | filterStage = "REVIEW"; 33 | } 34 | setFilter(() => generateFilterFn(filterStage)); 35 | }; 36 | 37 | const filterStyle = useStyleConfig("Filter"); 38 | return ( 39 | 40 | Filters 41 | 42 | 43 | Progress 44 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default MyWorkFilter; 56 | -------------------------------------------------------------------------------- /frontend/src/APIClients/mutations/UserMutations.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const SOFT_DELETE_USER = gql` 4 | mutation SoftDeleteUser($id: Int!) { 5 | softDeleteUser(id: $id) { 6 | ok 7 | } 8 | } 9 | `; 10 | export type SoftDeleteUserResponse = { ok: boolean }; 11 | 12 | export const UPDATE_USER_APPROVED_LANGUAGES = gql` 13 | mutation UpdateUserApprovedLanguages( 14 | $userId: ID! 15 | $isTranslate: Boolean! 16 | $language: String! 17 | $level: Int! 18 | ) { 19 | updateUserApprovedLanguage( 20 | userId: $userId 21 | isTranslate: $isTranslate 22 | language: $language 23 | level: $level 24 | ) { 25 | user { 26 | id 27 | } 28 | } 29 | } 30 | `; 31 | 32 | export type UpdateUserApprovedLanguagesResponse = { 33 | user: { 34 | id: number; 35 | }; 36 | }; 37 | 38 | export const UPDATE_ME = gql` 39 | mutation Updateme($userData: UpdateUserDTO!, $resume: Upload) { 40 | updateMe(user: $userData, resume: $resume) { 41 | user { 42 | id 43 | } 44 | } 45 | } 46 | `; 47 | 48 | export type UpdateMeResponse = { 49 | user: { 50 | id: number; 51 | }; 52 | }; 53 | 54 | export const COMPLETE_SIGN_UP = gql` 55 | mutation CompleteSignUp( 56 | $userData: UpdateUserDTO! 57 | $userId: ID! 58 | $level: Int! 59 | $language: String! 60 | $wantsReviewer: Boolean! 61 | $resume: Upload 62 | ) { 63 | updateMe(user: $userData, resume: $resume) { 64 | user { 65 | id 66 | } 67 | } 68 | 69 | createStoryTranslationTest( 70 | userId: $userId 71 | level: $level 72 | language: $language 73 | wantsReviewer: $wantsReviewer 74 | ) { 75 | story { 76 | id 77 | } 78 | } 79 | } 80 | `; 81 | 82 | export type CompleteSignUpResponse = { 83 | user: { 84 | id: number; 85 | }; 86 | story: { 87 | id: number; 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /backend/python/app/graphql/mutations/comment_mutation.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | from ...middlewares.auth import ( 4 | get_user_id_from_request, 5 | require_authorization_by_role_gql, 6 | ) 7 | from ..service import services 8 | from ..types.comment_type import ( 9 | CommentResponseDTO, 10 | CreateCommentDTO, 11 | UpdateCommentRequestDTO, 12 | UpdateCommentResponseDTO, 13 | ) 14 | 15 | 16 | class CreateComment(graphene.Mutation): 17 | class Arguments: 18 | comment_data = CreateCommentDTO(required=True) 19 | 20 | ok = graphene.Boolean() 21 | comment = graphene.Field(lambda: CommentResponseDTO) 22 | 23 | @require_authorization_by_role_gql({"User", "Admin"}) 24 | def mutate(root, info, comment_data): 25 | user_id = get_user_id_from_request() 26 | is_admin = services["user"].get_user_by_id(user_id).role == "Admin" 27 | comment_response = services["comment"].create_comment( 28 | comment=comment_data, user_id=user_id, is_admin=is_admin 29 | ) 30 | ok = True 31 | return CreateComment(ok=ok, comment=comment_response) 32 | 33 | 34 | class UpdateCommentById(graphene.Mutation): 35 | class Arguments: 36 | comment_data = UpdateCommentRequestDTO(required=True) 37 | 38 | ok = graphene.Boolean() 39 | comment = graphene.Field(lambda: CommentResponseDTO) 40 | 41 | @require_authorization_by_role_gql({"User", "Admin"}) 42 | def mutate(root, info, comment_data): 43 | try: 44 | user_id = get_user_id_from_request() 45 | comment_response = services["comment"].update_comment( 46 | updated_comment=comment_data, user_id=user_id 47 | ) 48 | ok = True 49 | return UpdateCommentById(ok=ok, comment=comment_response) 50 | except Exception as e: 51 | error_message = getattr(e, "message", None) 52 | raise Exception(error_message if error_message else str(e)) 53 | -------------------------------------------------------------------------------- /backend/python/app/models/user_all.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, inspect 2 | from sqlalchemy.dialects.mysql import TEXT 3 | from sqlalchemy.orm.properties import ColumnProperty 4 | 5 | from . import db 6 | 7 | roles_enum = db.Enum("User", "Admin", name="roles") 8 | 9 | 10 | class UserAll(db.Model): 11 | __tablename__ = "users_all" 12 | 13 | id = db.Column(db.Integer, primary_key=True, nullable=False) 14 | first_name = db.Column(TEXT, nullable=False) 15 | last_name = db.Column(TEXT, nullable=False) 16 | email = db.Column(db.String(100), nullable=False, unique=True) 17 | auth_id = db.Column(db.String(100), nullable=False, unique=True) 18 | role = db.Column(roles_enum) 19 | resume = db.Column(db.Integer, db.ForeignKey("files.id")) 20 | profile_pic = db.Column(db.Integer, db.ForeignKey("files.id")) 21 | # format for approved_languages_translation and approved_languages_review 22 | # should be language: highest approved level 23 | approved_languages_translation = db.Column(db.JSON) 24 | approved_languages_review = db.Column(db.JSON) 25 | additional_experiences = db.Column(db.JSON) 26 | is_deleted = db.Column(Boolean, default=False, nullable=False) 27 | 28 | def to_dict(self, include_relationships=False): 29 | # define the entities table 30 | cls = type(self) 31 | 32 | mapper = inspect(cls) 33 | formatted = {} 34 | for column in mapper.attrs: 35 | field = column.key 36 | attr = getattr(self, field) 37 | # if it's a regular column, extract the value 38 | if isinstance(column, ColumnProperty): 39 | formatted[field] = attr 40 | # otherwise, it's a relationship field 41 | # (currently not applicable, but may be useful for entity groups) 42 | elif include_relationships: 43 | # recursively format the relationship 44 | # don't format the relationship's relationships 45 | formatted[field] = [obj.to_dict() for obj in attr] 46 | return formatted 47 | -------------------------------------------------------------------------------- /update_secret_files.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | 4 | # Open secret.config file 5 | configFileNotFound = False 6 | try: 7 | configFile = open('secret.config') 8 | except Exception as e: 9 | print("File secret.config could not be opened in current directory.") 10 | print(e) 11 | configFileNotFound = True 12 | # Script will exit after checking if the Vault request is valid 13 | 14 | # Decode json result 15 | try: 16 | rawInput = ''.join(sys.stdin.readlines()) 17 | decodedJson = json.loads(rawInput) 18 | except Exception as e: 19 | print("Unable to retrieve secrets from Vault and obtain valid json result.") 20 | print("Please ensure you are authenticated and have supplied the correct path argument.") 21 | exit() 22 | 23 | # Extract the data field containting the secrets 24 | if "data" in decodedJson and "data" in decodedJson["data"]: 25 | data = decodedJson["data"]["data"] 26 | else: 27 | print("Unable to access the field data:{data:{}} from result which should contain the secrets.") 28 | print("Please ensure you are authenticated and have supplied the correct path argument.") 29 | exit() 30 | 31 | # Even if the config file is not found, it is useful to still indicate if the Vault request has any problems before exiting 32 | if configFileNotFound: 33 | exit() 34 | 35 | # Read all the secret file locations from secret.config 36 | locations = {} 37 | for line in configFile: 38 | key, val = line.rstrip().partition('=')[::2] 39 | if key in locations: 40 | print("Key <{keyName}> appeared more than once on configuration file. Ignoring second instance of the key.".format(keyName=key)) 41 | else: 42 | locations[key] = val 43 | configFile.close() 44 | 45 | # Write values to the secret file corresponding to their keys 46 | for key in data: 47 | if key in locations: 48 | try: 49 | f = open(locations[key], 'w') 50 | f.write(data[key]) 51 | f.close() 52 | except Exception as e: 53 | print("Could not write the values for key <{keyName}> to location <{locName}>".format(keyName=key, locName=locations[key])) 54 | print(e) 55 | else: 56 | print("File location for key <{keyName}> was not found.".format(keyName=key)) -------------------------------------------------------------------------------- /frontend/src/components/auth/ResetPassword.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { Text } from "@chakra-ui/react"; 4 | import { 5 | RESET_PASSWORD, 6 | ResetPasswordResponse, 7 | } from "../../APIClients/mutations/AuthMutations"; 8 | import authAPIClient from "../../APIClients/AuthAPIClient"; 9 | 10 | type ResetPasswordProps = { 11 | email: string; 12 | }; 13 | 14 | const ResetPassword = ({ email }: ResetPasswordProps) => { 15 | const isRealEmailAvailable = (emailString: string): boolean => { 16 | const re = 17 | /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 18 | return re.test(String(emailString).toLowerCase()); 19 | }; 20 | 21 | const [resetPassword] = 22 | useMutation<{ resetPassword: ResetPasswordResponse }>(RESET_PASSWORD); 23 | 24 | const handleErrorOnReset = (errorMessage: string) => { 25 | // eslint-disable-next-line no-alert 26 | alert(errorMessage); 27 | }; 28 | 29 | const handleSuccessOnReset = (successMessage: string) => { 30 | // eslint-disable-next-line no-alert 31 | alert(successMessage); 32 | }; 33 | 34 | const onResetPasswordClick = async () => { 35 | if (!isRealEmailAvailable(email!)) { 36 | // eslint-disable-next-line no-alert 37 | alert("Invalid email"); 38 | return; 39 | } 40 | 41 | try { 42 | const result = await authAPIClient.resetPassword(email, resetPassword); 43 | if (result) { 44 | handleSuccessOnReset(`Reset email sent to ${email}`); 45 | } else { 46 | handleErrorOnReset("Reset password failed."); 47 | } 48 | } catch (err) { 49 | if (typeof err === "string") { 50 | handleErrorOnReset(err); 51 | } else { 52 | handleErrorOnReset("Error occurred, please try again."); 53 | } 54 | } 55 | }; 56 | 57 | return ( 58 | 59 | Forgot password? 60 | 61 | ); 62 | }; 63 | 64 | export default ResetPassword; 65 | -------------------------------------------------------------------------------- /backend/python/app/models/story_translation_all.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Boolean, DateTime, Float, func, inspect 2 | from sqlalchemy.dialects.mysql import TEXT 3 | from sqlalchemy.ext.hybrid import hybrid_property 4 | from sqlalchemy.orm.properties import ColumnProperty 5 | 6 | from . import db 7 | from .story_translation_content_all import StoryTranslationContentAll 8 | 9 | stages_enum = db.Enum("TRANSLATE", "REVIEW", "PUBLISH", name="stages") 10 | 11 | 12 | class StoryTranslationAll(db.Model): 13 | 14 | __tablename__ = "story_translations_all" 15 | id = db.Column(db.Integer, primary_key=True, nullable=False) 16 | story_id = db.Column(db.Integer, db.ForeignKey("stories_all.id"), nullable=False) 17 | language = db.Column(TEXT, nullable=False) 18 | stage = db.Column(stages_enum, nullable=False) 19 | translator_id = db.Column(db.Integer, db.ForeignKey("users_all.id"), index=True) 20 | reviewer_id = db.Column(db.Integer, db.ForeignKey("users_all.id"), index=True) 21 | translation_contents = db.relationship(StoryTranslationContentAll) 22 | is_deleted = db.Column(Boolean, default=False, nullable=False) 23 | is_test = db.Column(Boolean, default=False, nullable=False) 24 | test_grade = db.Column(Float(3, 2)) 25 | test_result = db.Column(db.JSON) 26 | test_feedback = db.Column(TEXT) 27 | translator_last_activity = db.Column(DateTime) 28 | reviewer_last_activity = db.Column(DateTime) 29 | 30 | def to_dict(self, include_relationships=False): 31 | cls = type(self) 32 | mapper = inspect(cls) 33 | formatted = {} 34 | for column in mapper.attrs: 35 | field = column.key 36 | attr = getattr(self, field) 37 | if isinstance(column, ColumnProperty): 38 | formatted[field] = attr 39 | elif include_relationships: 40 | formatted[field] = [obj.to_dict() for obj in attr] 41 | return formatted 42 | 43 | @hybrid_property 44 | def last_activity(self): 45 | return func.greatest( 46 | func.coalesce(self.translator_last_activity, 0), 47 | func.coalesce(self.reviewer_last_activity, 0), 48 | ) 49 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 27 | 28 | 29 | Add My Language 30 | 31 | 32 | You need to enable JavaScript to run this app. 33 | 34 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/src/components/admin/ManageUsers.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useQuery } from "@apollo/client"; 3 | import { Box, Flex, Heading } from "@chakra-ui/react"; 4 | import UsersTable, { alphabeticalCompare } from "./UsersTable"; 5 | import { buildUsersQuery, User } from "../../APIClients/queries/UserQueries"; 6 | import TableFilter from "./TableFilter"; 7 | import { USER_TABLE_FILTER_SEARCH_BAR_PLACEHOLDER } from "../../utils/Copy"; 8 | 9 | export type ManageUsersProps = { 10 | isTranslators: boolean; 11 | }; 12 | 13 | const ManageUsers = ({ isTranslators }: ManageUsersProps) => { 14 | const [users, setUsers] = useState([]); 15 | const [language, setLanguage] = useState(null); 16 | const [level, setLevel] = useState(null); 17 | const [searchText, setSearchText] = useState(null); 18 | const query = buildUsersQuery( 19 | isTranslators, 20 | language || "", 21 | parseInt(level || "", 10) || 0, 22 | searchText || "", 23 | ); 24 | 25 | const { loading, data } = useQuery(query.string, { 26 | fetchPolicy: "cache-and-network", 27 | onCompleted: () => { 28 | const newUsers = [...data[query.fieldName]]; 29 | newUsers.sort(alphabeticalCompare); 30 | setUsers(newUsers); 31 | }, 32 | }); 33 | 34 | return ( 35 | 36 | 37 | 38 | {`Manage ${isTranslators ? "Translators" : "Reviewers"}`} 39 | 40 | 41 | 52 | 59 | 60 | ); 61 | }; 62 | 63 | export default ManageUsers; 64 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/src/components/utils/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Flex, 6 | Heading, 7 | Modal, 8 | ModalOverlay, 9 | ModalContent, 10 | ModalHeader, 11 | ModalBody, 12 | ModalCloseButton, 13 | Text, 14 | } from "@chakra-ui/react"; 15 | 16 | export type ConfirmationModalProps = { 17 | confirmation: boolean; 18 | onClose: () => void; 19 | onConfirmationClick: () => void; 20 | confirmationHeading?: string; 21 | confirmationMessage: string; 22 | buttonMessage: string; 23 | }; 24 | 25 | const ConfirmationModal = ({ 26 | confirmation, 27 | onClose, 28 | onConfirmationClick, 29 | confirmationHeading = "Are you sure?", 30 | confirmationMessage, 31 | buttonMessage, 32 | }: ConfirmationModalProps) => { 33 | return ( 34 | 40 | 41 | 42 | 48 | 49 | 50 | {confirmationHeading} 51 | 52 | 58 | 59 | 60 | 61 | 62 | {confirmationMessage} 63 | 64 | 65 | 70 | {buttonMessage} 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default ConfirmationModal; 81 | -------------------------------------------------------------------------------- /frontend/src/components/translation/Autosave.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { UPDATE_STORY_TRANSLATION_CONTENTS } from "../../APIClients/mutations/StoryMutations"; 4 | 5 | import debounce from "../../utils/DebounceUtils"; 6 | 7 | export type StoryLine = { 8 | lineIndex: number; 9 | originalContent: string; 10 | status?: string; 11 | translatedContent?: string; 12 | storyTranslationContentId?: number; 13 | }; 14 | 15 | type AutosaveProps = { 16 | storylines: StoryLine[]; 17 | onSuccess: () => void; 18 | setChangesSaved: (saved: boolean) => void; 19 | }; 20 | 21 | // Inspiration from https://www.synthace.com/autosave-with-react-hooks/ 22 | const Autosave = ({ 23 | storylines, 24 | onSuccess, 25 | setChangesSaved, 26 | }: AutosaveProps) => { 27 | const handleError = (errorMessage: string) => { 28 | // eslint-disable-next-line no-alert 29 | alert(errorMessage); 30 | }; 31 | 32 | const [updateTranslation] = useMutation<{}>( 33 | UPDATE_STORY_TRANSLATION_CONTENTS, 34 | ); 35 | 36 | const debouncedSave = useCallback( 37 | debounce(async (linesToUpdate: StoryLine[]) => { 38 | if (linesToUpdate.length === 0) { 39 | return; 40 | } 41 | const storyTranslationContents = linesToUpdate.map((line: StoryLine) => { 42 | return { 43 | id: line.storyTranslationContentId, 44 | translationContent: line.translatedContent, 45 | }; 46 | }); 47 | 48 | try { 49 | const result = await updateTranslation({ 50 | variables: { storyTranslationContents }, 51 | }); 52 | 53 | if (result.data == null) { 54 | handleError("Unable to save translation"); 55 | } else { 56 | onSuccess(); 57 | setChangesSaved(true); 58 | } 59 | } catch (err) { 60 | if (typeof err === "string") { 61 | handleError(err); 62 | } else { 63 | handleError("Error occurred, please try again."); 64 | } 65 | setChangesSaved(true); 66 | } 67 | }, 1000), 68 | [], 69 | ); 70 | 71 | useEffect(() => { 72 | debouncedSave(storylines); 73 | }, [storylines, debouncedSave]); 74 | 75 | return null; 76 | }; 77 | 78 | export default Autosave; 79 | -------------------------------------------------------------------------------- /backend/python/app/services/implementations/email_service.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from email.mime.text import MIMEText 3 | 4 | from google.oauth2.credentials import Credentials 5 | from googleapiclient.discovery import build 6 | 7 | from ..interfaces.email_service import IEmailService 8 | 9 | 10 | class EmailService(IEmailService): 11 | """ 12 | EmailService implementation for handling email related functionality 13 | """ 14 | 15 | def __init__(self, logger, credentials, sender_email, display_name=None): 16 | """ 17 | Create an instance of EmailService 18 | 19 | :param logger: application's logger instance 20 | :type logger: logger 21 | :param credentials: oauth credentials containing client_id, client_secret, 22 | token_uri, and refresh_token 23 | :type credentials: dict 24 | :param sender_email: the sender's email address 25 | :type sender_email: str 26 | :param display_name: the sender's display name, defaults to None 27 | :type display_name: str, optional 28 | """ 29 | self.logger = logger 30 | creds = Credentials(None, **credentials) 31 | self.service = build("gmail", "v1", credentials=creds) 32 | self.sender_email = sender_email 33 | if display_name: 34 | self.sender = "{name} <{email}>".format( 35 | name=display_name, email=sender_email 36 | ) 37 | else: 38 | self.sender = sender_email 39 | 40 | def send_email(self, to, subject, body): 41 | message = MIMEText(body, "html") 42 | message["from"] = self.sender 43 | message["to"] = to 44 | message["subject"] = subject 45 | email = {"raw": base64.urlsafe_b64encode(message.as_string().encode()).decode()} 46 | try: 47 | sent_info = ( 48 | self.service.users() 49 | .messages() 50 | .send(userId=self.sender_email, body=email) 51 | .execute() 52 | ) 53 | return sent_info 54 | except Exception as e: 55 | reason = getattr(e, "message", None) 56 | self.logger.error( 57 | "Failed to send email. Reason = {reason}".format( 58 | reason=(reason if reason else str(e)) 59 | ) 60 | ) 61 | raise e 62 | -------------------------------------------------------------------------------- /frontend/src/components/review/ExistingComment.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Text, Flex, Button } from "@chakra-ui/react"; 3 | import { CommentResponse } from "../../APIClients/queries/CommentQueries"; 4 | 5 | export type ExistingCommentProps = { 6 | comment: CommentResponse; 7 | handleReply?: () => void; 8 | isFirstReply: boolean; 9 | isThreadHead: boolean; 10 | resolveExistingComment?: () => void; 11 | userName: string; 12 | }; 13 | 14 | const ExistingComment = ({ 15 | comment, 16 | handleReply, 17 | isFirstReply, 18 | isThreadHead, 19 | resolveExistingComment, 20 | userName, 21 | }: ExistingCommentProps) => { 22 | const { content, lineIndex: storyContentId, resolved, time } = comment; 23 | 24 | const newTime = new Date(time); 25 | const displayTime = new Date( 26 | newTime.getTime() - newTime.getTimezoneOffset() * 60000, 27 | ); 28 | 29 | return ( 30 | 38 | {(isThreadHead || isFirstReply) && ( 39 | 40 | {isFirstReply && "Replies to "} 41 | Line {storyContentId + 1} 42 | 43 | )} 44 | 45 | {userName} 46 | 47 | {displayTime.toLocaleDateString("en-US", { 48 | month: "short", 49 | day: "numeric", 50 | hour: "numeric", 51 | minute: "numeric", 52 | hour12: true, 53 | })} 54 | 55 | 56 | 57 | {content} 58 | 59 | 60 | {!resolved && isThreadHead && ( 61 | <> 62 | handleReply() : undefined} 64 | variant="commentLabel" 65 | > 66 | Reply 67 | 68 | 69 | Resolve 70 | 71 | > 72 | )} 73 | 74 | 75 | ); 76 | }; 77 | 78 | export default ExistingComment; 79 | -------------------------------------------------------------------------------- /backend/python/app/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from graphene.test import Client 5 | from sqlalchemy.engine import create_engine 6 | from sqlalchemy.ext.declarative import declarative_base 7 | from sqlalchemy.orm import scoped_session, sessionmaker 8 | 9 | from . import create_app 10 | 11 | # https://github.com/apryor6/flask_testing_examples/tree/master/fte 12 | 13 | my_app = None 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def app(): 18 | """ 19 | Returns app configured for testing 20 | """ 21 | global my_app 22 | if my_app is None: 23 | my_app = create_app("testing") 24 | return my_app 25 | 26 | 27 | @pytest.fixture(scope="function") 28 | def db(app): 29 | """ 30 | Yields db instance 31 | """ 32 | from .models import db 33 | from .models.comment_all import CommentAll 34 | from .models.story_all import StoryAll 35 | from .models.story_content_all import StoryContentAll 36 | from .models.story_translation_all import StoryTranslationAll 37 | from .models.story_translation_content_all import StoryTranslationContentAll 38 | from .models.user_all import UserAll 39 | 40 | yield db 41 | 42 | db.session.query(CommentAll).delete() 43 | db.session.commit() 44 | db.session.query(StoryTranslationContentAll).delete() 45 | db.session.commit() 46 | db.session.query(StoryContentAll).delete() 47 | db.session.commit() 48 | db.session.query(StoryTranslationAll).delete() 49 | db.session.commit() 50 | db.session.query(StoryAll).delete() 51 | db.session.commit() 52 | db.session.query(UserAll).delete() 53 | db.session.commit() 54 | db.session.close() 55 | 56 | 57 | @pytest.fixture(scope="session") 58 | def services(app): 59 | """ 60 | Returns model services 61 | """ 62 | from .services.implementations.comment_service import CommentService 63 | from .services.implementations.story_service import StoryService 64 | from .services.implementations.user_service import UserService 65 | 66 | return { 67 | "comment": CommentService(), 68 | "story": StoryService(), 69 | "user": UserService(), 70 | } 71 | 72 | 73 | @pytest.fixture(scope="session") 74 | def client(app): 75 | """ 76 | Returns graphene client for test query/mutation 77 | """ 78 | from .graphql.schema import schema 79 | 80 | return Client(schema) 81 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/04653332ed3a_add_story_test_changes.py: -------------------------------------------------------------------------------- 1 | """add story test changes 2 | 3 | Revision ID: 04653332ed3a 4 | Revises: 91e8d22078f4 5 | Create Date: 2021-12-10 03:27:03.107768 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "04653332ed3a" 14 | down_revision = "91e8d22078f4" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column( 22 | "stories", sa.Column("is_test", sa.Boolean(), default=False, nullable=False) 23 | ) 24 | op.add_column( 25 | "story_translations_all", 26 | sa.Column("is_test", sa.Boolean(), default=False, nullable=False), 27 | ) 28 | op.add_column( 29 | "story_translations_all", 30 | sa.Column("test_feedback", mysql.TEXT(), nullable=True), 31 | ) 32 | op.add_column( 33 | "story_translations_all", sa.Column("test_grade", sa.Float(), nullable=True) 34 | ) 35 | op.add_column( 36 | "story_translations_all", sa.Column("test_result", sa.JSON(), nullable=True) 37 | ) 38 | 39 | op.execute( 40 | "ALTER TABLE story_translation_contents_all MODIFY COLUMN status ENUM('DEFAULT', 'ACTION_REQUIRED', 'APPROVED', 'TEST_CORRECT', 'TEST_PARTIALLY_CORRECT', 'TEST_INCORRECT') NOT NULL;" 41 | ) 42 | 43 | active_translations_view = """ 44 | CREATE OR REPLACE VIEW story_translations 45 | AS 46 | SELECT * from story_translations_all 47 | WHERE is_deleted=0; 48 | """ 49 | op.execute(active_translations_view) 50 | 51 | # ### end Alembic commands ### 52 | 53 | 54 | def downgrade(): 55 | # ### commands auto generated by Alembic - please adjust! ### 56 | op.drop_column("story_translations_all", "test_result") 57 | op.drop_column("story_translations_all", "test_grade") 58 | op.drop_column("story_translations_all", "test_feedback") 59 | op.drop_column("story_translations_all", "is_test") 60 | op.drop_column("stories", "is_test") 61 | 62 | active_translations_view = """ 63 | CREATE OR REPLACE VIEW story_translations 64 | AS 65 | SELECT * from story_translations_all 66 | WHERE is_deleted=0; 67 | """ 68 | op.execute(active_translations_view) 69 | # ### end Alembic commands ### 70 | -------------------------------------------------------------------------------- /frontend/src/utils/FileUtils.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode, useLazyQuery } from "@apollo/client"; 2 | import { saveAs } from "file-saver"; 3 | import { File } from "../APIClients/queries/FileQueries"; 4 | 5 | // Retrieved from: https://stackoverflow.com/a/16245768 6 | const b64toBlob = ( 7 | b64Data: string, 8 | contentType = "", 9 | sliceSize = 512, 10 | ): Blob => { 11 | const byteCharacters = atob(b64Data); 12 | const byteArrays = []; 13 | 14 | for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { 15 | const slice = byteCharacters.slice(offset, offset + sliceSize); 16 | 17 | const byteNumbers = new Array(slice.length); 18 | for (let i = 0; i < slice.length; i += 1) { 19 | byteNumbers[i] = slice.charCodeAt(i); 20 | } 21 | 22 | const byteArray = new Uint8Array(byteNumbers); 23 | byteArrays.push(byteArray); 24 | } 25 | 26 | const blob = new Blob(byteArrays, { type: contentType }); 27 | return blob; 28 | }; 29 | 30 | const getMIMETypeFromExtension = (ext: string) => { 31 | switch (ext) { 32 | case "txt": 33 | return "text/plain;charset=utf-8"; 34 | case "png": 35 | return "image/png"; 36 | case "jpg": 37 | return "image/jpeg"; 38 | case "jpeg": 39 | return "image/jpeg"; 40 | case "doc": 41 | return "application/msword"; 42 | case "docx": 43 | return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; 44 | case "pdf": 45 | return "application/pdf"; 46 | default: 47 | return "application/octet-stream"; 48 | } 49 | }; 50 | 51 | /* 52 | * A hook that retrieves a file through GraphQL and downloads the file. 53 | * 54 | * @param id - The file id 55 | * @param filename - The filename to save as 56 | * 57 | * @returns downloadFile - A function that triggers the file download 58 | */ 59 | /* eslint-disable-next-line import/prefer-default-export */ 60 | export const useFileDownload = ( 61 | filename: string, 62 | query: { fieldName: string; string: DocumentNode }, 63 | ) => { 64 | const [download] = useLazyQuery(query.string, { 65 | onCompleted: (data) => { 66 | const { ext, file } = data[query.fieldName] as File; 67 | const blob = b64toBlob(file, getMIMETypeFromExtension(ext)); 68 | saveAs(blob, `${filename}.${ext}`); 69 | }, 70 | }); 71 | 72 | const downloadFile = (id: number | null): void => { 73 | if (id) { 74 | download({ variables: { id } }); 75 | } 76 | }; 77 | return [downloadFile]; 78 | }; 79 | -------------------------------------------------------------------------------- /frontend/src/components/onboarding/FailUserModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { useHistory } from "react-router-dom"; 4 | import { 5 | FINISH_GRADING_STORY_TRANSLATION, 6 | FinishGradingStoryTranslationResponse, 7 | } from "../../APIClients/mutations/StoryMutations"; 8 | import { 9 | GRADING_PAGE_FAIL_USER_CONFIRMATION, 10 | GRADING_PAGE_FAIL_USER_BUTTON, 11 | } from "../../utils/Copy"; 12 | import ConfirmationModal from "../utils/ConfirmationModal"; 13 | import TestFeedbackModal from "./TestFeedbackModal"; 14 | 15 | export type FailUserModalProps = { 16 | isOpen: boolean; 17 | onClose: () => void; 18 | storyTranslationId: number; 19 | }; 20 | 21 | const FailUserModal = ({ 22 | isOpen, 23 | onClose, 24 | storyTranslationId, 25 | }: FailUserModalProps) => { 26 | const [enterFeedback, setEnterFeedback] = useState(false); 27 | const [testFeedback, setTestFeedback] = useState(""); 28 | 29 | const history = useHistory(); 30 | 31 | const [finishGradingStoryTranslation] = useMutation<{ 32 | finishGradingStoryTranslation: FinishGradingStoryTranslationResponse; 33 | }>(FINISH_GRADING_STORY_TRANSLATION); 34 | 35 | const failGrade = async (feedback: string) => { 36 | const result = await finishGradingStoryTranslation({ 37 | variables: { 38 | storyTranslationTestId: storyTranslationId, 39 | testFeedback: feedback, 40 | testResult: "{}", 41 | }, 42 | }); 43 | if (result.data?.finishGradingStoryTranslation.ok) { 44 | history.push("/?tab=4"); 45 | } else { 46 | // eslint-disable-next-line no-alert 47 | window.alert("Could not mark story translation as failed."); 48 | } 49 | }; 50 | 51 | return ( 52 | <> 53 | setEnterFeedback(true)} 56 | onClose={onClose} 57 | confirmationMessage={GRADING_PAGE_FAIL_USER_CONFIRMATION} 58 | buttonMessage={GRADING_PAGE_FAIL_USER_BUTTON} 59 | /> 60 | { 66 | failGrade(testFeedback); 67 | setEnterFeedback(false); 68 | onClose(); 69 | }} 70 | /> 71 | > 72 | ); 73 | }; 74 | 75 | export default FailUserModal; 76 | -------------------------------------------------------------------------------- /backend/python/tools/seed_users.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | from app import create_app 4 | 5 | db = SQLAlchemy() 6 | 7 | import csv 8 | import json 9 | import random 10 | import string 11 | 12 | import requests 13 | 14 | query = """ 15 | mutation SignUp($firstName: String!, $lastName: String!, $email: String!, $password: String!) 16 | { 17 | signup( 18 | firstName: $firstName 19 | lastName: $lastName 20 | email: $email 21 | password: $password 22 | ) 23 | { 24 | id 25 | firstName 26 | lastName 27 | email 28 | role 29 | accessToken 30 | refreshToken 31 | } 32 | } 33 | """ 34 | 35 | app = create_app("development") 36 | # To generate file, Use the linked google sheet document, File > Download > tsv 37 | with open("tools/seed_users.tsv", newline="") as csvfile: 38 | spamreader = csv.reader(csvfile, delimiter="\t") 39 | for i, row in enumerate(spamreader): 40 | if i == 0: 41 | continue 42 | fname, lname, email, role, appr_trans, appr_rev = row 43 | user_id = None 44 | # create firebase + database user via signup mutation 45 | temp = random.sample(string.ascii_lowercase, 10) 46 | password = "".join(temp) 47 | variables = { 48 | "firstName": fname, 49 | "lastName": lname, 50 | "email": email, 51 | "password": password, 52 | } 53 | url = "http://localhost:5000/graphql" 54 | r = requests.post(url, json={"query": query, "variables": variables}) 55 | json_data = json.loads(r.text) 56 | user_id = json_data["data"]["signup"]["id"] 57 | # correct database user information 58 | # update approved languages and role 59 | if appr_trans: 60 | db.engine.execute( 61 | f"UPDATE users \ 62 | SET \ 63 | role = '{role}', approved_languages_translation= '{appr_trans}'\ 64 | WHERE \ 65 | id='{user_id}'; \ 66 | " 67 | ) 68 | if appr_rev: 69 | db.engine.execute( 70 | f"UPDATE users \ 71 | SET \ 72 | role = '{role}', approved_languages_review = '{appr_rev}'\ 73 | WHERE \ 74 | id='{user_id}'; \ 75 | " 76 | ) 77 | -------------------------------------------------------------------------------- /backend/python/app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from logging.config import dictConfig 4 | 5 | import firebase_admin 6 | from flask import Flask 7 | from flask.cli import ScriptInfo 8 | from flask_cors import CORS 9 | from flask_sqlalchemy import SQLAlchemy 10 | from flaskext.mysql import MySQL 11 | 12 | from .config import app_config 13 | 14 | 15 | def create_app(config_name): 16 | # configure Flask logger 17 | dictConfig( 18 | { 19 | "version": 1, 20 | "handlers": { 21 | "wsgi": { 22 | "class": "logging.FileHandler", 23 | "level": "ERROR", 24 | "filename": "error.log", 25 | "formatter": "default", 26 | } 27 | }, 28 | "formatters": { 29 | "default": { 30 | "format": "%(asctime)s-%(levelname)s-%(name)s::%(module)s,%(lineno)s: %(message)s" 31 | }, 32 | }, 33 | "root": {"level": "ERROR", "handlers": ["wsgi"]}, 34 | } 35 | ) 36 | 37 | app = Flask(__name__) 38 | mysql = MySQL() 39 | 40 | if type(config_name) is not ScriptInfo: 41 | app.config.from_object(app_config[config_name]) 42 | 43 | if os.getenv("IS_PREVIEW_DEPLOY", "False") == "True": 44 | app.config["CORS_ORIGINS"] = re.compile("https://planet-read-uwbp.*") 45 | else: 46 | app.config["CORS_ORIGINS"] = os.getenv( 47 | "CORS_ORIGINS", "http://localhost:3000" 48 | ).split(",") 49 | app.config["CORS_SUPPORTS_CREDENTIALS"] = True 50 | CORS(app) 51 | 52 | app.config["MYSQL_HOST"] = os.getenv("MYSQL_HOST") 53 | app.config["MYSQL_USER"] = os.getenv("MYSQL_USER") 54 | app.config["MYSQL_PASSWORD"] = os.getenv("MYSQL_PASSWORD") 55 | app.config["MYSQL_DATABASE"] = os.getenv("MYSQL_DATABASE") 56 | 57 | app.config[ 58 | "SQLALCHEMY_DATABASE_URI" 59 | ] = "mysql://{username}:{password}@{host}:3306/{db}".format( 60 | username=os.getenv("MYSQL_USER"), 61 | password=os.getenv("MYSQL_PASSWORD"), 62 | host=os.getenv("TEST_DB_HOST") 63 | if config_name == "testing" 64 | else os.getenv("DB_HOST"), 65 | db=os.getenv("MYSQL_DATABASE"), 66 | ) 67 | 68 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 69 | mysql.init_app(app) 70 | firebase_admin.initialize_app() 71 | 72 | from . import models 73 | 74 | models.init_app(app) 75 | 76 | from . import graphql 77 | 78 | graphql.init_app(app) 79 | 80 | return app 81 | -------------------------------------------------------------------------------- /frontend/src/components/admin/ManageStories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { useQuery } from "@apollo/client"; 4 | import { Box, Button, Icon, Flex, Heading } from "@chakra-ui/react"; 5 | import { MdLogin } from "react-icons/md"; 6 | import StoriesTable from "./StoriesTable"; 7 | import { Story, GET_STORIES } from "../../APIClients/queries/StoryQueries"; 8 | import TableFilter from "./TableFilter"; 9 | import { STORY_TRANSLATION_TABLE_FILTER_SEARCH_BAR_PLACEHOLDER } from "../../utils/Copy"; 10 | 11 | const ManageStories = () => { 12 | const [stories, setStories] = useState([]); 13 | const [searchText, setSearchText] = useState(null); 14 | const [startDate, setStartDate] = useState(null); 15 | const [endDate, setEndDate] = useState(null); 16 | 17 | const { loading } = useQuery(GET_STORIES(searchText, startDate, endDate), { 18 | fetchPolicy: "cache-and-network", 19 | onCompleted: (data) => { 20 | setStories(data.stories); 21 | }, 22 | }); 23 | 24 | const history = useHistory(); 25 | const routeChange = () => { 26 | history.push("/import-story"); 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 33 | Manage Stories 34 | 35 | 41 | 48 | IMPORT STORY 49 | 50 | 51 | 67 | 73 | 74 | ); 75 | }; 76 | 77 | export default ManageStories; 78 | -------------------------------------------------------------------------------- /frontend/src/theme/components/Button.ts: -------------------------------------------------------------------------------- 1 | const Button = { 2 | baseStyle: { 3 | fontSize: "14px", 4 | textAlign: "center", 5 | textTransform: "uppercase", 6 | height: "40px", 7 | width: "160px", 8 | }, 9 | sizes: { 10 | primary: { 11 | width: "30px", 12 | }, 13 | secondary: { 14 | color: "blue.100", 15 | }, 16 | tertiary: { 17 | color: "black", 18 | textDecoration: "underline", 19 | }, 20 | undoRedo: { 21 | width: "36px", 22 | height: "36px", 23 | fontSize: "20px", 24 | }, 25 | clear: { 26 | width: "16px", 27 | height: "16px", 28 | borderRadius: "50%", 29 | }, 30 | }, 31 | variants: { 32 | blueOutline: { 33 | background: "white", 34 | border: "1px solid", 35 | borderColor: "gray.100", 36 | color: "blue.100", 37 | }, 38 | clear: { 39 | marginLeft: "10px", 40 | padding: "10px", 41 | background: "gray.100", 42 | color: "gray.700", 43 | }, 44 | ghost: { 45 | background: "transparent", 46 | textDecoration: "none", 47 | }, 48 | addComment: { 49 | border: "1px solid", 50 | borderColor: "gray.50", 51 | background: "white", 52 | color: "blue.100", 53 | width: "100px", 54 | fontSize: "14px", 55 | }, 56 | scrollToTop: { 57 | background: "white", 58 | border: "1px solid", 59 | borderColor: "gray.300", 60 | bottom: "10px", 61 | fontSize: "14px", 62 | position: "fixed", 63 | right: "10px", 64 | width: "50px", 65 | }, 66 | header: { 67 | background: "transparent", 68 | fontWeight: "lighter", 69 | padding: "30px", 70 | textTransform: "capitalize", 71 | width: "fit-content", 72 | }, 73 | headerSelect: { 74 | background: "transparent", 75 | borderRadius: "0px", 76 | borderBottom: "3px solid", 77 | borderBottomColor: "blue.100", 78 | color: "blue.100", 79 | padding: "30px 30px 27px 30px", 80 | textTransform: "capitalize", 81 | width: "fit-content", 82 | }, 83 | commentLabel: { 84 | background: "transparent", 85 | fontSize: "12px", 86 | justifyContent: "flex-start", 87 | padding: "0px", 88 | textTransform: "capitalize", 89 | width: "40px", 90 | }, 91 | link: { 92 | fontSize: "16px", 93 | textTransform: "lowercase", 94 | textDecoration: "underline", 95 | width: "fit-content", 96 | fontWeight: "normal", 97 | }, 98 | }, 99 | }; 100 | export default Button; 101 | -------------------------------------------------------------------------------- /backend/python/app/graphql/types/user_type.py: -------------------------------------------------------------------------------- 1 | import graphene 2 | 3 | 4 | class RoleEnum(graphene.Enum): 5 | User = "User" 6 | Admin = "Admin" 7 | 8 | 9 | class UserDTO(graphene.ObjectType): 10 | id = graphene.Int() 11 | first_name = graphene.String(required=True) 12 | last_name = graphene.String(required=True) 13 | role = graphene.Field(RoleEnum, required=True) 14 | email = graphene.String(required=True) 15 | resume = graphene.Int() 16 | profile_pic = graphene.Int() 17 | approved_languages_translation = graphene.JSONString() 18 | approved_languages_review = graphene.JSONString() 19 | additional_experiences = graphene.JSONString() 20 | 21 | 22 | class CreateUserWithEmailDTO(graphene.InputObjectType): 23 | first_name = graphene.String(required=True) 24 | last_name = graphene.String(required=True) 25 | role = graphene.Argument(RoleEnum, required=True) 26 | email = graphene.String(required=True) 27 | password = graphene.String(required=True) 28 | resume = graphene.Int(required=False, default=None) 29 | profile_pic = graphene.Int(required=False, default=None) 30 | approved_languages_translation = graphene.JSONString(required=False, default=None) 31 | approved_languages_review = graphene.JSONString(required=False, default=None) 32 | additional_experiences = graphene.JSONString(required=False, default=None) 33 | sign_up_method = graphene.String(default="PASSWORD") 34 | 35 | 36 | class CreateUserWithGoogleDTO(graphene.InputObjectType): 37 | first_name = graphene.String(required=True) 38 | last_name = graphene.String(required=True) 39 | role = graphene.Argument(RoleEnum, required=True) 40 | email = graphene.String(required=True) 41 | auth_id = graphene.String(required=True) 42 | resume = graphene.Int(required=False, default=None) 43 | profile_pic = graphene.Int(required=False, default=None) 44 | approved_languages_translation = graphene.JSONString(required=False, default=None) 45 | approved_languages_review = graphene.JSONString(required=False, default=None) 46 | additional_experiences = graphene.JSONString(required=False, default=None) 47 | 48 | 49 | class UpdateUserDTO(graphene.InputObjectType): 50 | first_name = graphene.String(required=True) 51 | last_name = graphene.String(required=True) 52 | role = graphene.Argument(RoleEnum) 53 | email = graphene.String(required=True) 54 | resume = graphene.Int(required=False, default=None) 55 | profile_pic = graphene.Int(required=False, default=None) 56 | approved_languages_translation = graphene.JSONString(required=False, default=None) 57 | approved_languages_review = graphene.JSONString(required=False, default=None) 58 | additional_experiences = graphene.JSONString(required=False, default=None) 59 | -------------------------------------------------------------------------------- /frontend/src/components/utils/ButtonRadioGroup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint react/jsx-props-no-spreading: 0 */ 2 | /* eslint react/destructuring-assignment: 0 */ 3 | import React, { useEffect } from "react"; 4 | import { 5 | Box, 6 | Flex, 7 | useRadio, 8 | useRadioGroup, 9 | useStyleConfig, 10 | } from "@chakra-ui/react"; 11 | 12 | function RadioCard(props: any) { 13 | const { getInputProps, getCheckboxProps } = useRadio(props); 14 | const input = getInputProps(); 15 | const checkbox = getCheckboxProps(); 16 | 17 | const buttonStyle = useStyleConfig("Button", { 18 | size: props.size, 19 | }); 20 | const buttonStyleUnselected = useStyleConfig("Button", { 21 | size: props.size, 22 | variant: props.unselectedVariant, 23 | }); 24 | 25 | return ( 26 | 27 | 28 | 35 | {props.children} 36 | 37 | 38 | ); 39 | } 40 | 41 | export type ButtonRadioGroupProps = { 42 | size?: string; 43 | stacked?: boolean; 44 | unselectedVariant?: string; 45 | name: string; 46 | defaultValue: string; 47 | isDisabled?: boolean; 48 | options: string[]; 49 | onChange: (newState: string) => void; 50 | dependentValue?: string; 51 | value?: string; 52 | }; 53 | 54 | function ButtonRadioGroup({ 55 | size = "secondary", 56 | stacked = true, 57 | unselectedVariant = "outline", 58 | name, 59 | defaultValue, 60 | isDisabled = false, 61 | options, 62 | dependentValue, 63 | onChange, 64 | value, 65 | }: ButtonRadioGroupProps) { 66 | const { getRootProps, getRadioProps, setValue } = useRadioGroup({ 67 | name, 68 | defaultValue, 69 | onChange, 70 | }); 71 | 72 | const group = getRootProps(); 73 | 74 | useEffect(() => { 75 | if (!options.includes(defaultValue)) { 76 | onChange(options[options.length - 1]); 77 | setValue(options[options.length - 1]); 78 | } 79 | }, [dependentValue]); 80 | 81 | useEffect(() => { 82 | if (value) { 83 | setValue(value); 84 | } 85 | }, [value]); 86 | 87 | return ( 88 | 89 | {options.map((val) => { 90 | const radio = getRadioProps({ value: val }); 91 | return ( 92 | 99 | {val} 100 | 101 | ); 102 | })} 103 | 104 | ); 105 | } 106 | 107 | export default ButtonRadioGroup; 108 | -------------------------------------------------------------------------------- /frontend/src/components/review/ApproveAll.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { 4 | Checkbox, 5 | Flex, 6 | Menu, 7 | MenuButton, 8 | MenuList, 9 | Text, 10 | } from "@chakra-ui/react"; 11 | import { Icon } from "@chakra-ui/icon"; 12 | import { MdArrowDropDown } from "react-icons/md"; 13 | 14 | import ConfirmationModal from "../utils/ConfirmationModal"; 15 | import { 16 | APPROVE_ALL_STORY_TRANSLATION_CONTENT, 17 | ApproveAllStoryTranslationContentResponse, 18 | } from "../../APIClients/mutations/StoryMutations"; 19 | import { 20 | REVIEW_PAGE_APPROVE_ALL_CONFIRMATION, 21 | REVIEW_PAGE_BUTTON_MESSAGE, 22 | } from "../../utils/Copy"; 23 | 24 | export type ApproveAllProps = { 25 | numApprovedLines: number; 26 | setNumApprovedLines: (numLines: number) => void; 27 | totalLines: number; 28 | storyTranslationId: number; 29 | }; 30 | 31 | const ApproveAll = ({ 32 | numApprovedLines, 33 | setNumApprovedLines, 34 | totalLines, 35 | storyTranslationId, 36 | }: ApproveAllProps) => { 37 | const [sendAsApproved, setSendAsApproved] = useState(false); 38 | 39 | const [approveAll] = useMutation<{ 40 | response: ApproveAllStoryTranslationContentResponse; 41 | }>(APPROVE_ALL_STORY_TRANSLATION_CONTENT); 42 | 43 | const closeModal = () => { 44 | setSendAsApproved(false); 45 | }; 46 | 47 | const openModal = () => { 48 | setSendAsApproved(true); 49 | }; 50 | 51 | const callApproveAllMutation = async () => { 52 | const result = await approveAll({ 53 | variables: { 54 | storyTranslationId, 55 | }, 56 | }); 57 | 58 | if (result) { 59 | setNumApprovedLines(totalLines); 60 | } 61 | closeModal(); 62 | window.location.reload(); 63 | }; 64 | 65 | return ( 66 | 67 | 68 | 69 | Status 70 | 71 | 72 | 73 | 74 | 75 | 82 | Mark all as approved 83 | 84 | 85 | 86 | {sendAsApproved && ( 87 | 94 | )} 95 | 96 | ); 97 | }; 98 | 99 | export default ApproveAll; 100 | -------------------------------------------------------------------------------- /frontend/src/components/homepage/WelcomeModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | Box, 4 | Button, 5 | Checkbox, 6 | Divider, 7 | Flex, 8 | Heading, 9 | Image, 10 | Modal, 11 | ModalOverlay, 12 | ModalContent, 13 | ModalHeader, 14 | ModalBody, 15 | Text, 16 | ModalCloseButton, 17 | } from "@chakra-ui/react"; 18 | import { WELCOME_MODAL_COPY } from "../../utils/Copy"; 19 | import Logo from "../../assets/planet-read-logo.svg"; 20 | 21 | export type WelcomeModalProps = { 22 | isOpen: boolean; 23 | onClose: () => void; 24 | title?: string; 25 | readonly?: boolean; 26 | }; 27 | 28 | const modalBodyText = WELCOME_MODAL_COPY.map((text: string) => ( 29 | 30 | {text} 31 | 32 | )); 33 | 34 | const WelcomeModal = ({ 35 | isOpen, 36 | onClose, 37 | title = "Welcome to Add My Language", 38 | readonly = false, 39 | }: WelcomeModalProps) => { 40 | const [isAgreed, setIsAgreed] = useState(false); 41 | 42 | const handleClose = () => { 43 | onClose(); 44 | window.location.href = `#/?tab=2`; 45 | }; 46 | 47 | return ( 48 | 56 | 57 | 58 | 59 | 60 | 61 | {title} 62 | 63 | 64 | {readonly && ( 65 | 66 | )} 67 | 68 | 69 | 70 | 71 | {modalBodyText} 72 | {!readonly && ( 73 | 78 | setIsAgreed(e.target.checked)} 81 | > 82 | I agree to the terms and conditions 83 | 84 | 89 | Let's Get Started 90 | 91 | 92 | )} 93 | 94 | 95 | 96 | 97 | ); 98 | }; 99 | 100 | export default WelcomeModal; 101 | -------------------------------------------------------------------------------- /backend/python/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from alembic import context 7 | from flask import current_app 8 | 9 | # this is the Alembic Config object, which provides 10 | # access to the values within the .ini file in use. 11 | config = context.config 12 | 13 | # Interpret the config file for Python logging. 14 | # This line sets up loggers basically. 15 | fileConfig(config.config_file_name) 16 | logger = logging.getLogger("alembic.env") 17 | 18 | # add your model's MetaData object here 19 | # for 'autogenerate' support 20 | # from myapp import mymodel 21 | # target_metadata = mymodel.Base.metadata 22 | config.set_main_option( 23 | "sqlalchemy.url", 24 | str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"), 25 | ) 26 | target_metadata = current_app.extensions["migrate"].db.metadata 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | 33 | 34 | def run_migrations_offline(): 35 | """Run migrations in 'offline' mode. 36 | 37 | This configures the context with just a URL 38 | and not an Engine, though an Engine is acceptable 39 | here as well. By skipping the Engine creation 40 | we don't even need a DBAPI to be available. 41 | 42 | Calls to context.execute() here emit the given string to the 43 | script output. 44 | 45 | """ 46 | url = config.get_main_option("sqlalchemy.url") 47 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 48 | 49 | with context.begin_transaction(): 50 | context.run_migrations() 51 | 52 | 53 | def run_migrations_online(): 54 | """Run migrations in 'online' mode. 55 | 56 | In this scenario we need to create an Engine 57 | and associate a connection with the context. 58 | 59 | """ 60 | 61 | # this callback is used to prevent an auto-migration from being generated 62 | # when there are no changes to the schema 63 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 64 | def process_revision_directives(context, revision, directives): 65 | if getattr(config.cmd_opts, "autogenerate", False): 66 | script = directives[0] 67 | if script.upgrade_ops.is_empty(): 68 | directives[:] = [] 69 | logger.info("No changes in schema detected.") 70 | 71 | connectable = current_app.extensions["migrate"].db.get_engine() 72 | 73 | with connectable.connect() as connection: 74 | context.configure( 75 | connection=connection, 76 | target_metadata=target_metadata, 77 | process_revision_directives=process_revision_directives, 78 | **current_app.extensions["migrate"].configure_args 79 | ) 80 | 81 | with context.begin_transaction(): 82 | context.run_migrations() 83 | 84 | 85 | if context.is_offline_mode(): 86 | run_migrations_offline() 87 | else: 88 | run_migrations_online() 89 | -------------------------------------------------------------------------------- /backend/python/app/tests/graphql/queries/test_story_query.py: -------------------------------------------------------------------------------- 1 | from ....models.story import Story 2 | from ....models.story_all import StoryAll 3 | from ....models.story_content import StoryContent 4 | from ....models.story_content_all import StoryContentAll 5 | from ...helpers.story_helpers import ( 6 | assert_story_equals_model, 7 | guarantee_story_table_not_empty, 8 | ) 9 | 10 | GET_STORIES = """ 11 | query GetStories { 12 | stories { 13 | id 14 | title 15 | description 16 | level 17 | youtubeLink 18 | translatedLanguages 19 | contents { 20 | id 21 | storyId 22 | lineIndex 23 | content 24 | } 25 | } 26 | } 27 | """ 28 | 29 | 30 | # Test no longer works because query is authenticated 31 | def test_stories(app, db, client): 32 | """ 33 | guarantee_story_table_not_empty(db) 34 | 35 | result = client.execute(GET_STORIES) 36 | returned_arr = result["data"]["stories"] 37 | 38 | stories_db = Story.query.all() 39 | story_contents_db = StoryContent.query.all() 40 | story_contents_db_dict = {sc.id: sc for sc in story_contents_db} 41 | assert len(returned_arr) == len(stories_db) 42 | 43 | for story_dict, story_model in zip(returned_arr, stories_db): 44 | story_db_id = story_dict["id"] 45 | 46 | assert_story_equals_model(story_dict, story_model) 47 | 48 | for content_dict in story_dict["contents"]: 49 | content_db = story_contents_db_dict[content_dict["id"]] 50 | # type mismatch 51 | assert str(content_db.story_id) == content_dict["storyId"] 52 | assert content_db.line_index == content_dict["lineIndex"] 53 | assert content_db.content == content_dict["content"] 54 | """ 55 | pass 56 | 57 | 58 | def test_story_by_id(db, client): 59 | pass 60 | 61 | 62 | def test_story_by_id_invalid_id(db, client): 63 | pass 64 | 65 | 66 | def test_stories_available_for_translation(db, client): 67 | pass 68 | 69 | 70 | def test_stories_available_for_translation_invalid_language_string(db, client): 71 | pass 72 | 73 | 74 | def test_stories_available_for_translation_negative_or_too_large_level(db, client): 75 | pass 76 | 77 | 78 | def test_stories_available_for_translation_invalid_language_and_level_for_given_user( 79 | db, client 80 | ): 81 | pass 82 | 83 | 84 | def test_story_translaiton_by_id(db, client): 85 | pass 86 | 87 | 88 | def test_story_translation_by_id_invalid_id(db, client): 89 | pass 90 | 91 | 92 | def test_stories_available_for_review(db, client): 93 | pass 94 | 95 | 96 | def test_stories_available_for_review_invalid_language_string(db, client): 97 | pass 98 | 99 | 100 | def test_stories_available_for_review_negative_or_too_large_level(db, client): 101 | pass 102 | 103 | 104 | def test_stories_available_for_review_invalid_language_and_level_for_given_user( 105 | db, client 106 | ): 107 | pass 108 | 109 | 110 | def test_not_authenticated_for_endpoint(db, client): 111 | pass 112 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": ".", 6 | "dependencies": { 7 | "@apollo/client": "^3.3.16", 8 | "@chakra-ui/icon": "^1.1.11", 9 | "@chakra-ui/react": "^1.7.1", 10 | "@chakra-ui/theme": "^1.9.2", 11 | "@chakra-ui/theme-tools": "^1.1.8", 12 | "@emotion/react": "^11", 13 | "@emotion/styled": "^11", 14 | "@react-md/icon": "^3.0.0", 15 | "@rjsf/bootstrap-4": "^2.5.1", 16 | "@rjsf/core": "^2.5.1", 17 | "@testing-library/jest-dom": "^5.11.4", 18 | "@testing-library/react": "^11.1.0", 19 | "@testing-library/user-event": "^12.1.10", 20 | "@types/jest": "^26.0.15", 21 | "@types/node": "^12.0.0", 22 | "@types/react": "^17.0.0", 23 | "@types/react-dom": "^17.0.0", 24 | "@types/react-jsonschema-form": "^1.7.4", 25 | "@types/react-table": "^7.0.29", 26 | "apollo-upload-client": "^17.0.0", 27 | "axios": "^0.21.2", 28 | "bootstrap": "^4.6.0", 29 | "file-saver": "^2.0.5", 30 | "framer-motion": "^4", 31 | "graphql": "^15.5.0", 32 | "humps": "^2.0.1", 33 | "json-schema": "^0.4.0", 34 | "jsonwebtoken": "^8.5.1", 35 | "react": "^17.0.1", 36 | "react-bootstrap": "^1.5.2", 37 | "react-datepicker": "^4.5.0", 38 | "react-dom": "^17.0.1", 39 | "react-google-login": "^5.2.2", 40 | "react-icons": "^4.2.0", 41 | "react-json-schema": "^1.2.2", 42 | "react-jsonschema-form": "^1.8.1", 43 | "react-router-dom": "^5.2.0", 44 | "react-scripts": "^4.0.2", 45 | "react-select": "^5.1.0", 46 | "react-table": "^7.7.0", 47 | "sass": "^1.45.2", 48 | "typescript": "^4.1.2", 49 | "web-vitals": "^1.0.1" 50 | }, 51 | "scripts": { 52 | "start": "react-scripts start", 53 | "build": "react-scripts build && VERSIONTAG=`git describe --tags --always` && sed -i -- 's/%VERSIONTAG%/'$VERSIONTAG'/g' build/index.html && echo Tagged Version = $VERSIONTAG", 54 | "test": "react-scripts test", 55 | "eject": "react-scripts eject", 56 | "lint": "eslint . --ext .ts,.tsx,.js,.jsx", 57 | "fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix" 58 | }, 59 | "eslintConfig": { 60 | "extends": [ 61 | "react-app", 62 | "react-app/jest" 63 | ] 64 | }, 65 | "browserslist": { 66 | "production": [ 67 | ">0.2%", 68 | "not dead", 69 | "not op_mini all" 70 | ], 71 | "development": [ 72 | "last 1 chrome version", 73 | "last 1 firefox version", 74 | "last 1 safari version" 75 | ] 76 | }, 77 | "devDependencies": { 78 | "@types/apollo-upload-client": "^14.1.0", 79 | "@types/file-saver": "^2.0.4", 80 | "@types/humps": "^2.0.0", 81 | "@types/jsonwebtoken": "^8.5.1", 82 | "@types/react-datepicker": "^4.3.4", 83 | "@types/react-router-dom": "^5.1.7", 84 | "@typescript-eslint/eslint-plugin": "^4.15.2", 85 | "@typescript-eslint/parser": "^4.15.2", 86 | "eslint-config-airbnb-typescript": "^12.3.1", 87 | "eslint-config-prettier": "^8.3.0", 88 | "eslint-plugin-import": "^2.24.2", 89 | "eslint-plugin-jsx-a11y": "^6.4.1", 90 | "eslint-plugin-prettier": "^3.4.1", 91 | "eslint-plugin-react": "^7.25.1", 92 | "eslint-plugin-react-hooks": "^4.2.0", 93 | "prettier": "^2.3.2" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/08f4eaf160d3_soft_delete_users.py: -------------------------------------------------------------------------------- 1 | """soft delete users 2 | 3 | Revision ID: 08f4eaf160d3 4 | Revises: 1729015196af 5 | Create Date: 2021-11-01 01:38:35.818282 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "08f4eaf160d3" 13 | down_revision = "1729015196af" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | ### Update users 20 | op.add_column( 21 | "users", 22 | sa.Column("is_deleted", sa.Boolean(), default=False, nullable=False), 23 | ) 24 | op.rename_table("users", "users_all") 25 | active_users_view = """ 26 | CREATE VIEW users 27 | AS 28 | SELECT * from users_all 29 | WHERE is_deleted=0; 30 | """ 31 | op.execute(active_users_view) 32 | 33 | ### Update foreign key for story_translations 34 | op.drop_constraint( 35 | u"story_translations_all_ibfk_1", "story_translations_all", type_="foreignkey" 36 | ) 37 | op.drop_constraint( 38 | u"story_translations_all_ibfk_2", "story_translations_all", type_="foreignkey" 39 | ) 40 | op.create_foreign_key( 41 | u"story_translations_all_ibfk_1", 42 | "story_translations_all", 43 | "users_all", 44 | ["reviewer_id"], 45 | ["id"], 46 | ) 47 | op.create_foreign_key( 48 | u"story_translations_all_ibfk_2", 49 | "story_translations_all", 50 | "users_all", 51 | ["translator_id"], 52 | ["id"], 53 | ) 54 | 55 | ### Update foreign key for users 56 | op.drop_constraint( 57 | u"comments_all_ibfk_2", 58 | "comments_all", 59 | type_="foreignkey", 60 | ) 61 | op.create_foreign_key( 62 | u"comments_all_ibfk_2", 63 | "comments_all", 64 | "users_all", 65 | ["user_id"], 66 | ["id"], 67 | ) 68 | 69 | 70 | def downgrade(): 71 | # ### commands auto generated by Alembic - please adjust! ### 72 | ### foreign keys 73 | op.drop_constraint( 74 | u"comments_all_ibfk_2", 75 | "comments_all", 76 | type_="foreignkey", 77 | ) 78 | op.create_foreign_key( 79 | u"comments_all_ibfk_2", 80 | "comments_all", 81 | "users", 82 | ["user_id"], 83 | ["id"], 84 | ) 85 | 86 | op.drop_constraint( 87 | u"story_translations_all_ibfk_1", "story_translations_all", type_="foreignkey" 88 | ) 89 | op.drop_constraint( 90 | u"story_translations_all_ibfk_2", "story_translations_all", type_="foreignkey" 91 | ) 92 | op.create_foreign_key( 93 | u"story_translations_all_ibfk_1", 94 | "story_translations_all", 95 | "users", 96 | ["reviewer_id"], 97 | ["id"], 98 | ) 99 | op.create_foreign_key( 100 | u"story_translations_all_ibfk_2", 101 | "story_translations_all", 102 | "users", 103 | ["translator_id"], 104 | ["id"], 105 | ) 106 | op.execute("DROP VIEW users") 107 | op.rename_table("users_all", "users") 108 | op.drop_column("users", "is_deleted") 109 | # ### end Alembic commands ### 110 | -------------------------------------------------------------------------------- /frontend/src/components/homepage/StoryList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Flex, Text } from "@chakra-ui/react"; 3 | import StoryCard, { StoryCardProps } from "./StoryCard"; 4 | 5 | export type StoryListProps = { 6 | stories: StoryCardProps[] | null; 7 | displayMyStories: boolean; 8 | displayMyTests: boolean; 9 | }; 10 | 11 | const LoadingCard = () => { 12 | return ( 13 | 20 | Loading... 21 | Almost there ⌛⌛⌛ 22 | 23 | ); 24 | }; 25 | 26 | const NoStoriesFoundCardCheckAccess = () => { 27 | return ( 28 | 35 | No Stories Found 36 | Please check access permissions. 37 | 38 | ); 39 | }; 40 | 41 | const NoStoriesFoundCard = () => { 42 | return ( 43 | 50 | No Stories Found 51 | 52 | You may be seeing no stories if you are a translator or reviewer on an 53 | active story translation for this language. 54 | Please complete that story translation before signing up for more. 55 | 56 | 57 | ); 58 | }; 59 | 60 | const StoryList = ({ 61 | stories, 62 | displayMyStories, 63 | displayMyTests, 64 | }: StoryListProps) => { 65 | const createCardId = (storyId: number, storyTranslationId?: number) => 66 | `story-${storyId}${ 67 | storyTranslationId ? `-translation-${storyTranslationId}` : `` 68 | }-card`; 69 | 70 | if (stories == null) { 71 | return ; 72 | } 73 | 74 | if (stories.length === 0 && !displayMyStories && !displayMyTests) { 75 | return ; 76 | } 77 | 78 | if (stories.length === 0) { 79 | return ; 80 | } 81 | 82 | const storyCards = stories.map( 83 | ({ 84 | storyId, 85 | storyTranslationId, 86 | title, 87 | description, 88 | youtubeLink, 89 | level, 90 | language, 91 | translatorId, 92 | reviewerId, 93 | stage, 94 | }: StoryCardProps) => ( 95 | 110 | ), 111 | ); 112 | 113 | return {storyCards}; 114 | }; 115 | 116 | export default StoryList; 117 | -------------------------------------------------------------------------------- /frontend/src/components/onboarding/TestFeedbackModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Button, 4 | Flex, 5 | Grid, 6 | GridItem, 7 | Heading, 8 | Modal, 9 | ModalBody, 10 | ModalContent, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | Textarea, 15 | Tooltip, 16 | } from "@chakra-ui/react"; 17 | 18 | import { GRADED_TEST_ASSIGN_LEVEL_TOOL_TIP_COPY } from "../../utils/Copy"; 19 | 20 | export type TestFeedbackModalProps = { 21 | isOpen: boolean; 22 | isCentered: boolean; 23 | isDisabled?: boolean; 24 | testFeedback: string; 25 | setTestFeedback: (newFeedback: string) => void; 26 | onBack?: () => void; 27 | onConfirm: () => void; 28 | confirmString?: string; 29 | }; 30 | 31 | const TestFeedbackModal = ({ 32 | isOpen, 33 | isCentered, 34 | isDisabled = false, 35 | testFeedback, 36 | setTestFeedback, 37 | onBack, 38 | onConfirm, 39 | confirmString = "Confirm", 40 | }: TestFeedbackModalProps) => { 41 | return ( 42 | {}} 47 | size="4xl" 48 | > 49 | 50 | 51 | 52 | 53 | Feedback to User 54 | 55 | 56 | 57 | 58 | 63 | 64 | {!isDisabled && ( 65 | 66 | Provide any feedback or comments to the user in the box 67 | below (Optional) 68 | 69 | )} 70 | setTestFeedback(e.target.value)} 75 | placeholder="Enter your feedback here..." 76 | value={testFeedback} 77 | /> 78 | 79 | 80 | 81 | 82 | {onBack && ( 83 | 91 | Back 92 | 93 | )} 94 | 101 | {confirmString} 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | }; 109 | 110 | export default TestFeedbackModal; 111 | -------------------------------------------------------------------------------- /backend/python/migrations/versions/92ace1398b9f_update_soft_delete_user_index_names.py: -------------------------------------------------------------------------------- 1 | """update soft delete user index names 2 | 3 | Revision ID: 92ace1398b9f 4 | Revises: 08f4eaf160d3 5 | Create Date: 2021-12-01 03:35:16.710285 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "92ace1398b9f" 13 | down_revision = "08f4eaf160d3" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_index( 21 | op.f("ix_comments_all_story_translation_content_id"), 22 | "comments_all", 23 | ["story_translation_content_id"], 24 | unique=False, 25 | ) 26 | op.drop_index("ix_comments_story_translation_content_id", table_name="comments_all") 27 | op.create_index( 28 | op.f("ix_story_translation_contents_all_story_translation_id"), 29 | "story_translation_contents_all", 30 | ["story_translation_id"], 31 | unique=False, 32 | ) 33 | op.drop_index( 34 | "ix_story_translation_contents_story_translation_id", 35 | table_name="story_translation_contents_all", 36 | ) 37 | op.create_index( 38 | op.f("ix_story_translations_all_reviewer_id"), 39 | "story_translations_all", 40 | ["reviewer_id"], 41 | unique=False, 42 | ) 43 | op.create_index( 44 | op.f("ix_story_translations_all_translator_id"), 45 | "story_translations_all", 46 | ["translator_id"], 47 | unique=False, 48 | ) 49 | op.drop_index( 50 | "ix_story_translations_reviewer_id", table_name="story_translations_all" 51 | ) 52 | op.drop_index( 53 | "ix_story_translations_translator_id", table_name="story_translations_all" 54 | ) 55 | # ### end Alembic commands ### 56 | 57 | 58 | def downgrade(): 59 | # ### commands auto generated by Alembic - please adjust! ### 60 | op.create_index( 61 | "ix_story_translations_translator_id", 62 | "story_translations_all", 63 | ["translator_id"], 64 | unique=False, 65 | ) 66 | op.create_index( 67 | "ix_story_translations_reviewer_id", 68 | "story_translations_all", 69 | ["reviewer_id"], 70 | unique=False, 71 | ) 72 | op.drop_index( 73 | op.f("ix_story_translations_all_translator_id"), 74 | table_name="story_translations_all", 75 | ) 76 | op.drop_index( 77 | op.f("ix_story_translations_all_reviewer_id"), 78 | table_name="story_translations_all", 79 | ) 80 | op.create_index( 81 | "ix_story_translation_contents_story_translation_id", 82 | "story_translation_contents_all", 83 | ["story_translation_id"], 84 | unique=False, 85 | ) 86 | op.drop_index( 87 | op.f("ix_story_translation_contents_all_story_translation_id"), 88 | table_name="story_translation_contents_all", 89 | ) 90 | op.create_index( 91 | "ix_comments_story_translation_content_id", 92 | "comments_all", 93 | ["story_translation_content_id"], 94 | unique=False, 95 | ) 96 | op.drop_index( 97 | op.f("ix_comments_all_story_translation_content_id"), table_name="comments_all" 98 | ) 99 | # ### end Alembic commands ### 100 | -------------------------------------------------------------------------------- /frontend/src/components/navigation/UserModal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Box, 4 | Circle, 5 | Divider, 6 | Flex, 7 | Heading, 8 | Modal, 9 | ModalBody, 10 | ModalContent, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | } from "@chakra-ui/react"; 15 | import { Icon } from "@chakra-ui/icon"; 16 | import { MdOutlineAccountCircle } from "react-icons/md"; 17 | import Logout from "../auth/Logout"; 18 | 19 | type UserModalProps = { 20 | firstName: string; 21 | id: string; 22 | isReviewer: boolean; 23 | lastName: string; 24 | onClose: () => void; 25 | role: string; 26 | showUser: boolean; 27 | }; 28 | 29 | const UserModal = ({ 30 | firstName, 31 | id, 32 | isReviewer, 33 | lastName, 34 | onClose, 35 | role, 36 | showUser, 37 | }: UserModalProps) => { 38 | const onShowProfileClick = () => { 39 | window.location.href = `#/user/${id}`; 40 | }; 41 | 42 | return ( 43 | 49 | 50 | 51 | 57 | 58 | 64 | 70 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | {firstName} {lastName} 84 | 85 | 86 | {role === "Admin" ? "Admin" : "Translator"} 87 | {isReviewer && role !== "Admin" ? " & Reviewer" : ""} 88 | 89 | 90 | 91 | 92 | 93 | 94 | {role !== "Admin" && ( 95 | 102 | 108 | Profile 109 | 110 | )} 111 | 112 | 113 | 114 | 115 | 116 | ); 117 | }; 118 | 119 | export default UserModal; 120 | -------------------------------------------------------------------------------- /backend/python/app/graphql/queries/story_query.py: -------------------------------------------------------------------------------- 1 | from ...middlewares.auth import ( 2 | get_user_id_from_request, 3 | require_authorization_by_role_gql, 4 | ) 5 | from ..service import services 6 | 7 | 8 | @require_authorization_by_role_gql({"User", "Admin"}) 9 | def resolve_stories(root, info, story_title, start_date, end_date, **kwargs): 10 | return services["story"].get_stories(story_title, start_date, end_date) 11 | 12 | 13 | @require_authorization_by_role_gql({"User", "Admin"}) 14 | def resolve_story_by_id(root, info, id): 15 | return services["story"].get_story(id) 16 | 17 | 18 | @require_authorization_by_role_gql({"User", "Admin"}) 19 | def resolve_stories_available_for_translation(root, info, language, level, user_id): 20 | target_user_id = _select_user_id_for_available_translations_query(user_id) 21 | 22 | return services["story"].get_stories_available_for_translation( 23 | language, level, user_id=target_user_id 24 | ) 25 | 26 | 27 | @require_authorization_by_role_gql({"User", "Admin"}) 28 | def resolve_story_translations_by_user( 29 | root, info, user_id, is_translator, language, level 30 | ): 31 | return services["story"].get_story_translations_by_user( 32 | user_id, is_translator, language, level 33 | ) 34 | 35 | 36 | @require_authorization_by_role_gql({"User", "Admin"}) 37 | def resolve_story_translation_by_id(root, info, id): 38 | return services["story"].get_story_translation(id) 39 | 40 | 41 | @require_authorization_by_role_gql({"User", "Admin"}) 42 | def resolve_story_translations_available_for_review( 43 | root, info, language, level, user_id=None 44 | ): 45 | target_user_id = _select_user_id_for_available_translations_query(user_id) 46 | 47 | return services["story"].get_story_translations_available_for_review( 48 | language, level, user_id=target_user_id 49 | ) 50 | 51 | 52 | @require_authorization_by_role_gql({"Admin"}) 53 | def resolve_story_translation_statistics(root, info): 54 | return services["story"].get_story_translation_statistics() 55 | 56 | 57 | @require_authorization_by_role_gql({"Admin"}) 58 | def resolve_export_story_translation(root, info, id): 59 | res = services["story"].export_story_translation(id) 60 | file = services["file"].download_file(res["path"]) 61 | services["file"].delete_file(res["path"]) 62 | return file 63 | 64 | 65 | @require_authorization_by_role_gql({"Admin"}) 66 | def resolve_story_translations( 67 | root, info, language, level, stage, story_title, story_id, last_activity_ascending 68 | ): 69 | return services["story"].get_story_translations( 70 | language, 71 | level, 72 | stage, 73 | story_title, 74 | story_id, 75 | last_activity_ascending=last_activity_ascending, 76 | ) 77 | 78 | 79 | @require_authorization_by_role_gql({"User", "Admin"}) 80 | def resolve_story_translation_tests( 81 | root, info, language, level, stage, story_title, submitted_only 82 | ): 83 | user_id = get_user_id_from_request() 84 | user = services["user"].get_user_by_id(user_id) 85 | return services["story"].get_story_translation_tests( 86 | user, language, level, stage, story_title, submitted_only 87 | ) 88 | 89 | 90 | def _select_user_id_for_available_translations_query(user_id): 91 | # if caller is admin, use param use_id. Else, use caller id 92 | calling_user_id = get_user_id_from_request() 93 | isAdmin = services["user"].get_user_by_id(calling_user_id).role == "Admin" 94 | return user_id if isAdmin else calling_user_id 95 | -------------------------------------------------------------------------------- /frontend/src/components/pages/CompleteProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { Box, Button, Flex } from "@chakra-ui/react"; 4 | import Header from "../navigation/Header"; 5 | import UserProfileForm from "../userProfile/UserProfileForm"; 6 | import { 7 | COMPLETE_SIGN_UP, 8 | CompleteSignUpResponse, 9 | } from "../../APIClients/mutations/UserMutations"; 10 | import AuthContext from "../../contexts/AuthContext"; 11 | 12 | const CompleteProfilePage = () => { 13 | const { authenticatedUser } = useContext(AuthContext); 14 | 15 | const fullName = `${authenticatedUser!!.firstName} ${ 16 | authenticatedUser!!.lastName 17 | }`; 18 | 19 | const [name, setFullName] = useState(fullName); 20 | const [email, setEmail] = useState(authenticatedUser!!.email); 21 | const [language, setLanguage] = useState(""); 22 | const [educationalQualification, setEducationalQualification] = useState(""); 23 | const [languageExperience, setLanguageExperience] = useState(""); 24 | const [completeSignUp] = useMutation<{ 25 | response: CompleteSignUpResponse; 26 | }>(COMPLETE_SIGN_UP); 27 | const [resume, setResume] = useState(null); 28 | 29 | const updateResume = (updatedResume: File | null) => { 30 | setResume(updatedResume); 31 | }; 32 | 33 | const onSubmitClick = async () => { 34 | try { 35 | const [firstName, lastName] = name.split(" "); 36 | const tempAdditionalExperiences = { 37 | educationalQualification, 38 | languageExperience, 39 | }; 40 | const additionalExperiences = JSON.stringify(tempAdditionalExperiences); 41 | const userData = { 42 | firstName, 43 | lastName, 44 | email, 45 | additionalExperiences, 46 | }; 47 | await completeSignUp({ 48 | variables: { 49 | userData, 50 | userId: authenticatedUser!!.id, 51 | level: 2, 52 | language, 53 | wantsReviewer: false, 54 | resume, 55 | }, 56 | }); 57 | window.location.href = `#/?welcome=true&tab=2`; 58 | } catch (err) { 59 | // eslint-disable-next-line no-alert 60 | window.alert(err); 61 | } 62 | }; 63 | 64 | return ( 65 | 66 | 67 | 68 | 78 | 79 | 87 | 88 | 95 | SUBMIT FOR REVIEW 96 | 97 | 98 | 99 | 100 | ); 101 | }; 102 | 103 | export default CompleteProfilePage; 104 | --------------------------------------------------------------------------------
{userName}
47 | {displayTime.toLocaleDateString("en-US", { 48 | month: "short", 49 | day: "numeric", 50 | hour: "numeric", 51 | minute: "numeric", 52 | hour12: true, 53 | })} 54 |