├── 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 | PlanetRead logo 12 | 13 | Add my Language 14 | 15 | 16 | 17 | Login background image 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 | 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 | 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 |