├── .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 |
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
2 |
3 |
4 |
5 |
6 |
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 |
2 |
6 |
11 |
12 |
13 |
14 |
28 |
29 |
38 |
--------------------------------------------------------------------------------
/client/src/components/Pagination/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
18 |
19 |
20 |
68 |
69 |
79 |
--------------------------------------------------------------------------------
/client/src/components/Screenfull/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
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 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/back-top.svg:
--------------------------------------------------------------------------------
1 |
4 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/bug.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/chart.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/clipboard.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/component.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/dashboard.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/documentation.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/drag.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/edit.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/education.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/email.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/example.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/excel.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/exit-fullscreen.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/eye-off.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/eye-on.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/client/src/icons/svg/form.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/fullscreen.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/guide-2.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/guide.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/hamburger.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/icon.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/international.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/language.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/like.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/link.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/client/src/icons/svg/list.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/lock.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/message.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/money.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/nested.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/password.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/pdf.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/people.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/peoples.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/qq.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/search.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/shopping.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/size.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/skill.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/star.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/tab.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/table.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/theme.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/tree-table.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/tree.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/user.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/icons/svg/wechat.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/icons/svg/zip.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/layout/components/AppMain.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
31 |
32 |
58 |
--------------------------------------------------------------------------------
/client/src/layout/components/Sidebar/SidebarItemLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
31 |
--------------------------------------------------------------------------------
/client/src/layout/components/Sidebar/SidebarLogo.vue:
--------------------------------------------------------------------------------
1 |
2 |
34 |
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 |
2 |
3 |
Admin Dashboard
4 |
5 |
6 |
7 |
21 |
22 |
30 |
--------------------------------------------------------------------------------
/client/src/views/dashboard/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
34 |
--------------------------------------------------------------------------------
/client/src/views/dashboard/user/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
{{ name }}
6 |
Editor's Dashboard
7 |
8 | Your roles:
9 |
14 | {{ item }}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
40 |
41 |
71 |
--------------------------------------------------------------------------------
/client/src/views/profile/components/UserRoles.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
14 |
15 |
16 | {{ row[prop.name] }}
17 |
18 |
19 | {{ new Date(row[prop.name]).toDateString() }}
20 |
21 |
22 |
25 | {{ row[prop.name] }}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
61 |
--------------------------------------------------------------------------------
/client/src/views/profile/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
9 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
60 |
--------------------------------------------------------------------------------
/client/src/views/redirect/index.vue:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/client/src/views/users/EditUser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Edit
4 | {{ user }}
5 |
6 | Log
7 |
8 |
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 |
--------------------------------------------------------------------------------