├── .dockerignore ├── .editorconfig ├── .github └── workflows │ └── lint_and_test.yaml ├── .gitignore ├── .idea ├── dataSources.xml ├── fullstack-fastapi-vuejs.iml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── LICENSE ├── README.md ├── client ├── .browserslistrc ├── .editorconfig ├── .env.development ├── .env.production ├── .env.staging ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README-zh.md ├── README.md ├── babel.config.js ├── cypress.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── android-chrome-maskable-192x192.png │ │ │ ├── android-chrome-maskable-512x512.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.vue │ ├── api │ │ ├── auth.ts │ │ ├── enums.ts │ │ ├── me.ts │ │ ├── roles.ts │ │ ├── shops.ts │ │ ├── types.d.ts │ │ └── users.ts │ ├── assets │ │ ├── 401-images │ │ │ └── 401.gif │ │ ├── 404-images │ │ │ ├── 404-cloud.png │ │ │ └── 404.png │ │ └── custom-theme │ │ │ ├── fonts │ │ │ ├── element-icons.ttf │ │ │ └── element-icons.woff │ │ │ └── index.css │ ├── components │ │ ├── Breadcrumb │ │ │ └── index.vue │ │ ├── ErrorLog │ │ │ └── index.vue │ │ ├── Hamburger │ │ │ └── index.vue │ │ ├── HeaderSearch │ │ │ └── index.vue │ │ ├── JsonEditor │ │ │ └── index.vue │ │ ├── Pagination │ │ │ └── index.vue │ │ ├── RightPanel │ │ │ └── index.vue │ │ ├── Screenfull │ │ │ └── index.vue │ │ └── ThemePicker │ │ │ └── index.vue │ ├── icons │ │ ├── README.md │ │ ├── components │ │ │ ├── 404.ts │ │ │ ├── back-top.ts │ │ │ ├── bug.ts │ │ │ ├── chart.ts │ │ │ ├── clipboard.ts │ │ │ ├── component.ts │ │ │ ├── dashboard.ts │ │ │ ├── documentation.ts │ │ │ ├── drag.ts │ │ │ ├── edit.ts │ │ │ ├── education.ts │ │ │ ├── email.ts │ │ │ ├── example.ts │ │ │ ├── excel.ts │ │ │ ├── exit-fullscreen.ts │ │ │ ├── eye-off.ts │ │ │ ├── eye-on.ts │ │ │ ├── form.ts │ │ │ ├── fullscreen.ts │ │ │ ├── guide-2.ts │ │ │ ├── guide.ts │ │ │ ├── hamburger.ts │ │ │ ├── icon.ts │ │ │ ├── index.ts │ │ │ ├── international.ts │ │ │ ├── language.ts │ │ │ ├── like.ts │ │ │ ├── link.ts │ │ │ ├── list.ts │ │ │ ├── lock.ts │ │ │ ├── message.ts │ │ │ ├── money.ts │ │ │ ├── nested.ts │ │ │ ├── password.ts │ │ │ ├── pdf.ts │ │ │ ├── people.ts │ │ │ ├── peoples.ts │ │ │ ├── qq.ts │ │ │ ├── search.ts │ │ │ ├── shopping.ts │ │ │ ├── size.ts │ │ │ ├── skill.ts │ │ │ ├── star.ts │ │ │ ├── tab.ts │ │ │ ├── table.ts │ │ │ ├── theme.ts │ │ │ ├── tree-table.ts │ │ │ ├── tree.ts │ │ │ ├── user.ts │ │ │ ├── wechat.ts │ │ │ └── zip.ts │ │ └── svg │ │ │ ├── 404.svg │ │ │ ├── back-top.svg │ │ │ ├── bug.svg │ │ │ ├── chart.svg │ │ │ ├── clipboard.svg │ │ │ ├── component.svg │ │ │ ├── dashboard.svg │ │ │ ├── documentation.svg │ │ │ ├── drag.svg │ │ │ ├── edit.svg │ │ │ ├── education.svg │ │ │ ├── email.svg │ │ │ ├── example.svg │ │ │ ├── excel.svg │ │ │ ├── exit-fullscreen.svg │ │ │ ├── eye-off.svg │ │ │ ├── eye-on.svg │ │ │ ├── form.svg │ │ │ ├── fullscreen.svg │ │ │ ├── guide-2.svg │ │ │ ├── guide.svg │ │ │ ├── hamburger.svg │ │ │ ├── icon.svg │ │ │ ├── international.svg │ │ │ ├── language.svg │ │ │ ├── like.svg │ │ │ ├── link.svg │ │ │ ├── list.svg │ │ │ ├── lock.svg │ │ │ ├── message.svg │ │ │ ├── money.svg │ │ │ ├── nested.svg │ │ │ ├── password.svg │ │ │ ├── pdf.svg │ │ │ ├── people.svg │ │ │ ├── peoples.svg │ │ │ ├── qq.svg │ │ │ ├── search.svg │ │ │ ├── shopping.svg │ │ │ ├── size.svg │ │ │ ├── skill.svg │ │ │ ├── star.svg │ │ │ ├── tab.svg │ │ │ ├── table.svg │ │ │ ├── theme.svg │ │ │ ├── tree-table.svg │ │ │ ├── tree.svg │ │ │ ├── user.svg │ │ │ ├── wechat.svg │ │ │ └── zip.svg │ ├── layout │ │ ├── components │ │ │ ├── AppMain.vue │ │ │ ├── Navbar │ │ │ │ └── index.vue │ │ │ ├── Settings │ │ │ │ └── index.vue │ │ │ ├── Sidebar │ │ │ │ ├── SidebarItem.vue │ │ │ │ ├── SidebarItemLink.vue │ │ │ │ ├── SidebarLogo.vue │ │ │ │ └── index.vue │ │ │ ├── TagsView │ │ │ │ ├── ScrollPane.vue │ │ │ │ └── index.vue │ │ │ └── index.ts │ │ ├── index.vue │ │ └── mixin │ │ │ └── resize.ts │ ├── main.ts │ ├── permission.ts │ ├── pwa │ │ ├── components │ │ │ └── ServiceWorkerUpdatePopup.vue │ │ ├── register-service-worker.ts │ │ └── service-worker.js │ ├── router │ │ ├── index.ts │ │ └── modules │ │ │ ├── roles.ts │ │ │ ├── shops.ts │ │ │ └── users.ts │ ├── settings.ts │ ├── shims.d.ts │ ├── store │ │ ├── index.ts │ │ └── modules │ │ │ ├── app.ts │ │ │ ├── error-log.ts │ │ │ ├── me.ts │ │ │ ├── permission.ts │ │ │ ├── roles.ts │ │ │ ├── settings.ts │ │ │ ├── shops.ts │ │ │ ├── tags-view.ts │ │ │ └── users.ts │ ├── styles │ │ ├── _mixins.scss │ │ ├── _svgicon.scss │ │ ├── _transition.scss │ │ ├── _variables.scss │ │ ├── _variables.scss.d.ts │ │ ├── element-variables.scss │ │ ├── element-variables.scss.d.ts │ │ └── index.scss │ ├── utils │ │ ├── clipboard.ts │ │ ├── cookies.ts │ │ ├── error-log.ts │ │ ├── index.ts │ │ ├── permission.ts │ │ ├── request.ts │ │ ├── scroll-to.ts │ │ └── validate.ts │ └── views │ │ ├── dashboard │ │ ├── admin │ │ │ └── index.vue │ │ ├── index.vue │ │ └── user │ │ │ └── index.vue │ │ ├── error-page │ │ ├── 401.vue │ │ └── 404.vue │ │ ├── login │ │ └── index.vue │ │ ├── prices │ │ ├── Index.vue │ │ └── Index2.vue │ │ ├── profile │ │ ├── components │ │ │ ├── Account.vue │ │ │ ├── UserRoles.vue │ │ │ └── UserShops.vue │ │ └── index.vue │ │ ├── redirect │ │ └── index.vue │ │ ├── register │ │ └── Index.vue │ │ ├── roles │ │ ├── CreateRole.vue │ │ └── ListRoles.vue │ │ ├── shops │ │ ├── CreateShop.vue │ │ ├── EditShop.vue │ │ └── ListShops.vue │ │ └── users │ │ ├── CreateUser.vue │ │ ├── EditUser.vue │ │ └── ListUsers.vue ├── tsconfig.json └── vue.config.js ├── docker-compose.yml ├── docker ├── init.sql ├── postgres.env └── server.env ├── imgs ├── 1-login.png ├── 2-admin-dashboard.png ├── 3.0-user-CRUD.png ├── 3.1-user-create.png ├── 4-user-profile.png ├── 5.0-shop-CRUD.png ├── 5.1-shop-select.png └── 6-prices-display.png └── server ├── Dockerfile ├── alembic.ini ├── alembic ├── README ├── env.py ├── script.py.mako └── versions │ ├── 46773fb5dc7f_adding_user_table.py │ ├── daff23253894_adding_user_shops_relationship.py │ ├── e7ff97c10b8f_adding_user_roles_association_table.py │ └── fab2a86c8b63_adding_user_shops.py ├── app ├── api │ ├── apirouter.py │ └── v1 │ │ ├── dependencies │ │ ├── auth.py │ │ └── or_404.py │ │ ├── routes │ │ ├── auth.py │ │ ├── me.py │ │ ├── roles.py │ │ ├── shops.py │ │ └── users.py │ │ └── v1_router.py ├── bgtasks.py ├── db │ ├── initdb.py │ ├── initdb.yaml │ └── session.py ├── enums │ ├── logenums.py │ └── userenums.py ├── exceptions │ └── apiexceptions.py ├── main.py ├── messages │ ├── apimsg.py │ └── usermsgs.py ├── models │ ├── __init__.py │ ├── meta │ │ ├── pydanticbase.py │ │ ├── pydanticmixins.py │ │ ├── pydantictypes.py │ │ ├── sqlalchemybase.py │ │ ├── sqlalchemymixins.py │ │ └── sqlalchemytypes.py │ ├── rolemodels.py │ ├── scrapermodels.py │ ├── shopmodels.py │ ├── tokenmodels.py │ └── usermodels.py ├── service │ ├── emailservice.py │ ├── passwordservice.py │ ├── roleservice.py │ ├── scraperservice.py │ ├── shopservice.py │ ├── tokenservice.py │ └── userservice.py ├── settings.py ├── static │ └── email-templates │ │ ├── html │ │ └── verify-account.html │ │ └── src │ │ └── verify-account.mjml └── utils │ └── common.py ├── docker-entrypoint.sh ├── gunicorn_conf.py ├── manage.py ├── poetry.lock ├── pyproject.toml ├── scripts ├── lint.sh └── test.sh └── tests ├── __init__.py ├── api ├── overrides.py ├── test_healthcheck.py └── v1 │ ├── conftest.py │ ├── test_auth.py │ ├── test_me.py │ ├── test_roles.py │ ├── test_shops.py │ └── test_users.py ├── common.py ├── conftest.py ├── factories.py ├── models ├── test_role_models.py └── test_user_models.py └── service ├── test_roleservice.py ├── test_shopservice.py ├── test_tokenservice.py └── test_userservice.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .git 3 | .dockerignore 4 | .gitignore 5 | 6 | .cache/ 7 | .coverage 8 | .storybook-out/ 9 | .DS_Store 10 | .venv 11 | *.egg-info 12 | *.pyc 13 | *.log 14 | *.egg 15 | *.db 16 | *.pid 17 | MANIFEST 18 | test.conf 19 | pip-log.txt 20 | package.json 21 | /.artifacts 22 | /coverage/ 23 | /cover 24 | /build 25 | /env 26 | /tmp 27 | /node_modules/ 28 | /wheelhouse 29 | /test_cli/ 30 | .idea/ 31 | .pytest_cache/ 32 | .vscode/tags 33 | coverage.xml 34 | junit.xml 35 | *.codestyle.xml 36 | package-lock.json 37 | htmlcov 38 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yaml: -------------------------------------------------------------------------------- 1 | name: Price Aggregator 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | env: 7 | # FastAPI 8 | PROJECT_NAME: Backend 9 | FIRST_SUPERUSER: user@example.com 10 | FIRST_SUPERUSER_PASSWORD: reallysecretpass 11 | CORS_WHITELIST: '["http://localhost", "http://localhost:8000"]' 12 | USERS_OPEN_REGISTRATION: true 13 | # SMTP 14 | SMTP_USER: admin@backend.com 15 | SMTP_PASSWORD: reallysecretpass 16 | SMTP_HOST: mailhog 17 | SMTP_PORT: 1025 18 | SMTP_TLS: false 19 | SMTP_SSL: false 20 | # PostgreSQL 21 | POSTGRES_HOST: postgres 22 | POSTGRES_PORT: 5432 23 | POSTGRES_DB: backend 24 | POSTGRES_USER: postgres 25 | POSTGRES_PASSWORD: postgrespass 26 | steps: 27 | - uses: actions/checkout@v2 28 | - name: Check docker versions 29 | run: | 30 | docker-compose --version 31 | docker --version 32 | - name: Build the docker-compose stack 33 | working-directory: ./docker 34 | run: docker-compose up --detach --build 35 | - name: Lint 36 | working-directory: ./docker 37 | run: docker-compose run backend sh scripts/lint.sh 38 | - name: Test 39 | working-directory: ./docker 40 | run: docker-compose run backend sh scripts/test.sh 41 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | postgresql 6 | true 7 | org.postgresql.Driver 8 | jdbc:postgresql://localhost:5432/postgres 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/fullstack-fastapi-vuejs.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 21 | 22 | 23 | 25 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Oliver 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | # Indentation override for js(x), ts(x) and vue files 14 | [*.{js,jsx,ts,tsx,vue}] 15 | indent_size = 2 16 | indent_style = space 17 | 18 | # Indentation override for css related files 19 | [*.{css,styl,scss,less,sass}] 20 | indent_size = 2 21 | indent_style = space 22 | 23 | # Indentation override for html files 24 | [*.html] 25 | indent_size = 2 26 | indent_style = space 27 | 28 | # Trailing space override for markdown file 29 | [*.md] 30 | trim_trailing_whitespace = false 31 | 32 | # Indentation override for config files 33 | [*.{json,yml}] 34 | indent_size = 2 35 | indent_style = space 36 | -------------------------------------------------------------------------------- /client/.env.development: -------------------------------------------------------------------------------- 1 | # Base api 2 | # VUE_APP_BASE_API = 'https://vue-typescript-admin-mock-server.armour.now.sh/mock-api/v1/' 3 | VUE_APP_BASE_API = 'http://0.0.0.0:8000/api/v1/' 4 | 5 | # vue-cli uses the VUE_CLI_BABEL_TRANSPILE_MODULES environment variable, 6 | # to control whether the babel-plugin-dynamic-import-node plugin is enabled. 7 | # It only does one thing by converting all import() to require(). 8 | # This configuration can significantly increase the speed of hot updates, 9 | # when you have a large number of pages. 10 | # Detail: https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/babel-preset-app/index.js 11 | 12 | VUE_CLI_BABEL_TRANSPILE_MODULES = true 13 | -------------------------------------------------------------------------------- /client/.env.production: -------------------------------------------------------------------------------- 1 | # Base api 2 | # Remeber to change this to your production server address 3 | # Here I used my mock server for this project 4 | VUE_APP_BASE_API = 'https://vue-typescript-admin-mock-server.armour.now.sh/mock-api/v1/' 5 | -------------------------------------------------------------------------------- /client/.env.staging: -------------------------------------------------------------------------------- 1 | # Set to production for building optimization 2 | NODE_ENV = production 3 | 4 | # Base api 5 | VUE_APP_BASE_API = '/stage-api' 6 | 7 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/*.js 2 | src/assets 3 | tests/unit/coverage 4 | public/**/*.js 5 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | 'extends': [ 7 | 'plugin:vue/recommended', 8 | '@vue/standard', 9 | '@vue/typescript/recommended', 10 | ], 11 | parserOptions: { 12 | ecmaVersion: 2020 13 | }, 14 | rules: { 15 | '@typescript-eslint/camelcase': [2, {'properties': 'never'}], 16 | '@typescript-eslint/interface-name-prefix': ['error', { "prefixWithI": "always" }], 17 | '@typescript-eslint/no-explicit-any': 'off', 18 | '@typescript-eslint/member-delimiter-style': ['error', 19 | { 20 | 'multiline': { 21 | 'delimiter': 'none' 22 | }, 23 | 'singleline': { 24 | 'delimiter': 'comma' 25 | } 26 | }], 27 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 28 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 29 | 'space-before-function-paren': ['error', 'never'], 30 | 'vue/array-bracket-spacing': 'error', 31 | 'vue/arrow-spacing': 'error', 32 | 'vue/block-spacing': 'error', 33 | 'vue/brace-style': 'error', 34 | 'vue/camelcase': 'error', 35 | 'vue/comma-dangle': 'error', 36 | 'vue/component-name-in-template-casing': 'error', 37 | 'vue/eqeqeq': 'error', 38 | 'vue/key-spacing': 'error', 39 | 'vue/match-component-file-name': 'error', 40 | 'vue/object-curly-spacing': 'error', 41 | 'semi': ['error', 'always'], 42 | 'camelcase': [2, {'properties': 'never'}], 43 | 44 | }, 45 | overrides: [ 46 | { 47 | files: [ 48 | '**/__tests__/*.{j,t}s?(x)', 49 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 50 | ], 51 | env: { 52 | jest: true 53 | } 54 | } 55 | ] 56 | }; 57 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | /tests/**/coverage/ 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | .history 22 | .ionide 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw* 28 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chong Guo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | }; 6 | -------------------------------------------------------------------------------- /client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/android-chrome-maskable-192x192.png -------------------------------------------------------------------------------- /client/public/img/icons/android-chrome-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/android-chrome-maskable-512x512.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /client/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /client/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= webpackConfig.name %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Vue Typescript Admin", 3 | "short_name": "Vue Ts Admin", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "./index.html", 17 | "display": "standalone", 18 | "background_color": "#fff", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 20 | -------------------------------------------------------------------------------- /client/src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import http from '@/utils/request'; 2 | import {IUserData, ITokenData} from "@/api/types"; 3 | 4 | export const login = (data: any) => 5 | http.request({ 6 | url: '/auth/login', 7 | method: 'post', 8 | headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 9 | data, 10 | }); 11 | 12 | export const logout = (data?: any) => 13 | http.request({ 14 | url: '/auth/logout', 15 | method: 'post', 16 | data 17 | }); 18 | 19 | export const register = (data?: any) => 20 | http.request({ 21 | url: '/auth/register', 22 | method: 'post', 23 | data 24 | }); 25 | -------------------------------------------------------------------------------- /client/src/api/enums.ts: -------------------------------------------------------------------------------- 1 | export enum UserStatus { 2 | active = 'active', 3 | inactive = 'inactive', 4 | disabled = 'disabled', 5 | } 6 | -------------------------------------------------------------------------------- /client/src/api/me.ts: -------------------------------------------------------------------------------- 1 | import http from '@/utils/request' 2 | import {IUserData, IRoleData, IShopData} from './types'; 3 | 4 | export const getUserMe = (data?: any) => 5 | http.request({ 6 | url: '/me', 7 | method: 'get', 8 | data 9 | }); 10 | 11 | export const updateUserMe = (data: any) => 12 | http.request({ 13 | url: '/me', 14 | method: 'put', 15 | data 16 | }); 17 | 18 | export const getUserMeRoles = (data?: any) => 19 | http.request({ 20 | url: '/me/roles', 21 | method: 'get', 22 | data 23 | }); 24 | 25 | export const getUserMeShops = (data?: any) => 26 | http.request({ 27 | url: '/me/shops', 28 | method: 'get', 29 | data 30 | }); 31 | 32 | export const updateUserMeShops = (data?: any) => 33 | http.request({ 34 | url: '/me/shops', 35 | method: 'put', 36 | data 37 | }); 38 | -------------------------------------------------------------------------------- /client/src/api/roles.ts: -------------------------------------------------------------------------------- 1 | import http from '@/utils/request' 2 | import { IRoleData, IRoleUpdate, IRoleCreate } from './types'; 3 | 4 | export const getRoles = (params: any) => 5 | http.request({ 6 | url: '/roles', 7 | method: 'get', 8 | params 9 | }); 10 | 11 | export const createRole = (data: IRoleCreate) => 12 | http.request({ 13 | url: '/roles', 14 | method: 'post', 15 | data 16 | }); 17 | 18 | export const updateRole = (id: number, data: IRoleUpdate) => 19 | http.request({ 20 | url: `/roles/${id}`, 21 | method: 'put', 22 | data 23 | }); 24 | 25 | export const deleteRole = (id: number) => 26 | http.request({ 27 | url: `/roles/${id}`, 28 | method: 'delete', 29 | }); 30 | -------------------------------------------------------------------------------- /client/src/api/shops.ts: -------------------------------------------------------------------------------- 1 | import http from '@/utils/request' 2 | import {IShopData, IShopUpdate, IShopCreate, IShopListings} from './types'; 3 | 4 | export const getShops = (params: any) => 5 | http.request({ 6 | url: '/shops', 7 | method: 'get', 8 | params 9 | }); 10 | 11 | export const createShop = (data: IShopCreate) => 12 | http.request({ 13 | url: '/shops', 14 | method: 'post', 15 | data 16 | }); 17 | 18 | export const updateShop = (id: number, data: IShopUpdate) => 19 | http.request({ 20 | url: `/shops/${id}`, 21 | method: 'put', 22 | data 23 | }); 24 | 25 | export const deleteShop = (id: number) => 26 | http.request({ 27 | url: `/shops/${id}`, 28 | method: 'delete', 29 | }); 30 | 31 | export const getShopListings = (params: any) => 32 | http.request({ 33 | url: '/shops/listings/', 34 | method: 'get', 35 | params 36 | }); 37 | -------------------------------------------------------------------------------- /client/src/api/types.d.ts: -------------------------------------------------------------------------------- 1 | import { UserStatus } from './enums'; 2 | 3 | export interface ITokenData { 4 | access_token: string 5 | token_type: string 6 | } 7 | 8 | export interface IUserData { 9 | id: number 10 | email: string 11 | first_name: string 12 | last_name: string 13 | status: UserStatus 14 | created_at: Date 15 | updated_at: Date 16 | } 17 | 18 | export interface IUserCreate { 19 | email: string 20 | first_name: string 21 | last_name: string 22 | password: string 23 | status?: UserStatus 24 | } 25 | 26 | export interface IUserUpdate { 27 | email?: string 28 | first_name?: string 29 | last_name?: string 30 | password?: string 31 | status?: UserStatus 32 | } 33 | 34 | export interface IRoleData { 35 | id: number 36 | name: string 37 | description: string 38 | created_at: Date 39 | updated_at: Date 40 | } 41 | 42 | export interface IRoleCreate { 43 | name: string 44 | description: string 45 | } 46 | 47 | export interface IRoleUpdate { 48 | name?: string 49 | description?: string 50 | } 51 | 52 | export interface IShopData { 53 | id: number 54 | created_at: Date 55 | updated_at: Date 56 | name: string 57 | url: string 58 | query_url: string 59 | render_javascript: boolean 60 | listing_page_selector: Object 61 | } 62 | 63 | export interface IShopCreate { 64 | name: string 65 | url: string 66 | query_url: string 67 | render_javascript: boolean 68 | listing_page_selector: Object 69 | } 70 | 71 | export interface IShopUpdate { 72 | name?: string 73 | url?: string 74 | query_url?: string 75 | render_javascript?: boolean 76 | listing_page_selector?: Object 77 | } 78 | 79 | export interface ScrapedItem { 80 | name: string 81 | url: string 82 | price: string 83 | price_per_unit: string 84 | image_url: string 85 | } 86 | 87 | export interface IShopListings { 88 | id: number 89 | name: string 90 | listings: ScrapedItem[] 91 | } 92 | -------------------------------------------------------------------------------- /client/src/api/users.ts: -------------------------------------------------------------------------------- 1 | import http from '@/utils/request' 2 | import { IUserData, IUserCreate, IUserUpdate, IRoleData } from './types'; 3 | 4 | export const getUsers = (params: any) => 5 | http.request({ 6 | url: '/users', 7 | method: 'get', 8 | params 9 | }); 10 | 11 | export const createUser = (data: IUserCreate) => 12 | http.request({ 13 | url: `/users`, 14 | method: 'post', 15 | data 16 | }); 17 | 18 | export const updateUser = (id: number, data: IUserUpdate) => 19 | http.request({ 20 | url: `/users/${id}`, 21 | method: 'put', 22 | data 23 | }); 24 | 25 | export const deleteUser = (id: number) => 26 | http.request({ 27 | url: `/users/${id}`, 28 | method: 'delete', 29 | }); 30 | 31 | export const getUserRoles = (id: number) => 32 | http.request({ 33 | url: `/users/${id}/roles`, 34 | method: 'get', 35 | }); 36 | 37 | export const updateUserRoles = (id: number) => 38 | http.request({ 39 | url: `/users/${id}/roles`, 40 | method: 'get', 41 | }); 42 | -------------------------------------------------------------------------------- /client/src/assets/401-images/401.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/src/assets/401-images/401.gif -------------------------------------------------------------------------------- /client/src/assets/404-images/404-cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/src/assets/404-images/404-cloud.png -------------------------------------------------------------------------------- /client/src/assets/404-images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/src/assets/404-images/404.png -------------------------------------------------------------------------------- /client/src/assets/custom-theme/fonts/element-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/src/assets/custom-theme/fonts/element-icons.ttf -------------------------------------------------------------------------------- /client/src/assets/custom-theme/fonts/element-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/client/src/assets/custom-theme/fonts/element-icons.woff -------------------------------------------------------------------------------- /client/src/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | 29 | 38 | -------------------------------------------------------------------------------- /client/src/components/Pagination/index.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 68 | 69 | 79 | -------------------------------------------------------------------------------- /client/src/components/Screenfull/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 52 | -------------------------------------------------------------------------------- /client/src/icons/README.md: -------------------------------------------------------------------------------- 1 | # vue-svgicon 2 | 3 | ## English 4 | 5 | * All svg components were generated by `vue-svgicon` using svg files 6 | * After you adding new svg files into `icons/svg` folder, run `yarn svg` to regerenrate all svg components (before this, you should have `vue-svgicon` installed globally or use `npx`) 7 | * See details at: [https://github.com/MMF-FE/vue-svgicon](https://github.com/MMF-FE/vue-svgicon) 8 | 9 | ## 中文 10 | 11 | * 所有的 svg 组件都是由 `vue-svgicon` 生成的 12 | * 每当在 `icons/svg` 文件夹内添加 icon 之后,可以通过执行 `yarn svg` 来重新生成所有组件 (在此之前需要全局安装 `vue-svgicon` 或使用 `npx`) 13 | * 详细文档请见:[https://github.com/MMF-FE/vue-svgicon](https://github.com/MMF-FE/vue-svgicon) 14 | -------------------------------------------------------------------------------- /client/src/icons/components/404.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | '404': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/back-top.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'back-top': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/bug.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'bug': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/chart.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'chart': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/clipboard.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'clipboard': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/component.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'component': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/dashboard.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'dashboard': { 7 | width: 128, 8 | height: 100, 9 | viewBox: '0 0 128 100', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/documentation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'documentation': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/drag.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'drag': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/edit.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'edit': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/education.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'education': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/email.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'email': { 7 | width: 128, 8 | height: 96, 9 | viewBox: '0 0 128 96', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/example.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'example': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/excel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'excel': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/exit-fullscreen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'exit-fullscreen': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/eye-off.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'eye-off': { 7 | width: 128, 8 | height: 64, 9 | viewBox: '0 0 128 64', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/eye-on.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'eye-on': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 1024 1024', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/form.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'form': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/fullscreen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'fullscreen': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/guide-2.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'guide-2': { 7 | width: 1000, 8 | height: 1000, 9 | viewBox: '0 0 1000 1000', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/guide.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'guide': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/hamburger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'hamburger': { 7 | width: 64, 8 | height: 64, 9 | viewBox: '0 0 1024 1024', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/icon.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'icon': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | import './404' 3 | import './back-top' 4 | import './bug' 5 | import './chart' 6 | import './clipboard' 7 | import './component' 8 | import './dashboard' 9 | import './documentation' 10 | import './drag' 11 | import './edit' 12 | import './education' 13 | import './email' 14 | import './example' 15 | import './excel' 16 | import './exit-fullscreen' 17 | import './eye-off' 18 | import './eye-on' 19 | import './form' 20 | import './fullscreen' 21 | import './guide-2' 22 | import './guide' 23 | import './hamburger' 24 | import './icon' 25 | import './international' 26 | import './language' 27 | import './like' 28 | import './link' 29 | import './list' 30 | import './lock' 31 | import './message' 32 | import './money' 33 | import './nested' 34 | import './password' 35 | import './pdf' 36 | import './people' 37 | import './peoples' 38 | import './qq' 39 | import './search' 40 | import './shopping' 41 | import './size' 42 | import './skill' 43 | import './star' 44 | import './tab' 45 | import './table' 46 | import './theme' 47 | import './tree-table' 48 | import './tree' 49 | import './user' 50 | import './wechat' 51 | import './zip' 52 | -------------------------------------------------------------------------------- /client/src/icons/components/international.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'international': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/language.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'language': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/like.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'like': { 7 | width: 24, 8 | height: 24, 9 | viewBox: '0 0 24 24', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/link.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'link': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/list.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'list': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/lock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'lock': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/message.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'message': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/money.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'money': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/nested.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'nested': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/password.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'password': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/pdf.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'pdf': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 1024 1024', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/people.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'people': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/peoples.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'peoples': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/search.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'search': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/shopping.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'shopping': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/size.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'size': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/skill.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'skill': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/star.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'star': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/tab.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'tab': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/table.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'table': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/theme.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'theme': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/tree-table.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'tree-table': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/tree.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'tree': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/user.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'user': { 7 | width: 130, 8 | height: 130, 9 | viewBox: '0 0 130 130', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/wechat.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'wechat': { 7 | width: 128, 8 | height: 110, 9 | viewBox: '0 0 128 110', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/components/zip.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | // @ts-ignore 4 | import icon from 'vue-svgicon' 5 | icon.register({ 6 | 'zip': { 7 | width: 128, 8 | height: 128, 9 | viewBox: '0 0 128 128', 10 | data: '' 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /client/src/icons/svg/404.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/back-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/bug.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/chart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/clipboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/component.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/dashboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/documentation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/drag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/education.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/email.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/example.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/excel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/exit-fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/eye-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/eye-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/src/icons/svg/form.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/guide-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/guide.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/international.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/language.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/like.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/icons/svg/list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/lock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/message.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/money.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/nested.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/password.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/pdf.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/people.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/peoples.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/qq.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/shopping.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/size.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/skill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/table.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/theme.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/tree-table.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/tree.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/icons/svg/wechat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/icons/svg/zip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/layout/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | 32 | 58 | -------------------------------------------------------------------------------- /client/src/layout/components/Sidebar/SidebarItemLink.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /client/src/layout/components/Sidebar/SidebarLogo.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 49 | 50 | 99 | -------------------------------------------------------------------------------- /client/src/layout/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AppMain } from './AppMain.vue' 2 | export { default as Navbar } from './Navbar/index.vue' 3 | export { default as Settings } from './Settings/index.vue' 4 | export { default as Sidebar } from './Sidebar/index.vue' 5 | export { default as TagsView } from './TagsView/index.vue' 6 | -------------------------------------------------------------------------------- /client/src/layout/mixin/resize.ts: -------------------------------------------------------------------------------- 1 | import { Component, Vue, Watch } from 'vue-property-decorator' 2 | import { AppModule, DeviceType } from '@/store/modules/app' 3 | 4 | const WIDTH = 992; // refer to Bootstrap's responsive design 5 | 6 | @Component({ 7 | name: 'ResizeMixin' 8 | }) 9 | export default class extends Vue { 10 | get device() { 11 | return AppModule.device 12 | } 13 | 14 | get sidebar() { 15 | return AppModule.sidebar 16 | } 17 | 18 | @Watch('$route') 19 | private onRouteChange() { 20 | if (this.device === DeviceType.Mobile && this.sidebar.opened) { 21 | AppModule.CloseSideBar(false) 22 | } 23 | } 24 | 25 | beforeMount() { 26 | window.addEventListener('resize', this.resizeHandler) 27 | } 28 | 29 | mounted() { 30 | const isMobile = this.isMobile(); 31 | if (isMobile) { 32 | AppModule.ToggleDevice(DeviceType.Mobile); 33 | AppModule.CloseSideBar(true) 34 | } 35 | } 36 | 37 | beforeDestroy() { 38 | window.removeEventListener('resize', this.resizeHandler) 39 | } 40 | 41 | private isMobile() { 42 | const rect = document.body.getBoundingClientRect(); 43 | return rect.width - 1 < WIDTH 44 | } 45 | 46 | private resizeHandler() { 47 | if (!document.hidden) { 48 | const isMobile = this.isMobile(); 49 | AppModule.ToggleDevice(isMobile ? DeviceType.Mobile : DeviceType.Desktop); 50 | if (isMobile) { 51 | AppModule.CloseSideBar(true) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue, { DirectiveOptions } from 'vue' 2 | 3 | import 'normalize.css' 4 | import ElementUI from 'element-ui' 5 | import SvgIcon from 'vue-svgicon' 6 | 7 | import '@/styles/element-variables.scss' 8 | import '@/styles/index.scss' 9 | 10 | import App from '@/App.vue' 11 | import store from '@/store' 12 | import { AppModule } from '@/store/modules/app' 13 | import router from '@/router' 14 | import '@/icons/components' 15 | import '@/permission' 16 | import '@/utils/error-log' 17 | import '@/pwa/register-service-worker' 18 | 19 | Vue.use(ElementUI, { 20 | size: AppModule.size, // Set element-ui default size 21 | }); 22 | 23 | Vue.use(SvgIcon, { 24 | tagName: 'svg-icon', 25 | defaultWidth: '1em', 26 | defaultHeight: '1em' 27 | }); 28 | 29 | Vue.config.productionTip = false; 30 | 31 | new Vue({ 32 | router, 33 | store, 34 | render: (h) => h(App) 35 | }).$mount('#app'); 36 | -------------------------------------------------------------------------------- /client/src/pwa/components/ServiceWorkerUpdatePopup.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 68 | -------------------------------------------------------------------------------- /client/src/pwa/register-service-worker.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker' 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log( 9 | 'App is being served from cache by a service worker.\n' + 10 | 'For more details, visit https://goo.gl/AFskqB' 11 | ) 12 | }, 13 | registered(registration) { 14 | console.log('Service worker has been registered.'); 15 | // Routinely check for app updates by testing for a new service worker. 16 | setInterval(() => { 17 | registration.update() 18 | }, 1000 * 60 * 60) // hourly checks 19 | }, 20 | cached() { 21 | console.log('Content has been cached for offline use.') 22 | }, 23 | updatefound() { 24 | console.log('New content is downloading.') 25 | }, 26 | updated(registration) { 27 | console.log('New content is available; please refresh.'); 28 | // Add a custom event and dispatch it. 29 | // Used to display of a 'refresh' banner following a service worker update. 30 | // Set the event payload to the service worker registration object. 31 | document.dispatchEvent( 32 | new CustomEvent('swUpdated', { detail: registration }) 33 | ) 34 | }, 35 | offline() { 36 | console.log('No internet connection found. App is running in offline mode.') 37 | }, 38 | error(error) { 39 | console.error('Error during service worker registration:', error) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /client/src/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // This is the code piece that GenerateSW mode can't provide for us. 2 | // This code listens for the user's confirmation to update the app. 3 | self.addEventListener('message', (e) => { 4 | if (e.data) { 5 | if (e.data === 'skipWaiting') { 6 | self.skipWaiting(); 7 | } 8 | } 9 | }); 10 | 11 | /* eslint-disable no-undef */ 12 | workbox.core.clientsClaim(); 13 | workbox.precaching.precacheAndRoute(self.__precacheManifest || []); 14 | -------------------------------------------------------------------------------- /client/src/router/modules/roles.ts: -------------------------------------------------------------------------------- 1 | import { RouteConfig } from "vue-router"; 2 | import Layout from "@/layout/index.vue"; 3 | 4 | const rolesRouter: RouteConfig = { 5 | path: "/roles", 6 | component: Layout, 7 | redirect: "list", 8 | name: "Roles", 9 | meta: { 10 | title: "Roles", 11 | icon: "password", 12 | roles: ["admin"] 13 | }, 14 | children: [ 15 | { 16 | path: "list", 17 | component: () => 18 | import(/* webpackChunkName: "role-list" */ "@/views/roles/ListRoles.vue"), 19 | name: "ListRoles", 20 | meta: { title: "List Roles", icon: "list" } 21 | }, 22 | { 23 | path: "create", 24 | component: () => 25 | import( 26 | /* webpackChunkName: "role-create" */ "@/views/roles/CreateRole.vue" 27 | ), 28 | name: "CreateRole", 29 | meta: { title: "Create Role", icon: "form" } 30 | }, 31 | { 32 | path: "edit/:id", 33 | component: () => 34 | import(/* webpackChunkName: "role-edit" */ "@/views/roles/ListRoles.vue"), 35 | name: "EditRole", 36 | meta: { title: "Edit Role", icon: "form", hidden: true } 37 | } 38 | ] 39 | }; 40 | 41 | export default rolesRouter; 42 | -------------------------------------------------------------------------------- /client/src/router/modules/shops.ts: -------------------------------------------------------------------------------- 1 | import { RouteConfig } from "vue-router"; 2 | import Layout from "@/layout/index.vue"; 3 | 4 | const shopsRouter: RouteConfig = { 5 | path: "/shops", 6 | component: Layout, 7 | redirect: "list", 8 | name: "shops", 9 | meta: { 10 | title: "Shops", 11 | icon: "shopping", 12 | roles: ["admin"] 13 | }, 14 | children: [ 15 | { 16 | path: "list", 17 | component: () => 18 | import(/* webpackChunkName: "shop-list" */ "@/views/shops/ListShops.vue"), 19 | name: "Listshops", 20 | meta: { title: "List shops", icon: "list" } 21 | }, 22 | { 23 | path: "create", 24 | component: () => 25 | import( 26 | /* webpackChunkName: "shop-create" */ "@/views/shops/CreateShop.vue" 27 | ), 28 | name: "Createshop", 29 | meta: { title: "Create shop", icon: "form" } 30 | }, 31 | { 32 | path: "edit/:id", 33 | component: () => 34 | import(/* webpackChunkName: "shop-edit" */ "@/views/shops/EditShop.vue"), 35 | name: "Editshop", 36 | meta: { title: "Edit shop", icon: "form", hidden: true } 37 | } 38 | ] 39 | }; 40 | 41 | export default shopsRouter; 42 | -------------------------------------------------------------------------------- /client/src/router/modules/users.ts: -------------------------------------------------------------------------------- 1 | import { RouteConfig } from "vue-router"; 2 | import Layout from "@/layout/index.vue"; 3 | 4 | const usersRouter: RouteConfig = { 5 | path: "/users", 6 | component: Layout, 7 | redirect: "list", 8 | name: "Users", 9 | meta: { 10 | title: "Users", 11 | icon: "user", 12 | roles: ["admin"] 13 | }, 14 | children: [ 15 | { 16 | path: "list", 17 | component: () => 18 | import(/* webpackChunkName: "user-list" */ "@/views/users/ListUsers.vue"), 19 | name: "ListUsers", 20 | meta: { title: "List Users", icon: "list" } 21 | }, 22 | { 23 | path: "create", 24 | component: () => 25 | import( 26 | /* webpackChunkName: "user-create" */ "@/views/users/CreateUser.vue" 27 | ), 28 | name: "CreateUser", 29 | meta: { title: "Create User", icon: "form" } 30 | }, 31 | { 32 | path: "edit/:id", 33 | component: () => 34 | import(/* webpackChunkName: "user-edit" */ "@/views/users/EditUser.vue"), 35 | name: "EditUser", 36 | meta: { title: "Edit User", icon: "form", hidden: true } 37 | } 38 | ] 39 | }; 40 | 41 | export default usersRouter; 42 | -------------------------------------------------------------------------------- /client/src/settings.ts: -------------------------------------------------------------------------------- 1 | interface ISettings { 2 | title: string // Overrides the default title 3 | showSettings: boolean // Controls settings panel display 4 | showTagsView: boolean // Controls tagsview display 5 | showSidebarLogo: boolean // Controls siderbar logo display 6 | fixedHeader: boolean // If true, will fix the header component 7 | errorLog: string[] // The env to enable the errorlog component, default 'production' only 8 | sidebarTextTheme: boolean // If true, will change active text color for sidebar based on theme 9 | devServerPort: number // Port number for webpack-dev-server 10 | mockServerPort: number // Port number for mock server 11 | } 12 | 13 | // You can customize below settings :) 14 | const settings: ISettings = { 15 | title: 'FastAPI Admin Template', 16 | showSettings: true, 17 | showTagsView: true, 18 | fixedHeader: false, 19 | showSidebarLogo: false, 20 | errorLog: ['production'], 21 | sidebarTextTheme: true, 22 | devServerPort: 9527, 23 | mockServerPort: 9528 24 | }; 25 | 26 | export default settings 27 | -------------------------------------------------------------------------------- /client/src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | 6 | declare module 'element-ui/lib/locale/lang/*' { 7 | export const elementLocale: any 8 | } 9 | 10 | declare module '*.gif' { 11 | export const gif: any 12 | } 13 | 14 | // TODO: remove this part after vue-count-to has its typescript file 15 | declare module 'vue-count-to' 16 | 17 | // TODO: remove this part after vuedraggable has its typescript file 18 | declare module 'vuedraggable' 19 | 20 | // TODO: remove this part after vue2-dropzone has its typescript file 21 | declare module 'vue2-dropzone' 22 | 23 | // TODO: remove this part after vue-image-crop-upload has its typescript file 24 | declare module 'vue-image-crop-upload' 25 | 26 | // TODO: remove this part after vue-splitpane has its typescript file 27 | declare module 'vue-splitpane' 28 | -------------------------------------------------------------------------------- /client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import { IAppState } from './modules/app' 4 | import { IUserMeState } from './modules/me' 5 | import { ITagsViewState } from './modules/tags-view' 6 | import { IErrorLogState } from './modules/error-log' 7 | import { IPermissionState } from './modules/permission' 8 | import { ISettingsState } from './modules/settings' 9 | import { IUsersState } from "@/store/modules/users" 10 | import { IRolesState } from "@/store/modules/roles" 11 | import { IShopsState } from "@/store/modules/shops" 12 | 13 | Vue.use(Vuex); 14 | 15 | export interface IRootState { 16 | app: IAppState 17 | userMe: IUserMeState 18 | tagsView: ITagsViewState 19 | errorLog: IErrorLogState 20 | permission: IPermissionState 21 | settings: ISettingsState 22 | users: IUsersState 23 | roles: IRolesState 24 | shops: IShopsState 25 | } 26 | 27 | // Declare empty store first, dynamically register all modules later. 28 | export default new Vuex.Store({}) 29 | -------------------------------------------------------------------------------- /client/src/store/modules/app.ts: -------------------------------------------------------------------------------- 1 | import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators' 2 | import { getSidebarStatus, getSize, setSidebarStatus, setSize } from '@/utils/cookies' 3 | import store from '@/store' 4 | 5 | export enum DeviceType { 6 | Mobile, 7 | Desktop, 8 | } 9 | 10 | export interface IAppState { 11 | device: DeviceType 12 | sidebar: { 13 | opened: boolean 14 | withoutAnimation: boolean 15 | } 16 | size: string 17 | } 18 | 19 | @Module({ dynamic: true, store, name: 'app' }) 20 | class App extends VuexModule implements IAppState { 21 | public sidebar = { 22 | opened: getSidebarStatus() !== 'closed', 23 | withoutAnimation: false 24 | }; 25 | 26 | public device = DeviceType.Desktop; 27 | public size = getSize() || 'medium'; 28 | 29 | @Mutation 30 | private TOGGLE_SIDEBAR(withoutAnimation: boolean) { 31 | this.sidebar.opened = !this.sidebar.opened; 32 | this.sidebar.withoutAnimation = withoutAnimation; 33 | if (this.sidebar.opened) { 34 | setSidebarStatus('opened') 35 | } else { 36 | setSidebarStatus('closed') 37 | } 38 | } 39 | 40 | @Mutation 41 | private CLOSE_SIDEBAR(withoutAnimation: boolean) { 42 | this.sidebar.opened = false; 43 | this.sidebar.withoutAnimation = withoutAnimation; 44 | setSidebarStatus('closed') 45 | } 46 | 47 | @Mutation 48 | private TOGGLE_DEVICE(device: DeviceType) { 49 | this.device = device 50 | } 51 | 52 | @Mutation 53 | private SET_SIZE(size: string) { 54 | this.size = size; 55 | setSize(this.size) 56 | } 57 | 58 | @Action 59 | public ToggleSideBar(withoutAnimation: boolean) { 60 | this.TOGGLE_SIDEBAR(withoutAnimation) 61 | } 62 | 63 | @Action 64 | public CloseSideBar(withoutAnimation: boolean) { 65 | this.CLOSE_SIDEBAR(withoutAnimation) 66 | } 67 | 68 | @Action 69 | public ToggleDevice(device: DeviceType) { 70 | this.TOGGLE_DEVICE(device) 71 | } 72 | 73 | @Action 74 | public SetSize(size: string) { 75 | this.SET_SIZE(size) 76 | } 77 | } 78 | 79 | export const AppModule = getModule(App); 80 | -------------------------------------------------------------------------------- /client/src/store/modules/error-log.ts: -------------------------------------------------------------------------------- 1 | import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators' 2 | import store from '@/store' 3 | 4 | interface IErrorLog { 5 | err: Error 6 | vm: any 7 | info: string 8 | url: string 9 | } 10 | 11 | export interface IErrorLogState { 12 | logs: IErrorLog[] 13 | } 14 | 15 | @Module({ dynamic: true, store, name: 'errorLog' }) 16 | class ErrorLog extends VuexModule implements IErrorLogState { 17 | public logs: IErrorLog[] = []; 18 | 19 | @Mutation 20 | private ADD_ERROR_LOG(log: IErrorLog) { 21 | this.logs.push(log) 22 | } 23 | 24 | @Mutation 25 | private CLEAR_ERROR_LOG() { 26 | this.logs.splice(0) 27 | } 28 | 29 | @Action 30 | public AddErrorLog(log: IErrorLog) { 31 | this.ADD_ERROR_LOG(log) 32 | } 33 | 34 | @Action 35 | public ClearErrorLog() { 36 | this.CLEAR_ERROR_LOG() 37 | } 38 | } 39 | 40 | export const ErrorLogModule = getModule(ErrorLog); 41 | -------------------------------------------------------------------------------- /client/src/store/modules/permission.ts: -------------------------------------------------------------------------------- 1 | import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators' 2 | import { RouteConfig } from 'vue-router' 3 | import { asyncRoutes, constantRoutes } from '@/router' 4 | import store from '@/store' 5 | 6 | const hasPermission = (roles: string[], route: RouteConfig) => { 7 | if (route.meta && route.meta.roles) { 8 | return roles.some(role => route.meta.roles.includes(role)) 9 | } else { 10 | return true 11 | } 12 | }; 13 | 14 | export const filterAsyncRoutes = (routes: RouteConfig[], roles: string[]) => { 15 | const res: RouteConfig[] = []; 16 | routes.forEach(route => { 17 | const r = { ...route }; 18 | if (hasPermission(roles, r)) { 19 | if (r.children) { 20 | r.children = filterAsyncRoutes(r.children, roles) 21 | } 22 | res.push(r) 23 | } 24 | }); 25 | return res 26 | }; 27 | 28 | export interface IPermissionState { 29 | routes: RouteConfig[] 30 | dynamicRoutes: RouteConfig[] 31 | } 32 | 33 | @Module({ dynamic: true, store, name: 'permission' }) 34 | class Permission extends VuexModule implements IPermissionState { 35 | public routes: RouteConfig[] = []; 36 | public dynamicRoutes: RouteConfig[] = []; 37 | 38 | @Mutation 39 | private SET_ROUTES(routes: RouteConfig[]) { 40 | this.routes = constantRoutes.concat(routes); 41 | this.dynamicRoutes = routes 42 | } 43 | 44 | @Action 45 | public GenerateRoutes(roles: string[]) { 46 | let accessedRoutes; 47 | if (roles.includes('admin')) { 48 | accessedRoutes = asyncRoutes 49 | } else { 50 | accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) 51 | } 52 | this.SET_ROUTES(accessedRoutes) 53 | } 54 | } 55 | 56 | export const PermissionModule = getModule(Permission); 57 | -------------------------------------------------------------------------------- /client/src/store/modules/roles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | getModule, 4 | Module, 5 | Mutation, 6 | VuexModule 7 | } from "vuex-module-decorators"; 8 | import store from "@/store"; 9 | import { getRoles, updateRole, deleteRole, createRole } from "@/api/roles"; 10 | import { IRoleData, IRoleCreate, IRoleUpdate } from "@/api/types"; 11 | 12 | export interface IRolesState { 13 | roles?: IRoleData[]; 14 | } 15 | 16 | @Module({ dynamic: true, store, name: "roles" }) 17 | class Roles extends VuexModule implements IRolesState { 18 | public roles: IRoleData[] = []; 19 | 20 | @Mutation 21 | private SET_ROLES(payload: IRoleData[]) { 22 | this.roles = payload; 23 | } 24 | 25 | @Mutation 26 | private SET_ROLE(payload: IRoleData) { 27 | const roles = this.roles.filter( 28 | (role: IRoleData) => role.id !== payload.id 29 | ); 30 | roles.push(payload); 31 | this.roles = roles; 32 | } 33 | 34 | @Mutation 35 | private DELETE_ROLE(id: number) { 36 | this.roles = this.roles.filter((user: IRoleData) => user.id !== id); 37 | } 38 | 39 | @Action 40 | public async GetRoles(params: any) { 41 | const { data } = await getRoles(params); 42 | this.SET_ROLES(data); 43 | } 44 | 45 | @Action 46 | public async CreateRole(createData: IRoleCreate) { 47 | const { data } = await createRole(createData); 48 | this.SET_ROLE(data); 49 | } 50 | 51 | @Action 52 | public async UpdateRole(id: number, updateData: IRoleUpdate) { 53 | const { data } = await updateRole(id, updateData); 54 | this.SET_ROLE(data); 55 | } 56 | 57 | @Action 58 | public async DeleteRole(id: number) { 59 | await deleteRole(id); 60 | this.DELETE_ROLE(id); 61 | } 62 | } 63 | 64 | export const RolesModule = getModule(Roles); 65 | -------------------------------------------------------------------------------- /client/src/store/modules/settings.ts: -------------------------------------------------------------------------------- 1 | import { VuexModule, Module, Mutation, Action, getModule } from 'vuex-module-decorators' 2 | import store from '@/store' 3 | import elementVariables from '@/styles/element-variables.scss' 4 | import defaultSettings from '@/settings' 5 | 6 | export interface ISettingsState { 7 | theme: string 8 | fixedHeader: boolean 9 | showSettings: boolean 10 | showTagsView: boolean 11 | showSidebarLogo: boolean 12 | sidebarTextTheme: boolean 13 | } 14 | 15 | @Module({ dynamic: true, store, name: 'settings' }) 16 | class Settings extends VuexModule implements ISettingsState { 17 | public theme = elementVariables.theme; 18 | public fixedHeader = defaultSettings.fixedHeader; 19 | public showSettings = defaultSettings.showSettings; 20 | public showTagsView = defaultSettings.showTagsView; 21 | public showSidebarLogo = defaultSettings.showSidebarLogo; 22 | public sidebarTextTheme = defaultSettings.sidebarTextTheme; 23 | 24 | @Mutation 25 | private CHANGE_SETTING(payload: { key: string, value: any }) { 26 | const { key, value } = payload; 27 | if (Object.prototype.hasOwnProperty.call(this, key)) { 28 | (this as any)[key] = value 29 | } 30 | } 31 | 32 | @Action 33 | public ChangeSetting(payload: { key: string, value: any}) { 34 | this.CHANGE_SETTING(payload) 35 | } 36 | } 37 | 38 | export const SettingsModule = getModule(Settings); 39 | -------------------------------------------------------------------------------- /client/src/store/modules/shops.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | getModule, 4 | Module, 5 | Mutation, 6 | VuexModule 7 | } from "vuex-module-decorators"; 8 | import store from "@/store"; 9 | import { getShops, createShop, updateShop, deleteShop } from "@/api/shops"; 10 | import { IShopData, IShopCreate, IShopUpdate } from "@/api/types"; 11 | 12 | export interface IShopsState { 13 | shops?: IShopData[]; 14 | } 15 | 16 | @Module({ dynamic: true, store, name: "shops" }) 17 | class Shops extends VuexModule implements IShopsState { 18 | public shops: IShopData[] = []; 19 | 20 | @Mutation 21 | private SET_SHOPS(payload: IShopData[]) { 22 | this.shops = payload; 23 | } 24 | 25 | @Mutation 26 | private SET_SHOP(payload: IShopData) { 27 | const shops = this.shops.filter( 28 | (shop: IShopData) => shop.id !== payload.id 29 | ); 30 | shops.push(payload); 31 | this.shops = shops; 32 | } 33 | 34 | @Mutation 35 | private DELETE_SHOP(id: number) { 36 | this.shops = this.shops.filter((shop: IShopData) => shop.id !== id); 37 | } 38 | 39 | @Action 40 | public async GetShops(params: any) { 41 | const { data } = await getShops(params); 42 | this.SET_SHOPS(data); 43 | } 44 | 45 | @Action 46 | public async CreateShop(createData: IShopCreate) { 47 | const { data } = await createShop(createData); 48 | this.SET_SHOP(data); 49 | } 50 | 51 | @Action 52 | public async UpdateShop(id: number, updateData: IShopUpdate) { 53 | const { data } = await updateShop(id, updateData); 54 | this.SET_SHOP(data); 55 | } 56 | 57 | @Action 58 | public async DeleteShop(id: number) { 59 | await deleteShop(id); 60 | this.DELETE_SHOP(id); 61 | } 62 | } 63 | 64 | export const ShopsModule = getModule(Shops); 65 | -------------------------------------------------------------------------------- /client/src/store/modules/users.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | getModule, 4 | Module, 5 | Mutation, 6 | VuexModule 7 | } from "vuex-module-decorators"; 8 | import { deleteUser, getUsers, updateUser, createUser } from "@/api/users"; 9 | import store from "@/store"; 10 | import { IUserCreate, IUserData, IUserUpdate } from "@/api/types"; 11 | 12 | export interface IUsersState { 13 | users: IUserData[]; 14 | } 15 | 16 | @Module({ dynamic: true, store, name: "users" }) 17 | class Users extends VuexModule implements IUsersState { 18 | public users: IUserData[] = []; 19 | 20 | @Mutation 21 | private SET_USERS(payload: IUserData[]) { 22 | this.users = payload; 23 | } 24 | 25 | @Mutation 26 | private SET_USER(payload: IUserData) { 27 | const users = this.users.filter( 28 | (user: IUserData) => user.id !== payload.id 29 | ); 30 | users.push(payload); 31 | this.users = users; 32 | } 33 | 34 | @Mutation 35 | private DELETE_USER(id: number) { 36 | this.users = this.users.filter((user: IUserData) => user.id !== id); 37 | } 38 | 39 | @Action 40 | public async GetUsers(params: any) { 41 | const { data } = await getUsers(params); 42 | this.SET_USERS(data); 43 | } 44 | 45 | @Action 46 | public async CreateUser(createData: IUserCreate) { 47 | const { data } = await createUser(createData); 48 | this.SET_USER(data); 49 | } 50 | 51 | @Action 52 | public async UpdateUser(id: number, updateData: IUserUpdate) { 53 | const { data } = await updateUser(id, updateData); 54 | this.SET_USER(data); 55 | } 56 | 57 | @Action 58 | public async DeleteUser(id: number) { 59 | await deleteUser(id); 60 | this.DELETE_USER(id); 61 | } 62 | } 63 | 64 | export const UsersModule = getModule(Users); 65 | -------------------------------------------------------------------------------- /client/src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | /* Mixins */ 2 | @mixin clearfix { 3 | &:after { 4 | content: ""; 5 | display: table; 6 | clear: both; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/styles/_svgicon.scss: -------------------------------------------------------------------------------- 1 | /* Recommended css code for vue-svgicon */ 2 | .svg-icon { 3 | display: inline-block; 4 | width: 16px; 5 | height: 16px; 6 | color: inherit; 7 | fill: none; 8 | stroke: currentColor; 9 | vertical-align: -0.15em; 10 | } 11 | 12 | .svg-fill { 13 | fill: currentColor; 14 | stroke: none; 15 | } 16 | 17 | .svg-up { 18 | transform: rotate(0deg); 19 | } 20 | 21 | .svg-right { 22 | transform: rotate(90deg); 23 | } 24 | 25 | .svg-down { 26 | transform: rotate(180deg); 27 | } 28 | 29 | .svg-left { 30 | transform: rotate(-90deg); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/styles/_transition.scss: -------------------------------------------------------------------------------- 1 | /* Global transition */ 2 | // See https://vuejs.org/v2/guide/transitions.html for detail 3 | 4 | // fade 5 | .fade-enter-active, 6 | .fade-leave-active { 7 | transition: opacity 0.28s; 8 | } 9 | 10 | .fade-enter, 11 | .fade-leave-active { 12 | opacity: 0; 13 | } 14 | 15 | // fade-transform 16 | .fade-transform-leave-active, 17 | .fade-transform-enter-active { 18 | transition: all .5s; 19 | } 20 | 21 | .fade-transform-enter { 22 | opacity: 0; 23 | transform: translateX(-30px); 24 | } 25 | 26 | .fade-transform-leave-to { 27 | opacity: 0; 28 | transform: translateX(30px); 29 | } 30 | 31 | // breadcrumb 32 | .breadcrumb-enter-active, 33 | .breadcrumb-leave-active { 34 | transition: all .5s; 35 | } 36 | 37 | .breadcrumb-enter, 38 | .breadcrumb-leave-active { 39 | opacity: 0; 40 | transform: translateX(20px); 41 | } 42 | 43 | .breadcrumb-move { 44 | transition: all .5s; 45 | } 46 | 47 | .breadcrumb-leave-active { 48 | position: absolute; 49 | } 50 | -------------------------------------------------------------------------------- /client/src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | /* Variables */ 2 | 3 | // Base color 4 | $blue:#324157; 5 | $light-blue:#3A71A8; 6 | $red:#C03639; 7 | $pink: #E65D6E; 8 | $green: #30B08F; 9 | $tiffany: #4AB7BD; 10 | $yellow:#FEC171; 11 | $panGreen: #30B08F; 12 | 13 | // Sidebar 14 | $sideBarWidth: 210px; 15 | $subMenuBg:#1f2d3d; 16 | $subMenuHover:#001528; 17 | $subMenuActiveText:#f4f4f5; 18 | $menuBg:#304156; 19 | $menuText:#bfcbd9; 20 | $menuActiveText:#409EFF; // Also see settings.sidebarTextTheme 21 | 22 | // Login page 23 | $lightGray: #eee; 24 | $darkGray:#889aa4; 25 | $loginBg: #2d3a4b; 26 | $loginCursorColor: #fff; 27 | 28 | // The :export directive is the magic sauce for webpack 29 | // https://mattferderer.com/use-sass-variables-in-typescript-and-javascript 30 | :export { 31 | menuBg: $menuBg; 32 | menuText: $menuText; 33 | menuActiveText: $menuActiveText; 34 | } 35 | -------------------------------------------------------------------------------- /client/src/styles/_variables.scss.d.ts: -------------------------------------------------------------------------------- 1 | export interface IScssVariables { 2 | menuBg: string 3 | menuText: string 4 | menuActiveText: string 5 | } 6 | 7 | export const variables: IScssVariables; 8 | 9 | export default variables 10 | -------------------------------------------------------------------------------- /client/src/styles/element-variables.scss: -------------------------------------------------------------------------------- 1 | /* Element Variables */ 2 | 3 | // Override Element UI variables 4 | $--color-primary: #1890ff; 5 | $--color-success: #13ce66; 6 | $--color-warning: #FFBA00; 7 | $--color-danger: #ff4949; 8 | $--color-info: #5d5d5d; 9 | $--button-font-weight: 400; 10 | $--color-text-regular: #1f2d3d; 11 | $--border-color-light: #dfe4ed; 12 | $--border-color-lighter: #e6ebf5; 13 | $--table-border:1px solid#dfe6ec; 14 | 15 | // Icon font path, required 16 | $--font-path: '~element-ui/lib/theme-chalk/fonts'; 17 | 18 | // Apply overrided variables in Element UI 19 | @import '~element-ui/packages/theme-chalk/src/index'; 20 | 21 | // The :export directive is the magic sauce for webpack 22 | // https://mattferderer.com/use-sass-variables-in-typescript-and-javascript 23 | :export { 24 | theme: $--color-primary; 25 | } 26 | -------------------------------------------------------------------------------- /client/src/styles/element-variables.scss.d.ts: -------------------------------------------------------------------------------- 1 | export interface IScssVariables { 2 | theme: string 3 | } 4 | 5 | export const variables: IScssVariables; 6 | 7 | export default variables 8 | -------------------------------------------------------------------------------- /client/src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Clipboard from 'clipboard' 3 | 4 | export const clipboardSuccess = () => 5 | Vue.prototype.$message({ 6 | message: 'Copy successfully', 7 | type: 'success', 8 | duration: 1500 9 | }); 10 | 11 | export const clipboardError = () => 12 | Vue.prototype.$message({ 13 | message: 'Copy failed', 14 | type: 'error' 15 | }); 16 | 17 | export const handleClipboard = (text: string, event: MouseEvent) => { 18 | const clipboard = new Clipboard(event.target as Element, { 19 | text: () => text 20 | }); 21 | clipboard.on('success', () => { 22 | clipboardSuccess(); 23 | clipboard.destroy() 24 | }); 25 | clipboard.on('error', () => { 26 | clipboardError(); 27 | clipboard.destroy() 28 | }); 29 | (clipboard as any).onClick(event) 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | // App 4 | const sidebarStatusKey = 'sidebar_status'; 5 | export const getSidebarStatus = () => Cookies.get(sidebarStatusKey); 6 | export const setSidebarStatus = (sidebarStatus: string) => Cookies.set(sidebarStatusKey, sidebarStatus); 7 | 8 | const sizeKey = 'size'; 9 | export const getSize = () => Cookies.get(sizeKey); 10 | export const setSize = (size: string) => Cookies.set(sizeKey, size); 11 | 12 | // User 13 | const tokenKey = 'vue_typescript_admin_access_token'; 14 | export const getToken = () => Cookies.get(tokenKey); 15 | export const setToken = (token: string) => Cookies.set(tokenKey, token); 16 | export const removeToken = () => Cookies.remove(tokenKey); 17 | -------------------------------------------------------------------------------- /client/src/utils/error-log.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { ErrorLogModule } from '@/store/modules/error-log' 3 | import { isArray } from '@/utils/validate' 4 | import settings from '@/settings' 5 | 6 | const { errorLog: needErrorLog } = settings; 7 | 8 | const checkNeed = () => { 9 | const env = process.env.NODE_ENV; 10 | if (isArray(needErrorLog) && env) { 11 | return needErrorLog.includes(env) 12 | } 13 | return false 14 | }; 15 | 16 | if (checkNeed()) { 17 | Vue.config.errorHandler = function(err, vm, info) { 18 | ErrorLogModule.AddErrorLog({ 19 | err, 20 | vm, 21 | info, 22 | url: window.location.href 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/utils/permission.ts: -------------------------------------------------------------------------------- 1 | import { UserMeModule } from '@/store/modules/me' 2 | 3 | export const checkPermission = (value: string[]): boolean => { 4 | if (value && value instanceof Array && value.length > 0) { 5 | const roles = UserMeModule.role_names; 6 | const permissionRoles = value; 7 | const hasPermission = roles.some(role => { 8 | return permissionRoles.includes(role) 9 | }); 10 | return hasPermission 11 | } else { 12 | console.error('need roles! Like v-permission="[\'admin\',\'editor\']"'); 13 | return false 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /client/src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import { Message } from 'element-ui'; 3 | import { UserMeModule } from '@/store/modules/me'; 4 | 5 | const http = axios.create({ 6 | baseURL: process.env.VUE_APP_BASE_API, 7 | headers: {'Content-Type': 'application/json'}, 8 | timeout: 10000, 9 | responseType: 'json', 10 | validateStatus: (status: number) => status >= 200 && status < 300, 11 | }); 12 | 13 | http.interceptors.request.use ( 14 | function (config) { 15 | const token = UserMeModule.token; 16 | if (token) config.headers.Authorization = `Bearer ${token}`; 17 | return config; 18 | }, 19 | function (error) { 20 | return Promise.reject (error); 21 | } 22 | ); 23 | 24 | // Response interceptors 25 | http.interceptors.response.use( 26 | // Everything went well, pass through 27 | (response) => { 28 | return response 29 | }, 30 | // Do something with response error 31 | (error) => { 32 | // Display message with Element-UI 33 | Message({ 34 | message: error.message, 35 | type: 'error', 36 | duration: 5 * 1000 37 | }); 38 | console.log(error.response); 39 | return Promise.reject(error) 40 | 41 | } 42 | ); 43 | 44 | export default http; 45 | -------------------------------------------------------------------------------- /client/src/utils/scroll-to.ts: -------------------------------------------------------------------------------- 1 | const easeInOutQuad = (t: number, b: number, c: number, d: number) => { 2 | t /= d / 2; 3 | if (t < 1) { 4 | return c / 2 * t * t + b 5 | } 6 | t--; 7 | return -c / 2 * (t * (t - 2) - 1) + b 8 | }; 9 | 10 | // requestAnimationFrame for Smart Animating http://goo.gl/sx5sts 11 | const requestAnimFrame = (function() { 12 | return window.requestAnimationFrame || window.webkitRequestAnimationFrame || (window as any).mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) } 13 | })(); 14 | 15 | // Because it's so fucking difficult to detect the scrolling element, just move them all 16 | const move = (amount: number) => { 17 | document.documentElement.scrollTop = amount; 18 | (document.body.parentNode as HTMLElement).scrollTop = amount; 19 | document.body.scrollTop = amount 20 | }; 21 | 22 | const position = () => { 23 | return document.documentElement.scrollTop || (document.body.parentNode as HTMLElement).scrollTop || document.body.scrollTop 24 | }; 25 | 26 | export const scrollTo = (to: number, duration: number, callback?: Function) => { 27 | const start = position(); 28 | const change = to - start; 29 | const increment = 20; 30 | let currentTime = 0; 31 | duration = (typeof (duration) === 'undefined') ? 500 : duration; 32 | const animateScroll = function() { 33 | // increment the time 34 | currentTime += increment; 35 | // find the value with the quadratic in-out easing function 36 | const val = easeInOutQuad(currentTime, start, change, duration); 37 | // move the document.body 38 | move(val); 39 | // do the animation unless its over 40 | if (currentTime < duration) { 41 | requestAnimFrame(animateScroll) 42 | } else { 43 | if (callback && typeof (callback) === 'function') { 44 | // the animation is done so lets callback 45 | callback() 46 | } 47 | } 48 | }; 49 | animateScroll() 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/utils/validate.ts: -------------------------------------------------------------------------------- 1 | export const isValidUsername = (str: string) => ['admin', 'editor'].indexOf(str.trim()) >= 0; 2 | 3 | export const isExternal = (path: string) => /^(https?:|mailto:|tel:)/.test(path); 4 | 5 | export const isArray = (arg: any) => { 6 | if (typeof Array.isArray === 'undefined') { 7 | return Object.prototype.toString.call(arg) === '[object Array]' 8 | } 9 | return Array.isArray(arg) 10 | }; 11 | 12 | export const isValidURL = (url: string) => { 13 | const reg = /^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/; 14 | return reg.test(url) 15 | }; 16 | -------------------------------------------------------------------------------- /client/src/views/dashboard/admin/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | 22 | 30 | -------------------------------------------------------------------------------- /client/src/views/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 34 | -------------------------------------------------------------------------------- /client/src/views/dashboard/user/index.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 40 | 41 | 71 | -------------------------------------------------------------------------------- /client/src/views/profile/components/UserRoles.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 61 | -------------------------------------------------------------------------------- /client/src/views/profile/index.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 60 | -------------------------------------------------------------------------------- /client/src/views/redirect/index.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /client/src/views/users/EditUser.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "node", 16 | "jest", 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | volumes: 4 | postgres_data: {} 5 | 6 | services: 7 | backend: 8 | build: 9 | context: ./server 10 | dockerfile: Dockerfile 11 | target: development 12 | volumes: 13 | - ./server:/app 14 | env_file: 15 | - docker/server.env 16 | - docker/postgres.env 17 | depends_on: 18 | - postgres 19 | - mailhog 20 | ports: 21 | - "8000:8000" 22 | 23 | postgres: 24 | image: postgres:12.2 25 | volumes: 26 | - postgres_data:/var/lib/postgresql/data 27 | - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql 28 | env_file: 29 | - docker/postgres.env 30 | ports: 31 | - "5432:5432" 32 | 33 | pgadmin: 34 | image: dpage/pgadmin4 35 | depends_on: 36 | - postgres 37 | environment: 38 | PGADMIN_DEFAULT_EMAIL: "pgadmin" 39 | PGADMIN_DEFAULT_PASSWORD: "pgadmin" 40 | ports: 41 | - "5050:80" 42 | 43 | mailhog: 44 | image: mailhog/mailhog:v1.0.0 45 | ports: 46 | - "8025:8025" 47 | 48 | splash-browser: 49 | image: scrapinghub/splash 50 | ports: 51 | - "8050:8050" 52 | -------------------------------------------------------------------------------- /docker/init.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pgcrypto; -------------------------------------------------------------------------------- /docker/postgres.env: -------------------------------------------------------------------------------- 1 | # PostgreSQL 2 | # --------------------- 3 | POSTGRES_HOST=postgres 4 | POSTGRES_PORT=5432 5 | POSTGRES_USER=postgres 6 | POSTGRES_PASSWORD=postgres 7 | POSTGRES_DB=fastapi_backend 8 | -------------------------------------------------------------------------------- /docker/server.env: -------------------------------------------------------------------------------- 1 | # docker-compose 2 | # --------------------- 3 | COMPOSE_PROJECT_NAME=fastapi 4 | 5 | # FastAPI 6 | # ----------------------------- 7 | SECRET_KEY=a5dbf43e07f4d19e5b73bc8gdfdvfd9a8f74 8 | PROJECT_NAME=Fastapi Backend 9 | SERVER_HOST=0.0.0.0 10 | FIRST_SUPERUSER=user@example.com 11 | FIRST_SUPERUSER_PASSWORD=a5dbf43e07f4d19e5b73bc89a8f74 12 | USERS_OPEN_REGISTRATION=true 13 | CORS_WHITELIST=["http://localhost", "http://localhost:8000", "http://0.0.0.0:8000"] 14 | 15 | # SMTP 16 | # ----------------------------- 17 | SMTP_USER=admin@backend.com 18 | SMTP_PASSWORD=wqiefasnfsafna 19 | SMTP_HOST=mailhog 20 | SMTP_PORT=1025 21 | SMTP_TLS=false 22 | SMTP_SSL=false 23 | -------------------------------------------------------------------------------- /imgs/1-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/1-login.png -------------------------------------------------------------------------------- /imgs/2-admin-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/2-admin-dashboard.png -------------------------------------------------------------------------------- /imgs/3.0-user-CRUD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/3.0-user-CRUD.png -------------------------------------------------------------------------------- /imgs/3.1-user-create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/3.1-user-create.png -------------------------------------------------------------------------------- /imgs/4-user-profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/4-user-profile.png -------------------------------------------------------------------------------- /imgs/5.0-shop-CRUD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/5.0-shop-CRUD.png -------------------------------------------------------------------------------- /imgs/5.1-shop-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/5.1-shop-select.png -------------------------------------------------------------------------------- /imgs/6-prices-display.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/imgs/6-prices-display.png -------------------------------------------------------------------------------- /server/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /server/alembic/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 | -------------------------------------------------------------------------------- /server/alembic/versions/daff23253894_adding_user_shops_relationship.py: -------------------------------------------------------------------------------- 1 | """Adding user shops relationship 2 | 3 | Revision ID: daff23253894 4 | Revises: fab2a86c8b63 5 | Create Date: 2020-04-11 08:46:05.071956 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "daff23253894" 13 | down_revision = "fab2a86c8b63" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | pass 21 | # ### end Alembic commands ### 22 | 23 | 24 | def downgrade(): 25 | # ### commands auto generated by Alembic - please adjust! ### 26 | pass 27 | # ### end Alembic commands ### 28 | -------------------------------------------------------------------------------- /server/alembic/versions/e7ff97c10b8f_adding_user_roles_association_table.py: -------------------------------------------------------------------------------- 1 | """Adding user roles association table 2 | 3 | Revision ID: e7ff97c10b8f 4 | Revises: 46773fb5dc7f 5 | Create Date: 2020-03-30 18:34:39.846931 6 | 7 | """ 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "e7ff97c10b8f" 13 | down_revision = "46773fb5dc7f" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "user_roles", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column( 24 | "created_at", 25 | sa.TIMESTAMP(timezone=True), 26 | server_default=sa.text("now()"), 27 | nullable=True, 28 | ), 29 | sa.Column( 30 | "updated_at", 31 | sa.TIMESTAMP(timezone=True), 32 | server_default=sa.text("now()"), 33 | nullable=True, 34 | ), 35 | sa.Column("user_id", sa.Integer(), nullable=True), 36 | sa.Column("role_id", sa.Integer(), nullable=True), 37 | sa.ForeignKeyConstraint( 38 | ["role_id"], ["role.id"], onupdate="CASCADE", ondelete="CASCADE" 39 | ), 40 | sa.ForeignKeyConstraint( 41 | ["user_id"], ["user.id"], onupdate="CASCADE", ondelete="CASCADE" 42 | ), 43 | sa.PrimaryKeyConstraint("id"), 44 | ) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table("user_roles") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /server/app/api/apirouter.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from .v1 import v1_router 4 | 5 | router = APIRouter() 6 | router.include_router(router=v1_router.router, prefix="/v1") 7 | 8 | 9 | @router.get("/healthcheck") 10 | def healthcheck(): 11 | return {"status": "ok"} 12 | -------------------------------------------------------------------------------- /server/app/api/v1/routes/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, HTTPException, status 2 | from fastapi.security import OAuth2PasswordRequestForm 3 | from sqlalchemy import orm 4 | 5 | from app.db.session import get_db 6 | from app.enums import userenums 7 | from app.models import tokenmodels, usermodels 8 | from app.service import tokenservice, userservice 9 | from app.settings import settings 10 | 11 | from ..dependencies.auth import get_current_user 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post("/login", response_model=tokenmodels.TokenResponse) 17 | def login( 18 | *, 19 | db_session: orm.Session = Depends(get_db), 20 | form_data: OAuth2PasswordRequestForm = Depends(), 21 | ): 22 | """ 23 | OAuth2 compatible token login, get an access token for future requests. 24 | """ 25 | user = userservice.authenticate( 26 | db_session=db_session, email=form_data.username, password=form_data.password 27 | ) 28 | if not user: 29 | raise HTTPException( 30 | status_code=status.HTTP_400_BAD_REQUEST, detail="REPLACE ME", 31 | ) 32 | 33 | elif not user.status == userenums.UserStatus.active: 34 | raise HTTPException( 35 | status_code=status.HTTP_400_BAD_REQUEST, detail="USER_INACTIVE" 36 | ) 37 | 38 | # TODO: maybe ? add custom encoder like ujson/orjson to encode method 39 | token = tokenservice.encode( 40 | payload={"user_id": str(user.id), "aud": tokenservice.AUTH_AUDIENCE}, 41 | lifetime_seconds=settings.JWT_AUTH_LIFETIME_SECONDS, 42 | ) 43 | 44 | return { 45 | "access_token": token, 46 | "token_type": "bearer", 47 | } 48 | 49 | 50 | @router.post("/logout") 51 | def logout(): 52 | return {"logout-status": "successful"} 53 | 54 | 55 | @router.post("/login/test-token", response_model=usermodels.UserRead) 56 | def test_token(current_user: usermodels.User = Depends(get_current_user)): 57 | """ 58 | Test access token 59 | """ 60 | return current_user 61 | -------------------------------------------------------------------------------- /server/app/api/v1/routes/roles.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends, HTTPException, status 4 | from sqlalchemy.orm import Session 5 | 6 | from app.db.session import get_db 7 | from app.models import rolemodels 8 | from app.service import roleservice 9 | 10 | from ..dependencies.or_404 import get_role_by_id_or_404 11 | 12 | router = APIRouter() 13 | 14 | 15 | @router.get( 16 | "/", response_model=List[rolemodels.RoleRead], 17 | ) 18 | def get_roles(*, db_session: Session = Depends(get_db)): 19 | """ 20 | Retrieve a list of roles. 21 | """ 22 | return roleservice.get_multiple(db_session=db_session) 23 | 24 | 25 | @router.post( 26 | "/", status_code=status.HTTP_201_CREATED, response_model=rolemodels.RoleRead, 27 | ) 28 | def create_role( 29 | *, db_session: Session = Depends(get_db), role_in: rolemodels.RoleCreate 30 | ): 31 | """ 32 | Create a new role. 33 | """ 34 | if roleservice.get_by_name(db_session=db_session, name=role_in.name): 35 | raise HTTPException( 36 | status_code=status.HTTP_400_BAD_REQUEST, detail="Role already exists.", 37 | ) 38 | 39 | return roleservice.create(db_session=db_session, role_in=role_in) 40 | 41 | 42 | @router.get("/{id}", response_model=rolemodels.RoleRead) 43 | def read_role(role: rolemodels.Role = Depends(get_role_by_id_or_404),): 44 | """ 45 | Retrieve details about a specific role. 46 | """ 47 | return role 48 | 49 | 50 | @router.put( 51 | "/{id}", response_model=rolemodels.RoleRead, 52 | ) 53 | def update_role( 54 | *, 55 | db_session: Session = Depends(get_db), 56 | role: rolemodels.Role = Depends(get_role_by_id_or_404), 57 | role_in: rolemodels.RoleUpdate, 58 | ): 59 | """ 60 | Update an individual role. 61 | """ 62 | 63 | return roleservice.update(db_session=db_session, role=role, role_in=role_in) 64 | 65 | 66 | @router.delete( 67 | "/{id}", response_model=rolemodels.RoleRead, 68 | ) 69 | def delete_role( 70 | *, 71 | db_session: Session = Depends(get_db), 72 | role: rolemodels.Role = Depends(get_role_by_id_or_404), 73 | ): 74 | """ 75 | Delete an individual role. 76 | """ 77 | return roleservice.delete(db_session=db_session, id_=role.id) 78 | -------------------------------------------------------------------------------- /server/app/api/v1/v1_router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | 3 | from .dependencies.auth import admin_role 4 | from .routes import auth, me, roles, shops, users 5 | 6 | router = APIRouter() 7 | 8 | router.include_router(router=auth.router, prefix="/auth", tags=["auth"]) 9 | 10 | router.include_router(router=me.router, tags=["self"]) 11 | 12 | router.include_router( 13 | router=users.router, 14 | prefix="/users", 15 | tags=["users"], 16 | dependencies=[Depends(admin_role)], 17 | ) 18 | 19 | router.include_router( 20 | router=roles.router, 21 | prefix="/roles", 22 | tags=["roles"], 23 | dependencies=[Depends(admin_role)], 24 | ) 25 | 26 | router.include_router(router=shops.router, prefix="/shops", tags=["shops"]) 27 | -------------------------------------------------------------------------------- /server/app/bgtasks.py: -------------------------------------------------------------------------------- 1 | from fastapi.background import BackgroundTasks 2 | 3 | 4 | def send_signup_email(): 5 | pass 6 | -------------------------------------------------------------------------------- /server/app/db/initdb.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | 3 | import yaml 4 | from sqlalchemy import engine, orm 5 | 6 | from app.models import rolemodels, shopmodels, usermodels 7 | from app.service import roleservice, shopservice, userservice 8 | from app.settings import settings 9 | 10 | INITDB_PATH = settings.APP_DIR / "db" / "initdb.yaml" 11 | with open(INITDB_PATH) as file: 12 | config = yaml.safe_load(file) 13 | 14 | 15 | def initdb(db_session: orm.Session): 16 | init_roles(db_session=db_session) 17 | init_shops(db_session=db_session) 18 | 19 | 20 | def init_roles(db_session: orm.Session): 21 | roles_in = [ 22 | rolemodels.RoleCreate(name=role["name"], description=role["description"]) 23 | for role in config["roles"] 24 | ] 25 | roleservice.create_multiple(db_session=db_session, roles_in=roles_in) 26 | 27 | 28 | def init_shops(db_session: orm.Session): 29 | for shop in config["shops"]: 30 | shop_in = shopmodels.ShopCreate(**shop) 31 | shopservice.create(db_session=db_session, shop_in=shop_in) 32 | 33 | 34 | def setup_guids_postgresql(engine: engine.Engine) -> None: # pragma: no cover 35 | """ 36 | Set up UUID generation using the pgcrypto extension for postgres 37 | This query only needs to be executed once when the database is created 38 | """ 39 | engine.execute('create EXTENSION if not EXISTS "pgcrypto"') 40 | -------------------------------------------------------------------------------- /server/app/db/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import create_engine, orm 2 | 3 | from app.settings import settings 4 | 5 | engine = create_engine(settings.POSTGRES_URL, pool_pre_ping=True) 6 | SessionLocal = orm.sessionmaker(bind=engine) 7 | 8 | 9 | def get_db(): 10 | """Endpoint dependency for getting db session.""" 11 | db = SessionLocal() 12 | try: 13 | yield db 14 | finally: 15 | db.close() 16 | -------------------------------------------------------------------------------- /server/app/enums/logenums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class LogLevel(str, Enum): 5 | debug = "debug" 6 | info = "info" 7 | error = "error" 8 | warning = "warning" 9 | critical = "critical" 10 | -------------------------------------------------------------------------------- /server/app/enums/userenums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class UserStatus(str, Enum): 5 | """ 6 | UserStatus enum. 7 | 8 | active: User account is active. 9 | inactive: User account is inactive, perhaps email has not been validated. 10 | disabled: User has been disabled, perhaps banned by an administrator. 11 | """ 12 | 13 | active = "active" 14 | inactive = "inactive" 15 | disabled = "disabled" 16 | -------------------------------------------------------------------------------- /server/app/exceptions/apiexceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import HTTPException, status 4 | 5 | JWTExpiredException = HTTPException( 6 | status_code=status.HTTP_403_FORBIDDEN, detail="JWT has expired" 7 | ) 8 | 9 | JWTInvalidAudienceException = HTTPException( 10 | status_code=status.HTTP_403_FORBIDDEN, detail="Incorrect JWT audience." 11 | ) 12 | 13 | InvalidCredentialsException = HTTPException( 14 | status_code=status.HTTP_401_UNAUTHORIZED, 15 | detail="Credentials could not be validated", 16 | ) 17 | 18 | UnauthorizedException = HTTPException( 19 | status_code=status.HTTP_401_UNAUTHORIZED, detail="User is not authenticated." 20 | ) 21 | 22 | UserNotFoundException = HTTPException( 23 | status_code=status.HTTP_404_NOT_FOUND, detail="The specified user was not found." 24 | ) 25 | 26 | AUTHENTICATION_FAILED = "Could not validate credentials." 27 | FORBIDDEN = "You are not authorized to access this resource." 28 | REGISTRATION_CLOSED = "Open user registration is forbidden on this server" 29 | 30 | USER_EXISTS = "Email address is already in use." 31 | USER_INACTIVE = "User is inactive." 32 | 33 | _NOT_FOUND = "{item} not found." 34 | USER_NOT_FOUND = _NOT_FOUND.format(item="User") 35 | 36 | EMAIL_NOT_FOUND = _NOT_FOUND.format(item="Email address") 37 | -------------------------------------------------------------------------------- /server/app/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | 6 | from .api import apirouter 7 | from .settings import settings 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | app = FastAPI(debug=settings.DEBUG, title=settings.PROJECT_NAME) 12 | 13 | # TODO: Add correct local CORS origins to .env file, atm everything is allowed 14 | if settings.CORS_WHITELIST: 15 | app.add_middleware( 16 | CORSMiddleware, 17 | allow_origins=["*"], 18 | allow_credentials=True, 19 | allow_methods=["*"], 20 | allow_headers=["*"], 21 | ) 22 | 23 | 24 | app.include_router(router=apirouter.router, prefix="/api") 25 | -------------------------------------------------------------------------------- /server/app/messages/apimsg.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from fastapi import HTTPException, status 4 | 5 | AUTHENTICATION_FAILED = "Could not validate credentials." 6 | FORBIDDEN = "You are not authorized to access this resource." 7 | REGISTRATION_CLOSED = "Open user registration is forbidden on this server" 8 | 9 | USER_EXISTS = "Email address is already in use." 10 | USER_INACTIVE = "User is inactive." 11 | 12 | _NOT_FOUND = "{item} not found." 13 | USER_NOT_FOUND = _NOT_FOUND.format(item="User") 14 | 15 | EMAIL_NOT_FOUND = _NOT_FOUND.format(item="Email address") 16 | -------------------------------------------------------------------------------- /server/app/messages/usermsgs.py: -------------------------------------------------------------------------------- 1 | from app.enums.userenums import UserStatus 2 | 3 | USER_STATUS_DESCRIPTIONS = { 4 | UserStatus.active: "User account is activated.", 5 | UserStatus.inactive: "User account is not activated.", 6 | UserStatus.disabled: "User account has been disabled.", 7 | } 8 | -------------------------------------------------------------------------------- /server/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | All known models are imported here so Alembic can see them. 3 | """ 4 | 5 | from .rolemodels import * 6 | from .shopmodels import * 7 | from .usermodels import * 8 | -------------------------------------------------------------------------------- /server/app/models/meta/pydanticbase.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import orjson 4 | from pydantic import BaseModel 5 | 6 | 7 | def orjson_dumps(v, *, default): 8 | """ 9 | orjson.dumps returns bytes, to match standard json.dumps we need to decode. 10 | orjson.dumps option arguments provide many options such as `option=orjson.OPT_SERIALIZE_UUID` to natively encode UUID instances. 11 | """ 12 | return orjson.dumps(v, default=default).decode() 13 | 14 | 15 | class PydanticBase(BaseModel): 16 | """PydanticBase with custom JSON implementation. 17 | 'orjson' is used here as it takes care of datetime 18 | encoding natively and gives better (de)serialisation performance. 19 | 20 | .. seealso:: 21 | 22 | https://pydantic-docs.helpmanual.io/usage/exporting_models/#custom-json-deserialisation 23 | 24 | """ 25 | 26 | class Config: 27 | orm_mode = True 28 | validate_assignment = True 29 | json_loads = orjson.loads 30 | json_dumps = orjson_dumps 31 | -------------------------------------------------------------------------------- /server/app/models/meta/pydanticmixins.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | class PydanticTS(BaseModel): 8 | created_at: datetime 9 | updated_at: Optional[datetime] 10 | -------------------------------------------------------------------------------- /server/app/models/meta/pydantictypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | See: https://pydantic-docs.helpmanual.io/usage/types/#custom-data-types 4 | """ 5 | -------------------------------------------------------------------------------- /server/app/models/meta/sqlalchemybase.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | from sqlalchemy_utils import EmailType, generic_repr 3 | 4 | 5 | @generic_repr 6 | class SQLAlchemyBase: 7 | """ 8 | SQLAlchemyBase class. 9 | """ 10 | 11 | __abstract__ = True 12 | 13 | # https://gist.githubusercontent.com/mjhea0/9b9c400a2bc58e6c90e5f77eeb739d6b/raw/2ca3e8c696011b500cf2864cd4a8f125112f4251/base_mixin.py 14 | # _repr_hide = ["created_at", "updated_at"] 15 | # 16 | # def __repr__(self): # pragma: no cover 17 | # values = ", ".join( 18 | # "%s=%r" % (n, getattr(self, n)) 19 | # for n in self.__table__.c.keys() 20 | # if n not in self._repr_hide 21 | # ) 22 | # return "%s(%s)" % (self.__class__.__name__, values) 23 | 24 | 25 | SQLAlchemyBase = declarative_base(cls=SQLAlchemyBase) 26 | -------------------------------------------------------------------------------- /server/app/models/meta/sqlalchemymixins.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import TIMESTAMP, Column, Integer 2 | from sqlalchemy.sql import func 3 | 4 | from .sqlalchemytypes import GUID 5 | 6 | 7 | class SQLAlchemyIntPK: 8 | """ 9 | Provides an integer PK column named `id`. 10 | """ 11 | 12 | id = Column(Integer, primary_key=True) 13 | 14 | 15 | class SQLAlchemyGUIDPK: 16 | """ 17 | Provides a GUID PK column named `id`. 18 | """ 19 | 20 | id = Column(GUID, primary_key=True) 21 | 22 | 23 | class SQLAlchemyTS: 24 | """ 25 | SQLAlchemy timestamp mixin. 26 | """ 27 | 28 | created_at = Column(TIMESTAMP(timezone=True), server_default=func.now()) 29 | updated_at = Column(TIMESTAMP(timezone=True), server_onupdate=func.now()) 30 | -------------------------------------------------------------------------------- /server/app/models/meta/sqlalchemytypes.py: -------------------------------------------------------------------------------- 1 | """ 2 | See: https://docs.sqlalchemy.org/en/13/core/custom_types.html 3 | """ 4 | 5 | import datetime 6 | import uuid 7 | 8 | from sqlalchemy import DateTime 9 | from sqlalchemy.dialects.postgresql import UUID 10 | from sqlalchemy.types import CHAR, TypeDecorator 11 | 12 | 13 | class GUID(TypeDecorator): 14 | """Platform-independent GUID type. 15 | 16 | Uses Postgresql's UUID type, otherwise uses 17 | CHAR(32), storing as stringified hex values. 18 | 19 | .. seealso:: 20 | 21 | http://docs.sqlalchemy.org/en/latest/core/custom_types.html#backend-agnostic-guid-type 22 | 23 | """ 24 | 25 | impl = CHAR 26 | 27 | def load_dialect_impl(self, dialect): 28 | if dialect.name == "postgresql": 29 | return dialect.type_descriptor(UUID()) 30 | else: 31 | return dialect.type_descriptor(CHAR(32)) 32 | 33 | def process_bind_param(self, value, dialect): 34 | if value is None: 35 | return value 36 | elif dialect.name == "postgresql": 37 | return str(value) 38 | else: 39 | if not isinstance(value, uuid.UUID): 40 | return "%.32x" % uuid.UUID(value) 41 | else: 42 | # hexstring 43 | return "%.32x" % value 44 | 45 | def process_result_value(self, value, dialect): 46 | if value is None: 47 | return value 48 | else: 49 | return uuid.UUID(value) 50 | 51 | 52 | class TZDateTime(TypeDecorator): 53 | impl = DateTime 54 | 55 | def process_bind_param(self, value, dialect): 56 | if value is not None: 57 | if not value.tzinfo: 58 | raise TypeError("tzinfo is required") 59 | value = value.astimezone(datetime.timezone.utc).replace(tzinfo=None) 60 | return value 61 | 62 | def process_result_value(self, value, dialect): 63 | if value is not None: 64 | value = value.replace(tzinfo=datetime.timezone.utc) 65 | return value 66 | -------------------------------------------------------------------------------- /server/app/models/rolemodels.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | https://flask-user.readthedocs.io/en/latest/data_models.html 4 | 5 | https://factoryboy.readthedocs.io/en/latest/orms.html#factory.alchemy.SQLAlchemyOptions.sqlalchemy_session 6 | 7 | """ 8 | 9 | from typing import Optional 10 | 11 | from sqlalchemy import Column, ForeignKey, Integer, String 12 | 13 | from .meta.pydanticbase import PydanticBase 14 | from .meta.pydanticmixins import PydanticTS 15 | from .meta.sqlalchemybase import SQLAlchemyBase 16 | from .meta.sqlalchemymixins import SQLAlchemyIntPK, SQLAlchemyTS 17 | 18 | 19 | # sqlalchemy models 20 | class Role(SQLAlchemyTS, SQLAlchemyIntPK, SQLAlchemyBase): 21 | __tablename__ = "role" 22 | name = Column(String, unique=True, nullable=False) 23 | description = Column(String) 24 | 25 | 26 | class UserRoles(SQLAlchemyTS, SQLAlchemyIntPK, SQLAlchemyBase): 27 | """Association table to hold user roles.""" 28 | 29 | __tablename__ = "user_roles" 30 | user_id = Column( 31 | Integer, ForeignKey("user.id", ondelete="CASCADE", onupdate="CASCADE") 32 | ) 33 | role_id = Column( 34 | Integer, ForeignKey("role.id", ondelete="CASCADE", onupdate="CASCADE") 35 | ) 36 | 37 | 38 | # pydantic models 39 | class RoleCreate(PydanticBase): 40 | """Properties to receive via API on creation""" 41 | 42 | name: str 43 | description: Optional[str] 44 | 45 | 46 | class RoleRead(PydanticTS, PydanticBase): 47 | """Attributes to return via API""" 48 | 49 | id: int 50 | name: str 51 | description: str 52 | 53 | 54 | class RoleUpdate(PydanticBase): 55 | """Properties to receive via API on update""" 56 | 57 | name: Optional[str] 58 | description: Optional[str] 59 | -------------------------------------------------------------------------------- /server/app/models/scrapermodels.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import List, Optional 4 | 5 | from pydantic import BaseModel, validator 6 | 7 | from .meta.pydanticbase import PydanticBase 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | # Matching 00p in currency 12 | CURRENCY_PENCE_REGEX = re.compile(r"(\d{2})[p]") 13 | 14 | # Matches any whitespace characters and £ symbol 15 | CURRENCY_WHITESPACE_REGEX = re.compile(r"[£()\s+]") 16 | 17 | 18 | class ScrapedItem(PydanticBase): 19 | name: Optional[str] = None 20 | url: Optional[str] = None 21 | price: Optional[str] = None 22 | price_per_unit: Optional[str] = None 23 | image_url: Optional[str] = None 24 | 25 | @validator("price") 26 | def format_p(cls, v): 27 | """Transforms varying pricing formats to #.##""" 28 | if v: 29 | v = re.sub(CURRENCY_WHITESPACE_REGEX, "", v) 30 | match = re.match(CURRENCY_PENCE_REGEX, v) 31 | if match: 32 | v = f"0.{match.group(1)}" 33 | return v 34 | 35 | @validator("price_per_unit") 36 | def format_ppu(cls, v): 37 | if v: 38 | v = re.sub(CURRENCY_WHITESPACE_REGEX, "", v) 39 | v = v.replace("per", "/") 40 | return v 41 | 42 | @validator("*", pre=True) 43 | def filter_invalid_values(cls, v): 44 | if isinstance(v, list): 45 | return None 46 | return v 47 | 48 | 49 | class ShopListings(BaseModel): 50 | id: int 51 | name: str 52 | listings: Optional[List[ScrapedItem]] = None 53 | -------------------------------------------------------------------------------- /server/app/models/shopmodels.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy import JSON, Boolean, Column, ForeignKey, Integer, String 4 | 5 | from .meta.pydanticbase import PydanticBase 6 | from .meta.pydanticmixins import PydanticTS 7 | from .meta.sqlalchemybase import SQLAlchemyBase 8 | from .meta.sqlalchemymixins import SQLAlchemyIntPK, SQLAlchemyTS 9 | 10 | 11 | # sqlalchemy models 12 | class Shop(SQLAlchemyTS, SQLAlchemyIntPK, SQLAlchemyBase): 13 | __tablename__ = "shop" 14 | name = Column(String, unique=True, nullable=False) 15 | url = Column(String, unique=True, nullable=False) 16 | query_url = Column(String, nullable=False) 17 | render_javascript = Column(Boolean, nullable=False, default=False) 18 | listing_page_selector = Column(JSON, nullable=True) 19 | 20 | 21 | class UserShops(SQLAlchemyTS, SQLAlchemyIntPK, SQLAlchemyBase): 22 | __tablename__ = "user_shops" 23 | user_id = Column( 24 | Integer, ForeignKey("user.id", ondelete="CASCADE", onupdate="CASCADE") 25 | ) 26 | shop_id = Column( 27 | Integer, ForeignKey("shop.id", ondelete="CASCADE", onupdate="CASCADE") 28 | ) 29 | 30 | 31 | # pydantic models 32 | class ShopCreate(PydanticBase): 33 | """Properties to receive via API on creation""" 34 | 35 | name: str 36 | url: str 37 | query_url: str 38 | render_javascript: bool 39 | listing_page_selector: Optional[dict] 40 | 41 | 42 | class ShopRead(PydanticTS, ShopCreate): 43 | """Attributes to return via API""" 44 | 45 | id: int 46 | 47 | 48 | class ShopUpdate(PydanticBase): 49 | """Properties to receive via API on update""" 50 | 51 | name: Optional[str] 52 | url: Optional[str] 53 | query_url: Optional[str] 54 | render_javascript: Optional[bool] 55 | listing_page_selector: Optional[dict] 56 | -------------------------------------------------------------------------------- /server/app/models/tokenmodels.py: -------------------------------------------------------------------------------- 1 | from .meta.pydanticbase import PydanticBase 2 | 3 | 4 | class TokenResponse(PydanticBase): 5 | access_token: str 6 | token_type: str 7 | 8 | 9 | class JWTAccessToken(PydanticBase): 10 | exp: int 11 | user_id: int 12 | -------------------------------------------------------------------------------- /server/app/service/emailservice.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import yagmail 4 | from jinja2 import Template 5 | 6 | from app.settings import settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | TEMPLATES = { 12 | "verify_account": settings.EMAIL_TEMPLATES_DIR / "verify-account.html", 13 | } 14 | 15 | 16 | def send_email(to: str, subject: str, contents: str, **kwargs): 17 | """Sends an email with the project defined SMTP settings.""" 18 | # Using a context manager here ensures the SMTP connection is closed, 19 | # even if there is an exception. 20 | with yagmail.SMTP( 21 | user=settings.SMTP.USER, 22 | password=settings.SMTP.PASSWORD.get_secret_value(), 23 | host=settings.SMTP.HOST, 24 | port=settings.SMTP.PORT, 25 | smtp_starttls=settings.SMTP.TLS, 26 | smtp_ssl=settings.SMTP.SSL, 27 | ) as client: 28 | client.send(to=to, subject=subject, contents=contents, **kwargs) 29 | 30 | 31 | def send_verify_account_email(to: str, first_name: str) -> True: 32 | template_path = TEMPLATES["verify_account"] 33 | with open(str(template_path)) as file: 34 | template_str = file.read() 35 | 36 | subject = f"{settings.PROJECT_NAME} - Verify Email" 37 | kwargs = { 38 | "project_name": settings.PROJECT_NAME, 39 | "first_name": first_name.title(), 40 | "link": settings.SERVER_HOST, 41 | } 42 | rendered_template = Template(template_str).render(kwargs) 43 | 44 | return send_email(to=to, subject=subject, contents=rendered_template) 45 | -------------------------------------------------------------------------------- /server/app/service/passwordservice.py: -------------------------------------------------------------------------------- 1 | from passlib.context import CryptContext 2 | 3 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 4 | 5 | 6 | def verify_password(plain_password: str, hashed_password: str) -> bool: 7 | return pwd_context.verify(plain_password, hashed_password) 8 | 9 | 10 | def get_password_hash(password: str) -> str: 11 | return pwd_context.hash(password) 12 | -------------------------------------------------------------------------------- /server/app/service/roleservice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app.models.rolemodels import Role, RoleCreate, RoleUpdate 6 | 7 | 8 | def get(db_session: Session, id_: int) -> Optional[Role]: 9 | return db_session.query(Role).filter(Role.id == id_).first() 10 | 11 | 12 | def get_by_name(db_session: Session, name: str) -> Optional[Role]: 13 | return db_session.query(Role).filter(Role.name == name).first() 14 | 15 | 16 | def get_multiple( 17 | db_session: Session, *, offset: int = 0, limit: int = 100 18 | ) -> List[Role]: 19 | return db_session.query(Role).offset(offset).limit(limit).all() 20 | 21 | 22 | def get_multiple_by_ids(db_session: Session, *, ids_: List[int]) -> List[Role]: 23 | return db_session.query(Role).filter(Role.id.in_(ids_)).all() 24 | 25 | 26 | def create(db_session: Session, role_in: RoleCreate) -> Role: 27 | db_obj = Role(name=role_in.name, description=role_in.description) 28 | db_session.add(db_obj) 29 | db_session.commit() 30 | return db_obj 31 | 32 | 33 | def create_multiple(db_session: Session, roles_in: List[RoleCreate]) -> List[Role]: 34 | db_objs = [Role(name=role.name, description=role.description) for role in roles_in] 35 | db_session.add_all(db_objs) 36 | db_session.commit() 37 | return db_objs 38 | 39 | 40 | def update(db_session: Session, role: Role, role_in: RoleUpdate) -> Role: 41 | update_data = role_in.dict(exclude_unset=True) 42 | for field in update_data: 43 | setattr(role, field, update_data[field]) 44 | 45 | db_session.add(role) 46 | db_session.commit() 47 | return role 48 | 49 | 50 | def delete(db_session: Session, *, id_: int): 51 | obj = db_session.query(Role).get(id_) 52 | db_session.delete(obj) 53 | db_session.commit() 54 | -------------------------------------------------------------------------------- /server/app/service/shopservice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from sqlalchemy.orm import Session 4 | 5 | from app.models.shopmodels import Shop, ShopCreate, ShopUpdate 6 | 7 | 8 | def get(db_session: Session, id_: int) -> Optional[Shop]: 9 | return db_session.query(Shop).filter(Shop.id == id_).first() 10 | 11 | 12 | def get_by_name(db_session: Session, name: str) -> Optional[Shop]: 13 | return db_session.query(Shop).filter(Shop.name == name).first() 14 | 15 | 16 | def get_multiple( 17 | db_session: Session, *, offset: int = 0, limit: int = 100 18 | ) -> List[Shop]: 19 | return db_session.query(Shop).offset(offset).limit(limit).all() 20 | 21 | 22 | def get_multiple_by_ids(db_session: Session, *, ids_: List[int]) -> List[Shop]: 23 | return db_session.query(Shop).filter(Shop.id.in_(ids_)).all() 24 | 25 | 26 | def create(db_session: Session, shop_in: ShopCreate) -> Shop: 27 | 28 | db_obj = Shop( 29 | name=shop_in.name, 30 | url=shop_in.url, 31 | query_url=shop_in.query_url, 32 | render_javascript=shop_in.render_javascript, 33 | listing_page_selector=shop_in.listing_page_selector, 34 | ) 35 | 36 | db_session.add(db_obj) 37 | db_session.commit() 38 | return db_obj 39 | 40 | 41 | def update(db_session: Session, shop: Shop, shop_in: ShopUpdate) -> Shop: 42 | update_data = shop_in.dict(exclude_unset=True) 43 | for field in update_data: 44 | setattr(shop, field, update_data[field]) 45 | db_session.add(shop) 46 | db_session.commit() 47 | return shop 48 | 49 | 50 | def delete(db_session: Session, id_: int) -> None: 51 | obj = db_session.query(Shop).get(id_) 52 | db_session.delete(obj) 53 | db_session.commit() 54 | -------------------------------------------------------------------------------- /server/app/service/tokenservice.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Union 3 | 4 | import jwt 5 | 6 | from app.settings import settings 7 | 8 | ALGORITHM: str = "HS256" 9 | 10 | AUTH_AUDIENCE = "auth-login" 11 | 12 | 13 | def decode(token: str, **jwtkwargs) -> dict: 14 | return jwt.decode( 15 | jwt=token, 16 | key=settings.SECRET_KEY.get_secret_value(), 17 | algorithms=[ALGORITHM], 18 | **jwtkwargs, 19 | ) 20 | 21 | 22 | def encode(payload: dict, lifetime_seconds: Union[int, float]) -> str: 23 | to_encode = payload.copy() 24 | expire = datetime.utcnow() + timedelta(seconds=lifetime_seconds) 25 | to_encode["exp"] = expire 26 | encoded_jwt = jwt.encode( 27 | payload=to_encode, 28 | key=settings.SECRET_KEY.get_secret_value(), 29 | algorithm=ALGORITHM, 30 | ).decode("utf-8") 31 | return encoded_jwt 32 | -------------------------------------------------------------------------------- /server/app/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | from pydantic import ( 5 | AnyHttpUrl, 6 | BaseSettings, 7 | DirectoryPath, 8 | PostgresDsn, 9 | SecretStr, 10 | validator, 11 | ) 12 | 13 | from app.enums.logenums import LogLevel 14 | 15 | 16 | class Settings(BaseSettings): 17 | 18 | APP_DIR: DirectoryPath = Path(__file__).resolve().parent 19 | STATIC_DIR: DirectoryPath = APP_DIR / "static" 20 | EMAIL_TEMPLATES_DIR: DirectoryPath = STATIC_DIR / "email-templates" / "html" 21 | 22 | PROJECT_NAME: str 23 | 24 | SERVER_HOST: str 25 | CORS_WHITELIST: List[AnyHttpUrl] = [] 26 | 27 | FASTAPI_ENV: str 28 | DEBUG: bool = False 29 | LOG_LEVEL: LogLevel = LogLevel.debug 30 | 31 | FIRST_SUPERUSER: str 32 | FIRST_SUPERUSER_PASSWORD: str 33 | 34 | USERS_OPEN_REGISTRATION: bool 35 | 36 | SECRET_KEY: SecretStr 37 | JWT_AUTH_LIFETIME_SECONDS: int = 60 * 60 * 24 * 7 # 7 days 38 | JWT_EMAIL_LIFETIME_SECONDS: int = 60 * 60 * 1 # 1 hour 39 | 40 | SMTP_USER: str 41 | SMTP_PASSWORD: SecretStr 42 | SMTP_TLS: bool 43 | SMTP_SSL: bool 44 | SMTP_HOST: str 45 | SMTP_PORT: int 46 | 47 | POSTGRES_USER: str 48 | POSTGRES_PASSWORD: SecretStr 49 | POSTGRES_HOST: str 50 | POSTGRES_PORT: int 51 | POSTGRES_DB: str 52 | POSTGRES_URL: PostgresDsn = None 53 | 54 | @validator("POSTGRES_URL", pre=True) 55 | def build_pgdsn(cls, v, values): 56 | if isinstance(v, str): 57 | return v 58 | return PostgresDsn.build( 59 | scheme="postgresql", 60 | user=values["POSTGRES_USER"], 61 | password=values["POSTGRES_PASSWORD"].get_secret_value(), 62 | host=values["POSTGRES_HOST"], 63 | port=str(values["POSTGRES_PORT"]), 64 | path="/" + values["POSTGRES_DB"], 65 | ) 66 | 67 | 68 | settings = Settings() 69 | -------------------------------------------------------------------------------- /server/app/static/email-templates/src/verify-account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ project_name }} - New Account 7 | Hi {{ first_name }}, 8 | 9 | Thanks for signing up. Please click below to verify your email address: 10 | Click to verify 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /server/app/utils/common.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/server/app/utils/common.py -------------------------------------------------------------------------------- /server/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # wait for our database to be ready 6 | dockerize -wait "tcp://$POSTGRES_HOST:$POSTGRES_PORT" -timeout 10s 7 | 8 | 9 | # add waiting for other services here 10 | 11 | 12 | # activate our virtual environment to ensure 13 | # poetry commands are ran against it 14 | . /opt/pysetup/.venv/bin/activate 15 | 16 | # Evaluating passed command: 17 | exec "$@" -------------------------------------------------------------------------------- /server/gunicorn_conf.py: -------------------------------------------------------------------------------- 1 | # https://github.com/tiangolo/uvicorn-gunicorn-machine-learning-docker/blob/c6043e1d0150e7a24f2907078933837f31dabe3b/cuda9.1-python3.6-tensorflow/gunicorn_conf.py 2 | 3 | import json 4 | import multiprocessing 5 | import os 6 | 7 | workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") 8 | web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) 9 | host = os.getenv("HOST", "0.0.0.0") 10 | port = os.getenv("PORT", "8000") 11 | bind_env = os.getenv("BIND", None) 12 | use_loglevel = os.getenv("LOG_LEVEL", "info") 13 | if bind_env: 14 | use_bind = bind_env 15 | else: 16 | use_bind = f"{host}:{port}" 17 | 18 | cores = multiprocessing.cpu_count() 19 | workers_per_core = float(workers_per_core_str) 20 | default_web_concurrency = workers_per_core * cores 21 | if web_concurrency_str: 22 | web_concurrency = int(web_concurrency_str) 23 | assert web_concurrency > 0 24 | else: 25 | web_concurrency = max(int(default_web_concurrency), 2) 26 | 27 | # Gunicorn config variables 28 | loglevel = use_loglevel 29 | workers = web_concurrency 30 | bind = use_bind 31 | keepalive = 120 32 | errorlog = "-" 33 | 34 | # For debugging and testing 35 | log_data = { 36 | "loglevel": loglevel, 37 | "workers": workers, 38 | "bind": bind, 39 | # Additional, non-gunicorn variables 40 | "workers_per_core": workers_per_core, 41 | "host": host, 42 | "port": port, 43 | } 44 | print(json.dumps(log_data)) 45 | -------------------------------------------------------------------------------- /server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | # https://github.com/python-poetry/poetry 3 | name = "testing" 4 | version = "0.1.0" 5 | description = "" 6 | authors = ["Michael Oliver "] 7 | 8 | [tool.poetry.dependencies] 9 | python = "3.8.1" 10 | fastapi = {version = "^0.53.2", extras = ["all"]} 11 | passlib = {version = "^1.7.2", extras = ["bcrypt"]} 12 | pyjwt = {version = "^1.7.1", extras = ["crypto"]} 13 | yagmail = "^0.11" 14 | alembic = "^1.4.2" 15 | sqlalchemy = "^1.3.15" 16 | sqlalchemy-utils = "^0.36.3" 17 | click-spinner = "^0.1.8" 18 | tabulate = "^0.8.6" 19 | psycopg2-binary = "^2.8.4" 20 | sqlalchemy-filters = "^0.10.0" 21 | orjson = "^2.6.1" 22 | typer = "^0.1.1" 23 | factory-boy = "^2.12.0" 24 | pytz = "^2019.3" 25 | ipython = "^7.13.0" 26 | httpx = "^0.12.1" 27 | async_lru = "^1.0.2" 28 | selectorlib = "^0.16.0" 29 | 30 | [tool.poetry.dev-dependencies] 31 | pytest = "^5.4.1" 32 | coverage = { version = "^5.0.4", extras = ["toml"]} 33 | isort = { version = "^4.3.21", extras = ["pyproject"]} 34 | black = "^19.10b0" 35 | 36 | [tool.black] 37 | # https://github.com/psf/black 38 | line-length = 88 39 | target_version = ['py38'] 40 | exclude = ''' 41 | ( 42 | /( 43 | \.git 44 | | \.mypy_cache 45 | | \.pytest_cache 46 | | htmlcov 47 | | venv 48 | | .venv 49 | )/ 50 | ) 51 | ''' 52 | 53 | [tool.isort] 54 | # https://github.com/timothycrosley/isort 55 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 56 | line_length = 88 57 | indent = ' ' 58 | multi_line_output = 3 59 | include_trailing_comma = true 60 | force_grid_wrap = 0 61 | known_third_party = ["alembic"] 62 | 63 | [tool.coverage] 64 | # https://github.com/nedbat/coveragepy 65 | [tool.coverage.run] 66 | source = ["app"] 67 | branch = true 68 | omit = [''] 69 | 70 | [tool.coverage.report] 71 | exclude_lines = [ 72 | "pragma: no cover", 73 | "raise NotImplementedError" 74 | ] 75 | [build-system] 76 | requires = ["poetry>=0.12"] 77 | build-backend = "poetry.masonry.api" 78 | 79 | -------------------------------------------------------------------------------- /server/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) 6 | BACKEND_DIR="$(dirname "$CURRENT_DIR")" 7 | 8 | black --config "${BACKEND_DIR}/pyproject.toml" "${BACKEND_DIR}" 9 | isort --recursive --settings-path "${BACKEND_DIR}/pyproject.toml" "${BACKEND_DIR}" 10 | -------------------------------------------------------------------------------- /server/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) 6 | BACKEND_DIR="$(dirname "$CURRENT_DIR")" 7 | 8 | coverage run --rcfile "${BACKEND_DIR}/pyproject.toml" -m pytest "${BACKEND_DIR}/tests" "$*" 9 | coverage html --rcfile "${BACKEND_DIR}/pyproject.toml" 10 | -------------------------------------------------------------------------------- /server/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/server/tests/__init__.py -------------------------------------------------------------------------------- /server/tests/api/overrides.py: -------------------------------------------------------------------------------- 1 | """ 2 | Endpoint dependency overrides are defined here. 3 | """ 4 | 5 | from tests import common 6 | 7 | 8 | def override_get_db(): 9 | """ 10 | Override for get_db route dependency. 11 | Provides the scoped session used in testing. 12 | """ 13 | db = common.ScopedSession() 14 | 15 | try: 16 | yield db 17 | finally: 18 | db.close() 19 | -------------------------------------------------------------------------------- /server/tests/api/test_healthcheck.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | 4 | def test_read_me(client: TestClient): 5 | url = "/api/healthcheck" 6 | resp = client.get(url=url) 7 | assert resp.status_code == 200 8 | -------------------------------------------------------------------------------- /server/tests/api/v1/test_auth.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | 4 | def test_use_access_token(admin_role_client): 5 | url = "/api/v1/auth/login/test-token" 6 | resp = admin_role_client.post(url=url) 7 | assert resp.status_code == 200 8 | 9 | 10 | def test_access_protected_endpoint_without_token(client: TestClient): 11 | url = "/api/v1/auth/login/test-token" 12 | resp = client.post(url=url) 13 | assert resp.status_code == 401 14 | -------------------------------------------------------------------------------- /server/tests/api/v1/test_me.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fastapi.testclient import TestClient 3 | 4 | from tests import factories 5 | 6 | 7 | def test_read_me(user_role_client: TestClient): 8 | url = "/api/v1/me" 9 | resp = user_role_client.get(url=url) 10 | assert resp.status_code == 200, resp.json() 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "payload", 15 | [ 16 | {"email": "mynewemail@gmail.com"}, 17 | {"password": "NewSecretPass01"}, 18 | {"first_name": "New"}, 19 | {"last_name": "Name"}, 20 | ], 21 | ) 22 | def test_update_user_me(user_role_client: TestClient, payload: dict): 23 | url = "/api/v1/me" 24 | 25 | resp = user_role_client.put(url=url, json=payload) 26 | assert resp.status_code == 200 27 | 28 | data = resp.json() 29 | 30 | for field in payload: 31 | if field == "password": 32 | pass 33 | else: 34 | assert payload[field].lower() == data[field].lower() 35 | 36 | 37 | @pytest.mark.parametrize( 38 | "payload", 39 | [ 40 | {"email": "imvalid-email-"}, 41 | {"email": "user@@domain.com"}, 42 | {"email": "www.notAnEmail.com"}, 43 | ], 44 | ) 45 | def test_update_user_me_invalid_data(user_role_client: TestClient, payload: dict): 46 | url = "/api/v1/me" 47 | resp = user_role_client.put(url=url, json=payload) 48 | assert 400 <= resp.status_code < 500 49 | 50 | 51 | def test_read_me_roles(user_role_client: TestClient): 52 | url = "/api/v1/me/roles" 53 | resp = user_role_client.get(url=url) 54 | assert resp.status_code == 200 55 | 56 | roles = resp.json() 57 | assert roles[0]["name"] == "user" 58 | 59 | 60 | def test_update_me_shops(user_role_client: TestClient): 61 | shops = [factories.ShopFactory(), factories.ShopFactory()] 62 | shop_ids = [shop.id for shop in shops] 63 | 64 | url = "/api/v1/me/shops" 65 | 66 | resp = user_role_client.put(url=url, json=shop_ids) 67 | assert resp.status_code == 200, resp.text 68 | -------------------------------------------------------------------------------- /server/tests/api/v1/test_roles.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/server/tests/api/v1/test_roles.py -------------------------------------------------------------------------------- /server/tests/common.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import orm 2 | 3 | from app.db.session import SessionLocal 4 | 5 | # Password used for models 6 | USER_PASSWORD = "VerySafePassword01!" 7 | 8 | # Provides a global reference to an existing session. 9 | ScopedSession = orm.scoped_session(SessionLocal) 10 | -------------------------------------------------------------------------------- /server/tests/models/test_role_models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/server/tests/models/test_role_models.py -------------------------------------------------------------------------------- /server/tests/models/test_user_models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaeloliverx/fullstack-fastapi-vuejs-price-aggregator/a686b7b71dca04f90538d00b350158cb6d7e9db2/server/tests/models/test_user_models.py -------------------------------------------------------------------------------- /server/tests/service/test_tokenservice.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import jwt 4 | import pytest 5 | 6 | from app.service import tokenservice 7 | 8 | # jwt.ExpiredSignatureError 9 | # jwt.InvalidAudienceError 10 | 11 | 12 | def test_token(): 13 | """Tests the encode and decode of a json web token.""" 14 | audience = "login-token" 15 | data = {"user_id": 1, "aud": audience} 16 | 17 | token = tokenservice.encode(payload=data, lifetime_seconds=10) 18 | assert isinstance(token, str) 19 | 20 | decoded = tokenservice.decode(token=token, audience=audience) 21 | assert decoded["user_id"] == data["user_id"] 22 | 23 | 24 | def test_token_expired(): 25 | """Tests expired tokens raise an exception.""" 26 | data = {"foo": "bar"} 27 | token = tokenservice.encode(payload=data, lifetime_seconds=0.1) 28 | time.sleep(1) 29 | with pytest.raises(jwt.ExpiredSignatureError): 30 | tokenservice.decode(token=token) 31 | 32 | 33 | def test_token_invalid_audience(): 34 | """Tests wrong audience raises an exception""" 35 | audience = "foo" 36 | data = {"user_id": 1, "aud": audience} 37 | token = tokenservice.encode(payload=data, lifetime_seconds=10) 38 | 39 | # Wrong audience claim 40 | with pytest.raises(jwt.InvalidAudienceError): 41 | tokenservice.decode(token=token, audience="bar") 42 | 43 | # No audience claim 44 | with pytest.raises(jwt.InvalidAudienceError): 45 | tokenservice.decode(token=token) 46 | --------------------------------------------------------------------------------