Compare commits
No commits in common. "75b2b1e1429c9cf5ec0820489798b0ee014d4547" and "c018d2fb52df2e0b6d6e1c9df1c3df0394c6c887" have entirely different histories.
75b2b1e142
...
c018d2fb52
53
.env-example
53
.env-example
@ -1,53 +0,0 @@
|
||||
# Port for the app to run on
|
||||
# PORT=31714
|
||||
|
||||
# Secret key used for cryptographic operations. Must be a long, random string.
|
||||
SECRET_KEY=
|
||||
|
||||
# Token expiration time in minutes. Default is 8 days (60 min * 24 hours * 8 days).
|
||||
# ACCESS_TOKEN_EXPIRE_MINUTES=11520
|
||||
|
||||
# The environment in which the application is running.
|
||||
# Options: local, staging, production
|
||||
ENVIRONMENT=local
|
||||
|
||||
# The frontend host that interacts with the backend.
|
||||
FRONTEND_HOST=
|
||||
|
||||
# CORS origins allowed to access the backend.
|
||||
# Multiple values should be comma-separated, e.g., "http://localhost,http://example.com"
|
||||
BACKEND_CORS_ORIGINS=
|
||||
|
||||
# MySQL database configuration
|
||||
POSTGRES_SERVER=
|
||||
POSTGRES_PORT=3306
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=
|
||||
|
||||
# SMTP configuration for sending emails
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
# Use TLS for email security
|
||||
SMTP_TLS=True
|
||||
# Set to True if using SSL instead of TLS
|
||||
SMTP_SSL=False
|
||||
|
||||
# Email sender information
|
||||
EMAILS_FROM_EMAIL=
|
||||
EMAILS_FROM_NAME=
|
||||
|
||||
# Expiration time for password reset tokens (in hours)
|
||||
# EMAIL_RESET_TOKEN_EXPIRE_HOURS=48
|
||||
|
||||
# A test user email used for automated email testing
|
||||
EMAIL_TEST_USER=test@example.com
|
||||
|
||||
# Superuser credentials for the first administrator
|
||||
FIRST_SUPERUSER=
|
||||
FIRST_SUPERUSER_PASSWORD=
|
||||
|
||||
DOCKER_IMAGE_BACKEND=backend
|
||||
DOCKER_IMAGE_FRONTEND=frontend
|
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
# TODO Fill me up
|
||||
|
||||
HOST=
|
||||
PORT=
|
||||
|
||||
MYSQL_USER=
|
||||
MYSQL_DATABASE=
|
||||
MYSQL_HOST=
|
||||
MYSQL_PORT=
|
||||
MYSQL_PASSWORD=
|
||||
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=
|
||||
|
||||
JWT_SECRET_KEY=
|
||||
|
||||
MAIL_SERVER=
|
||||
MAIL_PORT=
|
||||
MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_USE_TLS=
|
||||
MAIL_DEFAULT_SENDER=
|
295
.gitignore
vendored
295
.gitignore
vendored
@ -1,295 +1,2 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
!frontend/src/lib
|
||||
**/__pycache__/
|
63
.vscode/launch.json
vendored
63
.vscode/launch.json
vendored
@ -1,60 +1,23 @@
|
||||
{
|
||||
// Pro informace o možných atributech použijte technologii IntelliSense.
|
||||
// Umístěním ukazatele myši zobrazíte popisy existujících atributů.
|
||||
// Další informace najdete tady: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Backend",
|
||||
"name": "Flask Shop",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"module": "fastapi",
|
||||
"args": ["dev", "${cwd}/backend/app/main.py"],
|
||||
"program": "${workspaceFolder}/main.py",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"FLASK_APP": "main.py",
|
||||
"FLASK_ENV": "development"
|
||||
},
|
||||
"console": "internalConsole",
|
||||
"serverReadyAction":{
|
||||
"action": "openExternally",
|
||||
"killOnServerStop": false,
|
||||
"pattern": "Application startup complete.",
|
||||
"uriFormat": "http://localhost:8000/docs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Frontend: Dev",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"runtimeExecutable": "npm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"console": "internalConsole"
|
||||
}
|
||||
,
|
||||
{
|
||||
"name": "Frontend: Prod",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"preview"
|
||||
],
|
||||
"runtimeExecutable": "npm",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"console": "internalConsole"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack Debug",
|
||||
"configurations": [
|
||||
"Backend",
|
||||
"Frontend: Dev"
|
||||
]
|
||||
"autoReload": {"enable": true},
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
30
.vscode/settings.json
vendored
30
.vscode/settings.json
vendored
@ -1,13 +1,19 @@
|
||||
{
|
||||
// #region Backend settings
|
||||
"files.exclude" : { "**/__pycache__/**": true }, // Hide __pycache__ directories
|
||||
"mypy-type-checker.args" : ["--config-file='backend/mypy.ini'"], // Override mypy config
|
||||
"python.defaultInterpreterPath" : "./backend/.venv/bin/python", // Use venv by default
|
||||
"python.analysis.extraPaths" : ["./backend"], // Pylint - fix for import analysis
|
||||
"pylint.cwd" : "${workspaceFolder}/backend",
|
||||
// #endregion
|
||||
|
||||
// #region Frontend settings
|
||||
"prettier.configPath": "./frontend/.prettierrc" // Prettier config override
|
||||
// #endregion
|
||||
}
|
||||
"cSpell.words": [
|
||||
"blocklist",
|
||||
"displayname",
|
||||
"dotenv",
|
||||
"gensalt",
|
||||
"hashpw",
|
||||
"checkpw",
|
||||
"jsonify",
|
||||
"lastrowid",
|
||||
"rtype",
|
||||
"flasgger"
|
||||
],
|
||||
"files.exclude": {
|
||||
"**/__pycache__/**": true,
|
||||
},
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
}
|
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
@ -1,14 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build",
|
||||
"path": "frontend",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"label": "npm: build - frontend",
|
||||
"detail": "tsc -b && vite build"
|
||||
}
|
||||
]
|
||||
}
|
213
README.md
213
README.md
@ -13,3 +13,216 @@ Gunicorn is the simplest way to run this project
|
||||
```sh
|
||||
gunicorn -w 4 -b HOST:PORT main:app
|
||||
```
|
||||
|
||||
# Routes
|
||||
|
||||
## Hello World
|
||||
### `GET /`
|
||||
- **Description:** A simple route that returns a JSON message saying 'Hello, Flask!'.
|
||||
- **Response:**
|
||||
- JSON with the following structure:
|
||||
```json
|
||||
{
|
||||
"message": "Hello, Flask!"
|
||||
}
|
||||
```
|
||||
- **Status Code:**
|
||||
- 200: Success.
|
||||
|
||||
|
||||
## Users
|
||||
### `POST /register`
|
||||
- **Description:** Register a new user.
|
||||
- **Request Body:**
|
||||
- JSON with the following fields:
|
||||
- `username` (string): User's username.
|
||||
- `displayname` (string): User's display name.
|
||||
- `email` (string): User's email address.
|
||||
- `password` (string): User's password.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the registration.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if any required field is missing).
|
||||
|
||||
### `POST /login`
|
||||
- **Description:** Log in a user.
|
||||
- **Request Body:**
|
||||
- JSON with the following fields:
|
||||
- `username` (string): User's username.
|
||||
- `password` (string): User's password.
|
||||
- **Response:**
|
||||
- JSON containing authentication token and user information.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if any required field is missing).
|
||||
|
||||
### `DELETE /logout`
|
||||
- **Description:** Log out a user by invalidating the JWT token.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the logout.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /update/username`
|
||||
- **Description:** Update the username of the authenticated user.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Request Body:**
|
||||
- JSON with the following field:
|
||||
- `new_username` (string): New username.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the username update.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if new_username is missing).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /update/displayname`
|
||||
- **Description:** Update the display name of the authenticated user.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Request Body:**
|
||||
- JSON with the following field:
|
||||
- `new_displayname` (string): New display name.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the display name update.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if new_displayname is missing).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /update/email`
|
||||
- **Description:** Update the email address of the authenticated user.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Request Body:**
|
||||
- JSON with the following field:
|
||||
- `new_email` (string): New email address.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the email update.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if new_email is missing).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /update/password`
|
||||
- **Description:** Update the password of the authenticated user.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Request Body:**
|
||||
- JSON with the following field:
|
||||
- `new_password` (string): New password.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the password update.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if new_password is missing).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `DELETE /delete`
|
||||
- **Description:** Delete the account of the authenticated user.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the account deletion.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
## Cart
|
||||
### `GET /`
|
||||
- **Description:** Retrieve the contents of the user's shopping cart.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Response:**
|
||||
- JSON containing a list of dictionaries representing the cart contents.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /add/<int:product_id>`
|
||||
- **Description:** Add a specified quantity of a product to the user's shopping cart.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Parameters:**
|
||||
- `count` (optional, int): Quantity of the product to add. Defaults to 1.
|
||||
- **Response:**
|
||||
- JSON indicating the success of adding the product to the cart.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if count is less than 1).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `DELETE /remove/<int:product_id>`
|
||||
- **Description:** Remove a specific product from the user's shopping cart.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Response:**
|
||||
- JSON indicating the success of removing the product from the cart.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `PUT /update/<int:product_id>`
|
||||
- **Description:** Update the quantity of a product in the user's shopping cart.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Parameters:**
|
||||
- `count` (int): New quantity of the product.
|
||||
- **Response:**
|
||||
- JSON indicating the success of updating the product quantity in the cart.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if count is missing or not a valid integer).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
### `GET /purchase`
|
||||
- **Description:** Complete a purchase, transferring items from the user's cart to the purchase history.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the purchase.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
||||
## Products
|
||||
### `GET /get`
|
||||
- **Description:** Retrieve a paginated list of products.
|
||||
- **Parameters:**
|
||||
- `page` (optional, int): Page number for pagination. Defaults to 0.
|
||||
- **Response:**
|
||||
- JSON containing a list of products.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if the page is less than 0).
|
||||
|
||||
### `GET /<int:id>`
|
||||
- **Description:** Retrieve information about a specific product.
|
||||
- **Parameters:**
|
||||
- `id` (int): Product identifier.
|
||||
- `fields` (optional, string): Comma-separated list of fields to retrieve (e.g., 'name,price,image').
|
||||
- **Response:**
|
||||
- JSON containing information about the specified product.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if invalid fields are provided).
|
||||
|
||||
### `POST /create`
|
||||
- **Description:** Create a new product listing.
|
||||
- **Authentication:**
|
||||
- Requires a valid JWT token.
|
||||
- **Request Body:**
|
||||
- JSON with the following fields:
|
||||
- `name` (string): Product name.
|
||||
- `price` (float): Product price.
|
||||
- **Response:**
|
||||
- JSON indicating the success of the product listing creation.
|
||||
- **Status Codes:**
|
||||
- 200: Success.
|
||||
- 400: Bad Request (if name or price is missing or if price is not a valid float).
|
||||
- 401: Unauthorized (if JWT token is missing or invalid).
|
||||
|
29
app/__init__.py
Normal file
29
app/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
from flask import Flask
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flask_mail import Mail
|
||||
from flasgger import Swagger
|
||||
|
||||
from app.doc.main_swag import main_swagger
|
||||
|
||||
app = Flask(__name__)
|
||||
from app.config import FlaskTesting, FlaskProduction
|
||||
|
||||
app.config.from_object(FlaskTesting)
|
||||
|
||||
flask_mail = Mail(app)
|
||||
jwt_manager = JWTManager(app)
|
||||
swag = Swagger(app, template=main_swagger)
|
||||
|
||||
|
||||
def create_app():
|
||||
from app.api import bp, bp_errors, bp_product, bp_user, bp_cart
|
||||
|
||||
app.register_blueprint(bp)
|
||||
app.register_blueprint(bp_errors)
|
||||
app.register_blueprint(bp_product)
|
||||
app.register_blueprint(bp_user)
|
||||
app.register_blueprint(bp_cart)
|
||||
|
||||
from . import jwt_utils
|
||||
|
||||
return app
|
9
app/api/__init__.py
Normal file
9
app/api/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from flask import Blueprint
|
||||
|
||||
bp_errors = Blueprint('errors', __name__)
|
||||
bp = Blueprint('api', __name__)
|
||||
bp_product = Blueprint('products', __name__, url_prefix="/products")
|
||||
bp_user = Blueprint('user', __name__, url_prefix="/user")
|
||||
bp_cart = Blueprint('cart', __name__, url_prefix="/cart")
|
||||
|
||||
from . import routes
|
15
app/api/routes/__init__.py
Normal file
15
app/api/routes/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
from app.api.routes.user import (
|
||||
register_route,
|
||||
login_route,
|
||||
logout_route,
|
||||
update_route,
|
||||
delete_route,
|
||||
)
|
||||
from app.api.routes.product import (
|
||||
product_create_route,
|
||||
product_delete_route,
|
||||
product_info_route,
|
||||
product_page_route,
|
||||
)
|
||||
|
||||
from app.api.routes import main_routes, error_routes, cart_routes
|
79
app/api/routes/cart_routes.py
Normal file
79
app/api/routes/cart_routes.py
Normal file
@ -0,0 +1,79 @@
|
||||
from flask import jsonify, abort, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from app.doc.cart_swag import (
|
||||
show_cart_swagger,
|
||||
add_to_cart_swagger,
|
||||
remove_from_cart_swagger,
|
||||
update_count_in_cart_swagger,
|
||||
purchase_swagger,
|
||||
)
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.api import bp_cart
|
||||
|
||||
from app.services.cart_service import CartService
|
||||
|
||||
|
||||
@bp_cart.route("", methods=["GET"])
|
||||
@jwt_required()
|
||||
@swag_from(show_cart_swagger)
|
||||
def show_cart():
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
result, status_code = CartService.show_cart(user_id)
|
||||
|
||||
return result, status_code
|
||||
|
||||
|
||||
@bp_cart.route("/add/<int:product_id>", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@swag_from(add_to_cart_swagger)
|
||||
def add_to_cart(product_id: int):
|
||||
user_id = get_jwt_identity()
|
||||
count = request.args.get("count", default=1, type=int)
|
||||
|
||||
if count < 1:
|
||||
return abort(400)
|
||||
|
||||
result, status_code = CartService.add_to_cart(user_id, product_id, count)
|
||||
|
||||
return result, status_code
|
||||
|
||||
|
||||
@bp_cart.route("/remove/<int:product_id>", methods=["DELETE"])
|
||||
@jwt_required()
|
||||
@swag_from(remove_from_cart_swagger)
|
||||
def remove_from_cart(product_id: int):
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
result, status_code = CartService.delete_from_cart(user_id, product_id)
|
||||
|
||||
return result, status_code
|
||||
|
||||
|
||||
@bp_cart.route("/update/<int:product_id>", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@swag_from(update_count_in_cart_swagger)
|
||||
def update_count_in_cart(product_id: int):
|
||||
user_id = get_jwt_identity()
|
||||
count = request.args.get("count", type=int)
|
||||
|
||||
if not count:
|
||||
return abort(400)
|
||||
|
||||
result, status_code = CartService.update_count(user_id, product_id, count)
|
||||
|
||||
return result, status_code
|
||||
|
||||
|
||||
@bp_cart.route("/purchase", methods=["GET"])
|
||||
@jwt_required()
|
||||
@swag_from(purchase_swagger)
|
||||
def purchase():
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
result, status_code = CartService.purchase(user_id)
|
||||
|
||||
return result, status_code
|
38
app/api/routes/error_routes.py
Normal file
38
app/api/routes/error_routes.py
Normal file
@ -0,0 +1,38 @@
|
||||
from app.api import bp_errors
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(400)
|
||||
def bad_request(e):
|
||||
return {
|
||||
"msg": "The request was incorrectly formatted, or contained invalid data"
|
||||
}, 400
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(401)
|
||||
def unauthorized(e):
|
||||
return {"msg": "Failed to authorize the request"}, 401
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(403)
|
||||
def forbidden(e):
|
||||
return {"msg": "You shall not pass"}, 403
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(404)
|
||||
def not_found(e):
|
||||
return {"msg": "The requested resource was not found"}, 404
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(405)
|
||||
def method_not_allowed(e):
|
||||
return {"msg": "The method used is not allowed in current context"}, 405
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(500)
|
||||
def internal_error(e):
|
||||
return {"msg": "An error occurred on he server"}, 500
|
||||
|
||||
|
||||
@bp_errors.app_errorhandler(501)
|
||||
def unimplemented_error(e):
|
||||
return {"msg": "This function has not been implemented yet. Check back soon!"}, 501
|
12
app/api/routes/main_routes.py
Normal file
12
app/api/routes/main_routes.py
Normal file
@ -0,0 +1,12 @@
|
||||
from flask import jsonify
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.doc.root_swag import root_swagger
|
||||
|
||||
from app.api import bp
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@swag_from(root_swagger)
|
||||
def hello():
|
||||
return jsonify({"message": "Hello, Flask!"})
|
33
app/api/routes/product/product_create_route.py
Normal file
33
app/api/routes/product/product_create_route.py
Normal file
@ -0,0 +1,33 @@
|
||||
from flask import jsonify, abort, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from app.doc.product_swag import create_product_swagger
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.api import bp_product
|
||||
|
||||
from app.services.product import product_create_service
|
||||
|
||||
|
||||
@bp_product.route("/create", methods=["POST"])
|
||||
@swag_from(create_product_swagger)
|
||||
@jwt_required()
|
||||
def create_product_listing():
|
||||
user_id = get_jwt_identity()
|
||||
name = request.json.get("name")
|
||||
price = request.json.get("price")
|
||||
|
||||
if name is None or price is None:
|
||||
return abort(400)
|
||||
|
||||
float_price = float(price)
|
||||
|
||||
if not isinstance(float_price, float):
|
||||
return abort(400)
|
||||
|
||||
result, status_code = product_create_service.create_product(
|
||||
user_id, name, float_price
|
||||
)
|
||||
|
||||
return jsonify(result), status_code
|
19
app/api/routes/product/product_delete_route.py
Normal file
19
app/api/routes/product/product_delete_route.py
Normal file
@ -0,0 +1,19 @@
|
||||
from flask import jsonify, abort, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.api import bp_product
|
||||
|
||||
from app.services.product import product_delete_service
|
||||
|
||||
|
||||
@bp_product.route("/<int:product_id>/delete", methods=["DELETE"])
|
||||
@jwt_required()
|
||||
def delete_product(product_id: int):
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
|
||||
result, status_code = product_delete_service.delete_product(user_id, product_id)
|
||||
|
||||
return jsonify(result), status_code
|
25
app/api/routes/product/product_info_route.py
Normal file
25
app/api/routes/product/product_info_route.py
Normal file
@ -0,0 +1,25 @@
|
||||
from flask import jsonify, request
|
||||
|
||||
from app.doc.product_swag import get_product_info_swagger
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.api import bp_product
|
||||
|
||||
from app.services.product import product_info_service
|
||||
|
||||
|
||||
@bp_product.route("/<int:product_id>", methods=["GET"])
|
||||
@swag_from(get_product_info_swagger)
|
||||
def get_product_info(product_id: int):
|
||||
fields = ["name", "price", "image", "image_name", "seller"]
|
||||
|
||||
fields_param = request.args.get("fields")
|
||||
|
||||
fields_param_list = fields_param.split(",") if fields_param else fields
|
||||
|
||||
common_fields = list(set(fields) & set(fields_param_list))
|
||||
|
||||
result, status_code = product_info_service.product_info(product_id)
|
||||
|
||||
return jsonify(result), status_code
|
22
app/api/routes/product/product_page_route.py
Normal file
22
app/api/routes/product/product_page_route.py
Normal file
@ -0,0 +1,22 @@
|
||||
from flask import jsonify, abort, request
|
||||
|
||||
from app.doc.product_swag import get_products_swagger
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.api import bp_product
|
||||
|
||||
from app.services.product import product_list_service
|
||||
|
||||
|
||||
@bp_product.route("", methods=["GET"])
|
||||
@swag_from(get_products_swagger)
|
||||
def get_products():
|
||||
page = request.args.get("page", default=0, type=int)
|
||||
|
||||
if page < 0:
|
||||
return abort(400)
|
||||
|
||||
result, status_code = product_list_service.product_list(page)
|
||||
|
||||
return jsonify(result), status_code
|
22
app/api/routes/user/delete_route.py
Normal file
22
app/api/routes/user/delete_route.py
Normal file
@ -0,0 +1,22 @@
|
||||
from app.api import bp_user
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from flask import request, abort
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from app.doc.user_swag import delete_swagger
|
||||
from app.services.user import delete_service, logout_service
|
||||
|
||||
|
||||
@bp_user.route("/delete", methods=["DELETE"])
|
||||
@swag_from(delete_swagger)
|
||||
@jwt_required()
|
||||
def delete_user():
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
result, status_code = delete_service.delete_user(user_id)
|
||||
|
||||
jwt = get_jwt()
|
||||
logout_service.logout(jwt, user_id, True)
|
||||
|
||||
return result, status_code
|
33
app/api/routes/user/login_route.py
Normal file
33
app/api/routes/user/login_route.py
Normal file
@ -0,0 +1,33 @@
|
||||
from app.api import bp_user
|
||||
from flask import request, jsonify
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
import app.messages.api_responses.user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
from app.doc.user_swag import login_swagger
|
||||
|
||||
from app.services.user import login_service
|
||||
|
||||
@bp_user.route("/login", methods=["POST"])
|
||||
@swag_from(login_swagger)
|
||||
def login():
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
result, status_code = errors.NOT_JSON
|
||||
return jsonify(result), status_code
|
||||
|
||||
required_fields = ["username", "password"]
|
||||
missing_fields = [field for field in required_fields if field not in data]
|
||||
|
||||
if missing_fields:
|
||||
result, status_code = errors.MISSING_FIELDS(missing_fields)
|
||||
return jsonify(result), status_code
|
||||
|
||||
username = data["username"]
|
||||
password = data["password"]
|
||||
|
||||
result, status_code = login_service.login(username, password)
|
||||
|
||||
return result, status_code
|
20
app/api/routes/user/logout_route.py
Normal file
20
app/api/routes/user/logout_route.py
Normal file
@ -0,0 +1,20 @@
|
||||
from app.api import bp_user
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
from flask_jwt_extended import get_jwt_identity, jwt_required, get_jwt
|
||||
|
||||
from app.doc.user_swag import logout_swagger
|
||||
from app.services.user import logout_service
|
||||
|
||||
|
||||
@bp_user.route("/logout", methods=["DELETE"])
|
||||
@swag_from(logout_swagger)
|
||||
@jwt_required()
|
||||
def logout():
|
||||
jwt = get_jwt()
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
result, status_code = logout_service.logout(jwt, user_id, True)
|
||||
|
||||
return result, status_code
|
39
app/api/routes/user/register_route.py
Normal file
39
app/api/routes/user/register_route.py
Normal file
@ -0,0 +1,39 @@
|
||||
from app.api import bp_user
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.services.user import register_service
|
||||
|
||||
from app.doc.user_swag import register_swagger
|
||||
import app.messages.api_responses.user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
|
||||
@bp_user.route("/register", methods=["POST"])
|
||||
@swag_from(register_swagger)
|
||||
def register():
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
result, status_code = errors.NOT_JSON
|
||||
return jsonify(result), status_code
|
||||
|
||||
required_fields = ["username", "displayname", "email", "password"]
|
||||
missing_fields = [field for field in required_fields if field not in data]
|
||||
|
||||
if missing_fields:
|
||||
result, status_code = errors.MISSING_FIELDS(missing_fields)
|
||||
return jsonify(result), status_code
|
||||
|
||||
username = data["username"]
|
||||
displayname = data["displayname"]
|
||||
email = data["email"]
|
||||
password = data["password"]
|
||||
|
||||
result, status_code = register_service.register(
|
||||
username, displayname, email, password
|
||||
)
|
||||
|
||||
return jsonify(result), status_code
|
40
app/api/routes/user/update_route.py
Normal file
40
app/api/routes/user/update_route.py
Normal file
@ -0,0 +1,40 @@
|
||||
from app.api import bp_user
|
||||
from flask import request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
|
||||
from flasgger import swag_from
|
||||
|
||||
import app.messages.api_errors as errors
|
||||
from app.doc.user_swag import update_swagger
|
||||
|
||||
from app.services.user import logout_service, update_user_service
|
||||
|
||||
|
||||
@bp_user.route("/update", methods=["PUT"])
|
||||
@swag_from(update_swagger)
|
||||
@jwt_required()
|
||||
def update_user():
|
||||
data = request.get_json()
|
||||
|
||||
possible_fields = ["new_username", "new_displayname", "new_email", "new_password"]
|
||||
selected_fields = [field for field in possible_fields if field in data]
|
||||
|
||||
if not selected_fields:
|
||||
result, status_code = errors.NO_FIELD_PROVIDED(possible_fields)
|
||||
return jsonify(result), status_code
|
||||
|
||||
user_id = get_jwt_identity()
|
||||
|
||||
new_username = data.get("new_username")
|
||||
new_displayname = data.get("new_displayname")
|
||||
new_email = data.get("new_email")
|
||||
new_password = data.get("new_password")
|
||||
|
||||
result, status_code = update_user_service.update_user(user_id, new_username, new_displayname, new_email, new_password)
|
||||
|
||||
if status_code < 300:
|
||||
jwt = get_jwt()
|
||||
logout_service.logout(jwt, user_id, False)
|
||||
|
||||
return result, status_code
|
||||
|
42
app/config.py
Normal file
42
app/config.py
Normal file
@ -0,0 +1,42 @@
|
||||
import os
|
||||
|
||||
|
||||
class MySqlConfig:
|
||||
MYSQL_USER = os.environ.get("MYSQL_USER")
|
||||
MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE")
|
||||
MYSQL_HOST = os.environ.get("MYSQL_HOST")
|
||||
MYSQL_PORT = os.environ.get("MYSQL_PORT")
|
||||
MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD")
|
||||
|
||||
|
||||
class RedisConfig:
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST")
|
||||
REDIS_PORT = os.environ.get("REDIS_PORT")
|
||||
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD")
|
||||
|
||||
|
||||
class FlaskProduction:
|
||||
DEBUG = False
|
||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
|
||||
SERVER_NAME = os.environ.get("HOST") + ":" + os.environ.get("PORT")
|
||||
|
||||
MAIL_SERVER = os.environ.get("MAIL_SERVER")
|
||||
MAIL_PORT = os.environ.get("MAIL_PORT")
|
||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
|
||||
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS")
|
||||
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER")
|
||||
|
||||
|
||||
class FlaskTesting:
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
|
||||
SERVER_NAME = os.environ.get("HOST") + ":" + os.environ.get("PORT")
|
||||
|
||||
MAIL_SERVER = os.environ.get("MAIL_SERVER")
|
||||
MAIL_PORT = os.environ.get("MAIL_PORT")
|
||||
MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
|
||||
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
|
||||
MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS")
|
||||
MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER")
|
118
app/db/product_db.py
Normal file
118
app/db/product_db.py
Normal file
@ -0,0 +1,118 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db_connection
|
||||
|
||||
from app.models.product_model import Product
|
||||
|
||||
|
||||
def fetch_products(page: int = 0) -> Optional[list[Product]]:
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
offset = 10 * page
|
||||
cursor.execute(
|
||||
"select product.id, user.displayname as seller, product.name, product.price_pc from product inner join user on user.id = product.seller_id order by product.id limit 10 offset %s",
|
||||
(offset,),
|
||||
)
|
||||
results = cursor.fetchall()
|
||||
|
||||
if len(results) < 1:
|
||||
return None
|
||||
|
||||
result_products: list[Product] = []
|
||||
|
||||
for row in results:
|
||||
result_products.append(
|
||||
Product(
|
||||
product_id=row["id"],
|
||||
seller_id=row["seller_id"],
|
||||
name=row["name"],
|
||||
price=row["price"],
|
||||
creation_date=row["creation_date"],
|
||||
)
|
||||
)
|
||||
|
||||
return result_products
|
||||
|
||||
def fetch_product_by_id(product_id: int) -> Optional[Product]:
|
||||
"""
|
||||
Fetches specific product info
|
||||
|
||||
:param product_id: ID of product to be updated.
|
||||
:type product_id: int
|
||||
"""
|
||||
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("select * from product where id = %s", (product_id,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if cursor.rowcount != 1:
|
||||
return None
|
||||
|
||||
result_product = Product(
|
||||
product_id=result["id"],
|
||||
seller_id=result["seller_id"],
|
||||
name=result["name"],
|
||||
price=result["price"],
|
||||
creation_date=result["creation_date"],
|
||||
)
|
||||
|
||||
return result_product
|
||||
|
||||
|
||||
def fetch_product_extended_by_id(product_id: int) -> Optional[Product]:
|
||||
"""
|
||||
Fetches specific product info including the seller n
|
||||
|
||||
:param product_id: ID of product to be updated.
|
||||
:type product_id: int
|
||||
"""
|
||||
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("select * from product inner join user on user.id = product.seller_id where product.id = %s", (product_id,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if cursor.rowcount != 1:
|
||||
return None
|
||||
|
||||
result_product = Product(
|
||||
product_id=result["id"],
|
||||
seller_id=result["seller_id"],
|
||||
seller_name=result["displayname"],
|
||||
name=result["name"],
|
||||
price=result["price"],
|
||||
creation_date=result["creation_date"],
|
||||
)
|
||||
|
||||
return result_product
|
||||
|
||||
def insert_product(product: Product):
|
||||
"""
|
||||
Creates a new product listing
|
||||
|
||||
:param seller_id: User ID
|
||||
:type seller_id: str
|
||||
:param name: New product's name
|
||||
:type name: str
|
||||
:param price: New product's price
|
||||
:type price: float
|
||||
"""
|
||||
|
||||
cursor = db_connection.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"insert into product(seller_id, name, price_pc) values (%s, %s, %s)",
|
||||
(product.seller_id, product.name, round(product.price, 2)),
|
||||
)
|
||||
db_connection.commit()
|
||||
|
||||
|
||||
def delete_product(product: Product):
|
||||
cursor = db_connection.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"delete from product where id = %s",
|
||||
(product.product_id,),
|
||||
)
|
||||
db_connection.commit()
|
79
app/db/user_db.py
Normal file
79
app/db/user_db.py
Normal file
@ -0,0 +1,79 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.extensions import db_connection
|
||||
|
||||
from app.models.user_model import User
|
||||
|
||||
|
||||
def fetch_by_username(username: str) -> Optional[User]:
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("select * from user where username = %s", (username,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
|
||||
result_user = (
|
||||
User(
|
||||
user_id=result["id"],
|
||||
username=result["username"],
|
||||
displayname=result["displayname"],
|
||||
email=result["email"],
|
||||
password=result["password"],
|
||||
role_id=result["role_id"],
|
||||
creation_date=result["creation_date"],
|
||||
)
|
||||
if result
|
||||
else None
|
||||
)
|
||||
|
||||
return result_user
|
||||
|
||||
|
||||
def fetch_by_id(user_id: int) -> Optional[User]:
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("select * from user where id = %s", (user_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
result_user = (
|
||||
User(
|
||||
user_id=result["id"],
|
||||
username=result["username"],
|
||||
displayname=result["displayname"],
|
||||
email=result["email"],
|
||||
password=result["password"],
|
||||
role_id=result["role_id"],
|
||||
creation_date=result["creation_date"],
|
||||
)
|
||||
if result
|
||||
else None
|
||||
)
|
||||
|
||||
return result_user
|
||||
|
||||
|
||||
def insert_user(new_user: User):
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute(
|
||||
"insert into user (username, displayname, email, password) values (%s, %s, %s, %s)",
|
||||
(new_user.username, new_user.displayname, new_user.email, new_user.password),
|
||||
)
|
||||
db_connection.commit()
|
||||
|
||||
|
||||
def delete_user(user: User):
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("delete from user where id = %s", (user.user_id,))
|
||||
db_connection.commit()
|
||||
|
||||
|
||||
def update_user(user: User):
|
||||
cursor = db_connection.cursor(dictionary=True)
|
||||
|
||||
cursor.execute(
|
||||
"update user set username=%s, displayname=%s, email=%s, password=%s where id = %s",
|
||||
(user.username, user.displayname, user.email, user.password, user.user_id),
|
||||
)
|
||||
db_connection.commit()
|
118
app/doc/cart_swag.py
Normal file
118
app/doc/cart_swag.py
Normal file
@ -0,0 +1,118 @@
|
||||
show_cart_swagger = {
|
||||
"tags": ["Cart"],
|
||||
"security": [
|
||||
{"JWT": []}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Current content of user's shopping cart",
|
||||
"schema": {
|
||||
"items": {
|
||||
"count": {"type": "int"},
|
||||
"date_added": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"price_subtotal": {"type": "string"}
|
||||
},
|
||||
"example": [
|
||||
{
|
||||
"count": 5,
|
||||
"date_added": "Fri, 08 Mar 2024 08:43:09 GMT",
|
||||
"name": "Tablet",
|
||||
"price_subtotal": "1499.95"
|
||||
},
|
||||
{
|
||||
"count": 2,
|
||||
"date_added": "Fri, 08 Mar 2024 06:43:09 GMT",
|
||||
"name": "Laptop",
|
||||
"price_subtotal": "999.95"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
add_to_cart_swagger ={
|
||||
"tags": ["Cart"],
|
||||
"security": [
|
||||
{"JWT": []}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "product_id",
|
||||
"description": "ID of product to add to cart.",
|
||||
"in": "path",
|
||||
"type": "int",
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"description": "Count of the products. If not provided, defaults to 1",
|
||||
"in": "query",
|
||||
"type": "int",
|
||||
"default": 1,
|
||||
"minimum": 1,
|
||||
"required": False
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Successfully added a product to cart"},
|
||||
"400": {"description": "Causes:\n- Count is < 1"}
|
||||
}
|
||||
}
|
||||
|
||||
remove_from_cart_swagger = {
|
||||
"tags": ["Cart"],
|
||||
"security": [{"JWT": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "product_id",
|
||||
"in": "path",
|
||||
"type": "integer",
|
||||
"description": "ID of the product to be removed from the cart",
|
||||
"required": True
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Successfully removed item from the cart"},
|
||||
"400": {"description": "Bad Request - Invalid input"},
|
||||
"500": {"description": "Internal Server Error"}
|
||||
}
|
||||
}
|
||||
|
||||
update_count_in_cart_swagger = {
|
||||
"tags": ["Cart"],
|
||||
"security": [{"JWT": []}],
|
||||
"description": "Updates the count of products in the user's cart. If the count is less than or equal to 0, the product will be removed from the cart.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "product_id",
|
||||
"in": "path",
|
||||
"type": "integer",
|
||||
"description": "ID of the product to update in the cart",
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"in": "query",
|
||||
"type": "integer",
|
||||
"description": "New count of the product in the cart",
|
||||
"required": True
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Successfully updated item count in the cart"},
|
||||
"400": {"description": "Bad Request - Invalid input"},
|
||||
"500": {"description": "Internal Server Error"}
|
||||
}
|
||||
}
|
||||
|
||||
purchase_swagger = {
|
||||
"tags": ["Cart"],
|
||||
"security": [{"JWT": []}],
|
||||
"description": "Purchases the contents of the user's cart. This action creates a new purchase, moves items from the cart to the purchase history, and clears the cart.",
|
||||
"responses": {
|
||||
"200": {"description": "Successfully completed the purchase"},
|
||||
"400": {"description": "Bad Request - Invalid input or cart is empty"},
|
||||
"500": {"description": "Internal Server Error"}
|
||||
}
|
||||
}
|
18
app/doc/main_swag.py
Normal file
18
app/doc/main_swag.py
Normal file
@ -0,0 +1,18 @@
|
||||
main_swagger = {
|
||||
"info": {
|
||||
"title": "Swag Shop",
|
||||
"version": "0.1",
|
||||
"description": "Simple shop API using flask and co.\nFeatures include:\n- Not working\n- Successful registration of users\n- Adding items to cart\n- I don't know",
|
||||
},
|
||||
"host": "localhost:1236",
|
||||
"schemes": "http",
|
||||
"securityDefinitions": {
|
||||
"JWT": {
|
||||
"type": "apiKey",
|
||||
"scheme": "bearer",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "JWT Authorization header using the Bearer scheme.\n*Make sure to prefix the token with **Bearer**!*"
|
||||
}
|
||||
}
|
||||
}
|
73
app/doc/product_swag.py
Normal file
73
app/doc/product_swag.py
Normal file
@ -0,0 +1,73 @@
|
||||
get_products_swagger = {
|
||||
"methods": ["GET"],
|
||||
"tags": ["Products"],
|
||||
"parameters": [
|
||||
|
||||
],
|
||||
"responses":
|
||||
{
|
||||
"200":
|
||||
{
|
||||
"description": "Get a page of products",
|
||||
"schema":
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string", "example": "Hello, Flask!"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get_product_info_swagger = {
|
||||
"tags": ["Products"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"type": "integer",
|
||||
"description": "ID of the product to fetch information for",
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"in": "query",
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of fields to include in the response",
|
||||
"required": False
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Successfully fetched product information"},
|
||||
"400": {"description": "Bad Request - Invalid input or product doesn't exist"},
|
||||
"500": {"description": "Internal Server Error"}
|
||||
}
|
||||
}
|
||||
|
||||
create_product_swagger = {
|
||||
"methods": ["POST"],
|
||||
"tags": ["Products"],
|
||||
"security": [{"JWT": []}],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "body",
|
||||
"type": "string",
|
||||
"description": "Name for the new product",
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"name": "price",
|
||||
"in": "body",
|
||||
"type": "float",
|
||||
"description": "Price of the product",
|
||||
"required": True
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Successfully fetched product information"},
|
||||
"400": {"description": "Bad Request - Invalid input or missing input"},
|
||||
"500": {"description": "Internal Server Error"}
|
||||
}
|
||||
}
|
18
app/doc/root_swag.py
Normal file
18
app/doc/root_swag.py
Normal file
@ -0,0 +1,18 @@
|
||||
root_swagger = {
|
||||
"methods": ["GET"],
|
||||
"responses":
|
||||
{
|
||||
"200":
|
||||
{
|
||||
"description": "A hello world json",
|
||||
"schema":
|
||||
{
|
||||
"type": "object",
|
||||
"properties":
|
||||
{
|
||||
"message": {"type": "string", "example": "Hello, Flask!"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
116
app/doc/user_swag.py
Normal file
116
app/doc/user_swag.py
Normal file
@ -0,0 +1,116 @@
|
||||
register_swagger = {
|
||||
"methods": ["POST"],
|
||||
"tags": ["User"],
|
||||
"description": "Registers a new user in the app. Also sends a notification to the user via the provided email",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"description": 'Username, displayname and password of the new user\n- Username can be only lowercase and up to 64 characters\n- Displayname can contain special characters (. _ -) and lower and upper characters\n- Password must be at least 8 characters long, contain both lower and upper characters, numbers and special characters\n- Email has to be in format "name@domain.tld" and up to 64 characters long in total',
|
||||
"required": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {"type": "string", "example": "mycoolusername"},
|
||||
"email": {"type": "string", "example": "mymail@dot.com"},
|
||||
"displayname": {"type": "string", "example": "MyCoolDisplayName"},
|
||||
"password": {"type": "string", "example": "My5tr0ngP@55w0rd"},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
login_swagger = {
|
||||
"methods": ["POST"],
|
||||
"tags": ["User"],
|
||||
"description": "Logs in using username and password and returns a JWT token for further authorization of requests.\n**The token is valid for 1 hour**",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"description": "Username and password payload",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {"type": "string", "example": "mycoolusername"},
|
||||
"password": {"type": "string", "example": "MyStrongPassword123"},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Returns a fresh token",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTcxMDMyMjkyOCwianRpIjoiZDFhYzQxZDktZjA4NC00MmYzLThlMWUtZWFmZjJiNGU1MDAyIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6MjMwMDEsIm5iZiI6MTcxMDMyMjkyOCwiZXhwIjoxNzEwMzI2NTI4fQ.SW7LAi1j5vDOEIvzeN-sy0eHPP9PFJFkXYY029O35w0",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
"description": "Possible causes:\n- Missing username or password from request.\n- Nonexistent username"
|
||||
},
|
||||
"401": {"description": "Password is incorrect"},
|
||||
},
|
||||
}
|
||||
|
||||
logout_swagger = {
|
||||
"methods": ["DELETE"],
|
||||
"tags": ["User"],
|
||||
"security": [{"JWT": []}],
|
||||
"description": "Logs out the user via provided JWT token",
|
||||
"parameters": [],
|
||||
"responses": {"200": {"description": "User successfully logged out"}},
|
||||
}
|
||||
|
||||
update_swagger = {
|
||||
"methods": ["PUT"],
|
||||
"tags": ["User"],
|
||||
"security": [{"JWT": []}],
|
||||
"description": "Updates user attributes.",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"description": "Attributes to update for the user.",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_username": {"type": "string", "example": "mycoolusername"},
|
||||
"new_email": {"type": "string", "example": "mymail@dot.com"},
|
||||
"new_displayname": {
|
||||
"type": "string",
|
||||
"example": "MyCoolDisplayName",
|
||||
},
|
||||
"new_password": {"type": "string", "example": "My5tr0ngP@55w0rd"},
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "User attributes updated successfully."},
|
||||
"400": {"description": "Bad request. Check the request body for errors."},
|
||||
"401": {"description": "Unauthorized. User must be logged in."},
|
||||
"409": {"description": "Conflict. Check the response message for details."},
|
||||
"500": {
|
||||
"description": "Internal server error. Contact the system administrator."
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
delete_swagger = {
|
||||
"methods": ["DELETE"],
|
||||
"tags": ["User"],
|
||||
"security": [{"JWT": []}],
|
||||
"description": "Deletes a user via JWT token",
|
||||
"parameters": [],
|
||||
"responses": {"200": {"description": "User successfully deleted"}},
|
||||
}
|
21
app/extensions.py
Normal file
21
app/extensions.py
Normal file
@ -0,0 +1,21 @@
|
||||
import mysql.connector
|
||||
import redis
|
||||
import os
|
||||
|
||||
from app.config import RedisConfig
|
||||
from app.config import MySqlConfig
|
||||
|
||||
db_connection = mysql.connector.connect(
|
||||
host=MySqlConfig.MYSQL_HOST,
|
||||
user=MySqlConfig.MYSQL_USER,
|
||||
password=MySqlConfig.MYSQL_PASSWORD,
|
||||
database=MySqlConfig.MYSQL_DATABASE,
|
||||
)
|
||||
|
||||
jwt_redis_blocklist = redis.StrictRedis(
|
||||
host=RedisConfig.REDIS_HOST,
|
||||
port=RedisConfig.REDIS_PORT,
|
||||
password=RedisConfig.REDIS_PASSWORD,
|
||||
db=0,
|
||||
decode_responses=True,
|
||||
)
|
13
app/jwt_utils.py
Normal file
13
app/jwt_utils.py
Normal file
@ -0,0 +1,13 @@
|
||||
from app.extensions import jwt_redis_blocklist
|
||||
|
||||
from . import jwt_manager
|
||||
|
||||
from app import app
|
||||
|
||||
|
||||
@jwt_manager.token_in_blocklist_loader
|
||||
def check_if_token_is_revoked(jwt_header, jwt_payload: dict) -> bool:
|
||||
jti = jwt_payload["jti"]
|
||||
token_in_redis = jwt_redis_blocklist.get(jti)
|
||||
|
||||
return token_in_redis is not None
|
17
app/mail/mail.py
Normal file
17
app/mail/mail.py
Normal file
@ -0,0 +1,17 @@
|
||||
from flask_mail import Message
|
||||
|
||||
from app import flask_mail
|
||||
|
||||
from app.mail.message_content import MessageContent
|
||||
|
||||
|
||||
def send_mail(message: MessageContent, recipient: str):
|
||||
|
||||
msg = Message(subject=message.subject, recipients=[recipient], body=message.body)
|
||||
|
||||
try:
|
||||
flask_mail.send(msg)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send email. Error: {e}")
|
||||
return False
|
4
app/mail/message_content.py
Normal file
4
app/mail/message_content.py
Normal file
@ -0,0 +1,4 @@
|
||||
class MessageContent:
|
||||
def __init__(self, subject, body):
|
||||
self.subject = subject
|
||||
self.body = body
|
15
app/messages/api_errors.py
Normal file
15
app/messages/api_errors.py
Normal file
@ -0,0 +1,15 @@
|
||||
NOT_JSON = {"msg": "Request body must be JSON"}, 400
|
||||
|
||||
|
||||
def UNKNOWN_DATABASE_ERROR(e):
|
||||
return {"msg": f"An unknown error occurred within the database. {e}"}, 500
|
||||
|
||||
|
||||
def MISSING_FIELDS(fields):
|
||||
return {"msg": f"Missing required fields: {', '.join(fields)}"}, 400
|
||||
|
||||
|
||||
def NO_FIELD_PROVIDED(possible_fields):
|
||||
return {
|
||||
"msg": f"No field was provided. At least one of the following is required: {', '.join(possible_fields)}"
|
||||
}, 400
|
6
app/messages/api_responses/product_responses.py
Normal file
6
app/messages/api_responses/product_responses.py
Normal file
@ -0,0 +1,6 @@
|
||||
PRODUCT_LISTING_CREATED_SUCCESSFULLY = {"msg": "Successfully created a brand new product."}, 201
|
||||
|
||||
NOT_OWNER_OF_PRODUCT = {"msg": "You don't own this product, therefore you cannot delete it!"}, 400
|
||||
UNKNOWN_PRODUCT = {"msg": "The product you tried fetching is not known. Try a different product ID."}, 400
|
||||
|
||||
SCROLLED_TOO_FAR = {"msg": "You scrolled too far in the pages. Try going back a little again."}, 400
|
26
app/messages/api_responses/user_responses.py
Normal file
26
app/messages/api_responses/user_responses.py
Normal file
@ -0,0 +1,26 @@
|
||||
USER_CREATED_SUCCESSFULLY = {"msg": "User created successfully."}, 201
|
||||
USER_LOGGED_OUT_SUCCESSFULLY = {"msg": "Successfully logged out"}, 200
|
||||
USER_DELETED_SUCCESSFULLY = {"msg": "User successfully deleted"}, 200
|
||||
|
||||
|
||||
def USER_ACCOUNT_UPDATED_SUCCESSFULLY(updated_attributes):
|
||||
return {"msg": f"Successfully updated your accounts {', '.join(updated_attributes)}"}, 200
|
||||
|
||||
INVALID_USERNAME_FORMAT = {
|
||||
"msg": "Username is in incorrect format. It must be between 1 and 64 lowercase characters."
|
||||
}, 400
|
||||
INVALID_DISPLAYNAME_FORMAT = {
|
||||
"msg": "Display name is in incorrect format. It must contain only letters, '.', '-', or '_' and be between 1 and 64 characters."
|
||||
}, 400
|
||||
INVALID_EMAIL_FORMAT = {"msg": "Email is in incorrect format."}, 400
|
||||
INVALID_PASSWORD_FORMAT = {
|
||||
"msg": "Password is in incorrect format. It must be between 8 and 64 characters and contain at least one uppercase letter, one lowercase letter, one digit, and one special character"
|
||||
}, 400
|
||||
|
||||
EMAIL_ALREADY_IN_USE = {"msg": "Email already in use."}, 409
|
||||
USERNAME_ALREADY_IN_USE = {"msg": "Username already in use."}, 409
|
||||
|
||||
USERNAME_NOT_FOUND = {"msg": "Username not found"}, 400
|
||||
INCORRECT_PASSWORD = {"msg": "Incorrect password"}, 401
|
||||
|
||||
UNKNOWN_ERROR = {"msg": "An unknown error occurred with user"}, 500
|
26
app/messages/mail_responses/user_email.py
Normal file
26
app/messages/mail_responses/user_email.py
Normal file
@ -0,0 +1,26 @@
|
||||
from app.mail.message_content import MessageContent
|
||||
|
||||
USER_EMAIL_SUCCESSFULLY_REGISTERED = MessageContent(
|
||||
subject="Successfully registered!",
|
||||
body="Congratulations! Your account has been successfully created.\nThis mail also serves as a test that the email address is correct",
|
||||
)
|
||||
|
||||
USER_EMAIL_SUCCESSFULLY_LOGGED_IN = MessageContent(
|
||||
subject="New Login detected!",
|
||||
body="A new login token has been created",
|
||||
)
|
||||
|
||||
USER_EMAIL_SUCCESSFULLY_LOGGED_OUT = MessageContent(
|
||||
subject="Successfully logged out",
|
||||
body="A login has been revoked. No further action is needed.",
|
||||
)
|
||||
|
||||
USER_EMAIL_SUCCESSFULLY_UPDATED_ACCOUNT = MessageContent(
|
||||
subject="Account updated",
|
||||
body="Your account has been successfully updated. This also means you have been logged out of everywhere",
|
||||
)
|
||||
|
||||
USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT = MessageContent(
|
||||
subject="Account Deleted!",
|
||||
body="Your account has been deleted. No further action needed",
|
||||
)
|
63
app/models/cart_model.py
Normal file
63
app/models/cart_model.py
Normal file
@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
|
||||
class Cart:
|
||||
"""
|
||||
Represents a cart in the system.
|
||||
|
||||
:param id: The unique identifier of the cart.
|
||||
:type id: int
|
||||
:param price_total: The total price of the cart.
|
||||
:type price_total: float
|
||||
:param item_count: The count of items in the cart.
|
||||
:type item_count: int
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cart_id: int = None,
|
||||
price_total: float = 0.00,
|
||||
item_count: int = 0,
|
||||
):
|
||||
self.id = cart_id
|
||||
self.price_total = price_total
|
||||
self.item_count = item_count
|
||||
|
||||
def __repr__(self):
|
||||
return f"Cart(id={self.id}, price_total={self.price_total}, item_count={self.item_count})"
|
||||
|
||||
class CartItem:
|
||||
"""
|
||||
Represents a cart item in the system.
|
||||
|
||||
:param id: The unique identifier of the cart item.
|
||||
:type id: int
|
||||
:param cart_id: The identifier of the cart.
|
||||
:type cart_id: int
|
||||
:param product_id: The identifier of the product.
|
||||
:type product_id: int
|
||||
:param count: The count of the product in the cart.
|
||||
:type count: int
|
||||
:param price_subtotal: The subtotal price of the product in the cart.
|
||||
:type price_subtotal: float
|
||||
:param date_added: The date and time when the item was added to the cart.
|
||||
:type date_added: datetime
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cart_item_id: int = None,
|
||||
cart_id: int = None,
|
||||
product_id: int = None,
|
||||
count: int = 0,
|
||||
price_subtotal: float = 0.00,
|
||||
date_added: datetime = None,
|
||||
):
|
||||
self.id = cart_item_id
|
||||
self.cart_id = cart_id
|
||||
self.product_id = product_id
|
||||
self.count = count
|
||||
self.price_subtotal = price_subtotal
|
||||
self.date_added = date_added or datetime.now()
|
||||
|
||||
def __repr__(self):
|
||||
return f"CartItem(id={self.id}, cart_id={self.cart_id}, product_id={self.product_id}, count={self.count}, price_subtotal={self.price_subtotal}, date_added={self.date_added})"
|
38
app/models/product_model.py
Normal file
38
app/models/product_model.py
Normal file
@ -0,0 +1,38 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class Product:
|
||||
"""
|
||||
Represents a product in the system.
|
||||
|
||||
:param id: The unique identifier of the product.
|
||||
:type id: int
|
||||
:param seller_id: The user ID of the seller.
|
||||
:type seller_id: int
|
||||
:param name: The name of the product.
|
||||
:type name: str
|
||||
:param price: The price of the product.
|
||||
:type price: Decimal
|
||||
:param creation_date: The date and time when the product was created.
|
||||
:type creation_date: datetime
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_id: int = None,
|
||||
seller_id: int = None,
|
||||
seller_name: str = None,
|
||||
name: str = None,
|
||||
price: Decimal = None,
|
||||
creation_date: datetime = None,
|
||||
):
|
||||
self.product_id = product_id
|
||||
self.seller_id = seller_id
|
||||
self.seller_name = seller_name
|
||||
self.name = name
|
||||
self.price = price
|
||||
self.creation_date = creation_date
|
||||
|
||||
def __repr__(self):
|
||||
return f"Product(product_id={self.product_id}, seller_id={self.seller_id}, seller_name={self.seller_name}, name='{self.name}', price={self.price}, creation_date={self.creation_date!r})"
|
44
app/models/user_model.py
Normal file
44
app/models/user_model.py
Normal file
@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User:
|
||||
"""
|
||||
Represents a user in the system.
|
||||
|
||||
:param user_id: The unique identifier of the user.
|
||||
:type user_id: int
|
||||
:param username: The username of the user.
|
||||
:type username: str
|
||||
:param displayname: The display name of the user.
|
||||
:type displayname: str
|
||||
:param email: The email address of the user.
|
||||
:type email: str
|
||||
:param password: The hashed password of the user.
|
||||
:type password: str
|
||||
:param role_id: The role ID of the user. Defaults to 1.
|
||||
:type role_id: int
|
||||
:param creation_date: The date and time when the user was created.
|
||||
:type creation_date: datetime
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str = None,
|
||||
username: str = None,
|
||||
displayname: str = None,
|
||||
email: str = None,
|
||||
password: str = None,
|
||||
role_id: int = 1,
|
||||
creation_date: datetime = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.username = username
|
||||
self.displayname = displayname
|
||||
self.email = email
|
||||
self.password = password
|
||||
self.role_id = role_id
|
||||
self.creation_date = creation_date
|
||||
|
||||
def __repr__(self):
|
||||
return f"User(id={self.user_id}, username={self.username}, displayname={self.displayname}, email={self.email}, password={self.password}, role_id={self.role_id}, creation_date={self.creation_date})"
|
162
app/services/cart_service.py
Normal file
162
app/services/cart_service.py
Normal file
@ -0,0 +1,162 @@
|
||||
from mysql.connector import Error
|
||||
from typing import Tuple, Union
|
||||
|
||||
from app.extensions import db_connection
|
||||
|
||||
|
||||
class CartService:
|
||||
|
||||
@staticmethod
|
||||
@staticmethod
|
||||
def update_count(
|
||||
user_id: str, product_id: int, count: int
|
||||
) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Updates count of products in user's cart
|
||||
|
||||
:param user_id: User ID.
|
||||
:type user_id: str
|
||||
:param product_id: ID of product to be updated.
|
||||
:type product_id: int
|
||||
:param count: New count of products
|
||||
:type count: int
|
||||
:return: Tuple containing a dictionary with a token and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
if count <= 0:
|
||||
return CartService.delete_from_cart(user_id, product_id)
|
||||
|
||||
with db_connection.cursor(dictionary=True) as cursor:
|
||||
cursor.execute(
|
||||
"update cart_item set count = %s where cart_id = %s and product_id = %s",
|
||||
(count, user_id, product_id),
|
||||
)
|
||||
db_connection.commit()
|
||||
|
||||
return {"Success": "Successfully added to cart"}, 200
|
||||
|
||||
except Error as e:
|
||||
return {"Failed": f"Failed to update item count in cart. Reason: {e}"}, 500
|
||||
|
||||
@staticmethod
|
||||
def delete_from_cart(user_id: str, product_id: int) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Completely deletes an item from a user's cart
|
||||
|
||||
:param user_id: User ID.
|
||||
:type user_id: str
|
||||
:param product_id: ID of product to be updated.
|
||||
:type product_id: int
|
||||
:return: Tuple containing a dictionary with a token and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
with db_connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"delete from cart_item where cart_id = %s and product_id = %s",
|
||||
(user_id, product_id),
|
||||
)
|
||||
db_connection.commit()
|
||||
|
||||
return {"Success": "Successfully removed item from cart"}, 200
|
||||
except Error as e:
|
||||
return {"Failed": f"Failed to remove item from cart. Reason: {e}"}, 500
|
||||
|
||||
@staticmethod
|
||||
def show_cart(user_id: str) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Gives the user the content of their cart
|
||||
|
||||
:param user_id: User ID.
|
||||
:type user_id: str
|
||||
:return: Tuple containing a dictionary with a token and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
with db_connection.cursor(dictionary=True) as cursor:
|
||||
cursor.execute(
|
||||
"select product.name as product_name, count, price_subtotal, date_added from cart_item inner join product on cart_item.product_id = product.id where cart_item.cart_id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
results = []
|
||||
|
||||
for row in rows:
|
||||
mid_result = {
|
||||
"name": row["product_name"],
|
||||
"count": row["count"],
|
||||
"price_subtotal": row["price_subtotal"],
|
||||
"date_added": row["date_added"],
|
||||
}
|
||||
|
||||
results.append(mid_result)
|
||||
|
||||
return results, 200
|
||||
|
||||
except Error as e:
|
||||
return {"Failed": f"Failed to load cart. Reason: {e}"}, 500
|
||||
|
||||
@staticmethod
|
||||
def purchase(user_id: str) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
"Purchases" the contents of user's cart
|
||||
|
||||
:param user_id: User ID.
|
||||
:type user_id: str
|
||||
:return: Tuple containing a dictionary with a token and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
with db_connection.cursor(dictionary=True) as cursor:
|
||||
# get all cart items
|
||||
cursor.execute(
|
||||
"select id, product_id, count, price_subtotal from cart_item where cart_id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
results = cursor.fetchall()
|
||||
|
||||
if len(results) < 1:
|
||||
return {"Failed": "Failed to purchase. Cart is Empty"}, 400
|
||||
|
||||
# create a purchase
|
||||
cursor.execute("insert into purchase(user_id) values (%s)", (user_id,))
|
||||
|
||||
last_id = cursor.lastrowid
|
||||
|
||||
parsed = []
|
||||
ids = []
|
||||
|
||||
for row in results:
|
||||
mid_row = (
|
||||
last_id,
|
||||
row["product_id"],
|
||||
row["count"],
|
||||
row["price_subtotal"],
|
||||
)
|
||||
|
||||
row_id = row["id"]
|
||||
|
||||
parsed.append(mid_row)
|
||||
ids.append(row_id)
|
||||
|
||||
insert_query = "INSERT INTO purchase_item (purchase_id, product_id, count, price_subtotal) VALUES (%s, %s, %s, %s)"
|
||||
for row in parsed:
|
||||
cursor.execute(insert_query, row)
|
||||
|
||||
delete_query = "delete from cart_item where id = %s"
|
||||
for one_id in ids:
|
||||
cursor.execute(delete_query, (one_id,))
|
||||
|
||||
db_connection.commit()
|
||||
|
||||
# clear cart
|
||||
except Error as e:
|
||||
return {"msg": f"Failed to load cart. Reason: {e}"}, 500
|
||||
|
||||
return {"msg": "Successfully purchased"}, 200
|
30
app/services/product/product_create_service.py
Normal file
30
app/services/product/product_create_service.py
Normal file
@ -0,0 +1,30 @@
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
from app.messages.api_responses import product_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
|
||||
from app.db import product_db
|
||||
|
||||
from app.models.product_model import Product
|
||||
|
||||
|
||||
def create_product(seller_id: str, name: str, price: float):
|
||||
"""
|
||||
Creates a new product listing
|
||||
|
||||
:param seller_id: User ID
|
||||
:type seller_id: str
|
||||
:param name: New product's name
|
||||
:type name: str
|
||||
:param price: New product's price
|
||||
:type price: float
|
||||
"""
|
||||
|
||||
product: Product = Product(seller_id=seller_id, name=name, price=price)
|
||||
try:
|
||||
product_db.insert_product(product)
|
||||
|
||||
except mysqlError as e:
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
||||
|
||||
return response.PRODUCT_LISTING_CREATED_SUCCESSFULLY
|
23
app/services/product/product_delete_service.py
Normal file
23
app/services/product/product_delete_service.py
Normal file
@ -0,0 +1,23 @@
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
from app.messages.api_responses import product_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
|
||||
from app.db import product_db
|
||||
|
||||
from app.models.product_model import Product
|
||||
|
||||
|
||||
def delete_product(seller_id: str, product_id: str):
|
||||
product: Product = product_db.fetch_product_by_id(product_id)
|
||||
|
||||
if product.seller_id != seller_id:
|
||||
return response.NOT_OWNER_OF_PRODUCT
|
||||
|
||||
try:
|
||||
product_db.delete_product(product)
|
||||
|
||||
except mysqlError as e:
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
||||
|
||||
return response.PRODUCT_LISTING_CREATED_SUCCESSFULLY
|
8
app/services/product/product_helper.py
Normal file
8
app/services/product/product_helper.py
Normal file
@ -0,0 +1,8 @@
|
||||
import imghdr
|
||||
|
||||
def is_base64_jpg(decoded_string) -> bool:
|
||||
try:
|
||||
image_type = imghdr.what(None, decoded_string)
|
||||
return image_type == "jpeg"
|
||||
except Exception:
|
||||
return False
|
20
app/services/product/product_info_service.py
Normal file
20
app/services/product/product_info_service.py
Normal file
@ -0,0 +1,20 @@
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
from app.messages.api_responses import product_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
|
||||
from app.db import product_db
|
||||
|
||||
from app.models.product_model import Product
|
||||
|
||||
|
||||
def product_info(product_id: int):
|
||||
try:
|
||||
product: Product = product_db.fetch_product_extended_by_id(product_id)
|
||||
|
||||
if product is None:
|
||||
return response.UNKNOWN_PRODUCT
|
||||
|
||||
return product, 200
|
||||
except mysqlError as e:
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
29
app/services/product/product_list_service.py
Normal file
29
app/services/product/product_list_service.py
Normal file
@ -0,0 +1,29 @@
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
from app.messages.api_responses import product_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
|
||||
from app.db import product_db
|
||||
|
||||
def product_list(page: int):
|
||||
try:
|
||||
result_products = product_db.fetch_products(page)
|
||||
|
||||
if result_products is None:
|
||||
return response.SCROLLED_TOO_FAR
|
||||
|
||||
result_obj = []
|
||||
for product in result_products:
|
||||
mid_result = {
|
||||
"id": product.product_id,
|
||||
"seller": product.seller_id,
|
||||
"name": product.name,
|
||||
"price": product.price,
|
||||
}
|
||||
|
||||
result_obj.append(mid_result)
|
||||
|
||||
return result_obj, 200
|
||||
|
||||
except mysqlError as e:
|
||||
errors.UNKNOWN_DATABASE_ERROR(e)
|
31
app/services/user/delete_service.py
Normal file
31
app/services/user/delete_service.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Tuple, Union
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
import app.db.user_db as user_db
|
||||
from app.models.user_model import User
|
||||
|
||||
import app.messages.api_responses.user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
from app.mail.mail import send_mail
|
||||
|
||||
|
||||
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT
|
||||
|
||||
def delete_user(user_id: str) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Deletes a user account.
|
||||
|
||||
:param user_id: User ID.
|
||||
:type user_id: str
|
||||
:return: Tuple containing a dictionary and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
user: User = user_db.fetch_by_id(user_id=user_id)
|
||||
user_db.delete_user(user)
|
||||
send_mail(USER_EMAIL_SUCCESSFULLY_DELETED_ACCOUNT, user.email)
|
||||
except mysqlError as e:
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
||||
|
||||
return response.USER_DELETED_SUCCESSFULLY
|
45
app/services/user/login_service.py
Normal file
45
app/services/user/login_service.py
Normal file
@ -0,0 +1,45 @@
|
||||
import datetime
|
||||
from typing import Tuple, Union
|
||||
|
||||
import bcrypt
|
||||
from mysql.connector import Error as mysqlError
|
||||
from flask_jwt_extended import create_access_token
|
||||
|
||||
import app.messages.api_responses.user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
from app.db import user_db
|
||||
from app.mail.mail import send_mail
|
||||
from app.models.user_model import User
|
||||
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_IN
|
||||
|
||||
|
||||
|
||||
def login(username: str, password: str) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Authenticates a user with the provided username and password.
|
||||
|
||||
:param username: User's username.
|
||||
:type username: str
|
||||
:param password: User's password.
|
||||
:type password: str
|
||||
:return: Tuple containing a dictionary with a token and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
try:
|
||||
user: User = user_db.fetch_by_username(username)
|
||||
|
||||
if user is None:
|
||||
return response.USERNAME_NOT_FOUND
|
||||
|
||||
if not bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")):
|
||||
return response.INCORRECT_PASSWORD
|
||||
|
||||
expire = datetime.timedelta(hours=1)
|
||||
token = create_access_token(identity=user.user_id, expires_delta=expire)
|
||||
|
||||
send_mail(USER_EMAIL_SUCCESSFULLY_LOGGED_IN, user.email)
|
||||
|
||||
return {"token": token}, 200
|
||||
|
||||
except mysqlError as e:
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
31
app/services/user/logout_service.py
Normal file
31
app/services/user/logout_service.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Tuple, Union
|
||||
|
||||
import app.messages.api_responses.user_responses as response
|
||||
from app.db import user_db
|
||||
from app.models.user_model import User
|
||||
from app.mail.mail import send_mail
|
||||
from app.services.user import user_helper as helper
|
||||
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_LOGGED_OUT
|
||||
|
||||
|
||||
def logout(jwt_token, user_id, send_notif: bool) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Logs out a user by invalidating the provided JWT.
|
||||
|
||||
:param jti: JWT ID.
|
||||
:type jti: str
|
||||
:param exp: JWT expiration timestamp.
|
||||
:type exp: int
|
||||
:return: Tuple containing a dictionary and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
jti = jwt_token["jti"]
|
||||
exp = jwt_token["exp"]
|
||||
|
||||
user: User = user_db.fetch_by_id(user_id)
|
||||
|
||||
helper.invalidate_token(jti, exp)
|
||||
if send_notif:
|
||||
send_mail(USER_EMAIL_SUCCESSFULLY_LOGGED_OUT, user.email)
|
||||
return response.USER_LOGGED_OUT_SUCCESSFULLY
|
64
app/services/user/register_service.py
Normal file
64
app/services/user/register_service.py
Normal file
@ -0,0 +1,64 @@
|
||||
import bcrypt
|
||||
from typing import Tuple, Union
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
import app.messages.api_responses.user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
from app.db import user_db
|
||||
from app.mail.mail import send_mail
|
||||
from app.models.user_model import User
|
||||
from app.services.user import user_helper as helper
|
||||
from app.messages.mail_responses.user_email import USER_EMAIL_SUCCESSFULLY_REGISTERED
|
||||
|
||||
|
||||
def register(
|
||||
username: str, displayname: str, email: str, password: str
|
||||
) -> Tuple[Union[dict, str], int]:
|
||||
"""
|
||||
Registers a new user with the provided username, email, and password.
|
||||
|
||||
:param username: User's username.
|
||||
:type username: str
|
||||
:param email: User's email address.
|
||||
:type email: str
|
||||
:param password: User's password.
|
||||
:type password: str
|
||||
:return: Tuple containing a dictionary and an HTTP status code.
|
||||
:rtype: Tuple[Union[dict, str], int]
|
||||
"""
|
||||
|
||||
try:
|
||||
if not helper.verify_username(username):
|
||||
return response.INVALID_USERNAME_FORMAT
|
||||
|
||||
if not helper.verify_displayname(displayname):
|
||||
return response.INVALID_DISPLAYNAME_FORMAT
|
||||
|
||||
if not helper.verify_email(email):
|
||||
return response.INVALID_EMAIL_FORMAT
|
||||
|
||||
if not helper.verify_password(password):
|
||||
return response.INVALID_PASSWORD_FORMAT
|
||||
|
||||
hashed_password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
|
||||
|
||||
new_user: User = User(
|
||||
username=username,
|
||||
displayname=displayname,
|
||||
email=email,
|
||||
password=hashed_password,
|
||||
)
|
||||
|
||||
user_db.insert_user(new_user)
|
||||
|
||||
except mysqlError as e:
|
||||
if "email" in e.msg:
|
||||
return response.EMAIL_ALREADY_IN_USE
|
||||
if "username" in e.msg:
|
||||
return response.USERNAME_ALREADY_IN_USE
|
||||
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
||||
|
||||
send_mail(USER_EMAIL_SUCCESSFULLY_REGISTERED, new_user.email)
|
||||
|
||||
return response.USER_CREATED_SUCCESSFULLY
|
72
app/services/user/update_user_service.py
Normal file
72
app/services/user/update_user_service.py
Normal file
@ -0,0 +1,72 @@
|
||||
import bcrypt
|
||||
from typing import Tuple, Union
|
||||
from mysql.connector import Error as mysqlError
|
||||
|
||||
from app.db import user_db
|
||||
from app.mail.mail import send_mail
|
||||
from app.models.user_model import User
|
||||
from app.services.user import user_helper as helper
|
||||
from app.messages.api_responses import user_responses as response
|
||||
import app.messages.api_errors as errors
|
||||
from app.messages.mail_responses.user_email import (
|
||||
USER_EMAIL_SUCCESSFULLY_UPDATED_ACCOUNT,
|
||||
)
|
||||
|
||||
|
||||
def update_user(
|
||||
user_id: str,
|
||||
new_username: str = None,
|
||||
new_displayname: str = None,
|
||||
new_email: str = None,
|
||||
new_password: str = None,
|
||||
) -> Tuple[Union[dict, str], int]:
|
||||
user: User = user_db.fetch_by_id(user_id)
|
||||
|
||||
updated_attributes = []
|
||||
|
||||
if user is None:
|
||||
return response.UNKNOWN_ERROR
|
||||
|
||||
if new_username:
|
||||
if not helper.verify_username(new_username):
|
||||
return response.INVALID_USERNAME_FORMAT
|
||||
|
||||
user.username = new_username
|
||||
updated_attributes.append("username")
|
||||
|
||||
if new_displayname:
|
||||
if not helper.verify_displayname(new_displayname):
|
||||
return response.INVALID_DISPLAYNAME_FORMAT
|
||||
|
||||
user.displayname = new_displayname
|
||||
updated_attributes.append("displayname")
|
||||
|
||||
if new_email:
|
||||
if not helper.verify_email(new_email):
|
||||
return response.INVALID_EMAIL_FORMAT
|
||||
|
||||
user.email = new_email
|
||||
updated_attributes.append("email")
|
||||
|
||||
if new_password:
|
||||
if not helper.verify_password(new_password):
|
||||
return response.INVALID_PASSWORD_FORMAT
|
||||
|
||||
hashed_password = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
|
||||
|
||||
user.password = hashed_password
|
||||
updated_attributes.append("password")
|
||||
|
||||
try:
|
||||
user_db.update_user(user)
|
||||
|
||||
except mysqlError as e:
|
||||
if "username" in e.msg:
|
||||
return response.USERNAME_ALREADY_IN_USE
|
||||
if "email" in e.msg:
|
||||
return response.EMAIL_ALREADY_IN_USE
|
||||
|
||||
return errors.UNKNOWN_DATABASE_ERROR(e)
|
||||
|
||||
send_mail(USER_EMAIL_SUCCESSFULLY_UPDATED_ACCOUNT, user.email)
|
||||
return response.USER_ACCOUNT_UPDATED_SUCCESSFULLY(updated_attributes)
|
74
app/services/user/user_helper.py
Normal file
74
app/services/user/user_helper.py
Normal file
@ -0,0 +1,74 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.extensions import jwt_redis_blocklist
|
||||
|
||||
|
||||
def invalidate_token(jti: str, exp: int):
|
||||
"""
|
||||
Invalidates a JWT by adding its JTI to the Redis blocklist.
|
||||
|
||||
:param jti: JWT ID.
|
||||
:type jti: str
|
||||
:param exp: JWT expiration timestamp.
|
||||
:type exp: int
|
||||
"""
|
||||
expiration = datetime.fromtimestamp(exp)
|
||||
now = datetime.now()
|
||||
|
||||
delta = expiration - now
|
||||
jwt_redis_blocklist.set(jti, "", ex=delta)
|
||||
|
||||
|
||||
def verify_email(email: str) -> bool:
|
||||
"""
|
||||
Verifies a given email string against a regular expression.
|
||||
|
||||
:param email: Email string.
|
||||
:type email: str
|
||||
:return: Boolean indicating whether the email successfully passed the check.
|
||||
:rtype: bool
|
||||
"""
|
||||
email_regex = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
return re.match(email_regex, email) and len(email) <= 64
|
||||
|
||||
|
||||
def verify_displayname(displayname: str) -> bool:
|
||||
"""
|
||||
Verifies a given display name string against a regular expression.
|
||||
|
||||
:param displayname: Display name string.
|
||||
:type displayname: str
|
||||
:return: Boolean indicating whether the display name successfully passed the check.
|
||||
:rtype: bool
|
||||
"""
|
||||
displayname_regex = r"^[a-zA-Z.-_]{1,64}$"
|
||||
return re.match(displayname_regex, displayname)
|
||||
|
||||
|
||||
def verify_username(username: str) -> bool:
|
||||
"""
|
||||
Verifies a given username string against a regular expression.
|
||||
|
||||
:param username: Username string.
|
||||
:type username: str
|
||||
:return: Boolean indicating whether the username successfully passed the check.
|
||||
:rtype: bool
|
||||
"""
|
||||
username_regex = r"^[a-z]{1,64}$"
|
||||
return re.match(username_regex, username)
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""
|
||||
Verifies a given password string against a regular expression.
|
||||
|
||||
:param password: Password string.
|
||||
:type password: str
|
||||
:return: Boolean indicating whether the password successfully passed the check.
|
||||
:rtype: bool
|
||||
"""
|
||||
password_regex = (
|
||||
r"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,64}$"
|
||||
)
|
||||
return re.match(password_regex, password)
|
@ -1,2 +0,0 @@
|
||||
[mypy-sqlalchemy.*]
|
||||
follow_untyped_imports = True
|
@ -1,25 +0,0 @@
|
||||
# Base Image
|
||||
FROM python:3.13
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app/
|
||||
|
||||
# Copy dependency files first to leverage caching
|
||||
COPY pyproject.toml poetry.lock /app/
|
||||
|
||||
# Copy the rest of the application
|
||||
COPY ./app /app/app
|
||||
|
||||
# Ensure dependencies are installed correctly
|
||||
RUN poetry install --no-interaction --no-ansi --without dev
|
||||
|
||||
# Expose port for FastAPI
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the app
|
||||
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
@ -1,11 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import cart_routes, login_routes, shop, user_routes, utils_routes
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(cart_routes.router)
|
||||
api_router.include_router(user_routes.router)
|
||||
api_router.include_router(utils_routes.router)
|
||||
api_router.include_router(login_routes.router)
|
||||
api_router.include_router(shop.shop_router)
|
@ -1,50 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.database.manager import get_session
|
||||
from app.database.models.user_model import User, UserRole
|
||||
from app.schemas.user_schemas import TokenPayload
|
||||
|
||||
reusable_oauth2 = OAuth2PasswordBearer(
|
||||
tokenUrl="/login/access-token"
|
||||
)
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
TokenDep = Annotated[str, Depends(reusable_oauth2)]
|
||||
|
||||
|
||||
def get_current_user(session: SessionDep, token: TokenDep) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
)
|
||||
token_data = TokenPayload(**payload)
|
||||
except (InvalidTokenError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = session.get(User, {"uuid": token_data.sub})
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
|
||||
def get_owner_user(current_user: CurrentUser) -> User:
|
||||
if current_user.user_role != UserRole.OWNER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You must be an owner")
|
||||
return current_user
|
||||
|
||||
CurrentOwnerUser = Annotated[User, Depends(get_owner_user)]
|
@ -1,38 +0,0 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/cart",
|
||||
tags=["Cart"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def show_cart():
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@router.put("/add/{product_id}")
|
||||
async def add_to_cart(
|
||||
product_id: int,
|
||||
count: int = Query(
|
||||
1, ge=1, description="Count must be greater than or equal to 1")
|
||||
):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@router.delete("/remove/{product_id}")
|
||||
async def remove_from_cart(product_id: int):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@router.put("/update/{product_id}")
|
||||
async def update_count_in_cart(
|
||||
product_id: int,
|
||||
count: int = Query(..., description="Count must be provided")
|
||||
):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@router.get("/purchase")
|
||||
async def purchase():
|
||||
raise NotImplementedError
|
@ -1,36 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.api.dependencies import SessionDep
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.crud import user_crud
|
||||
from app.schemas.user_schemas import Token
|
||||
|
||||
router = APIRouter(tags=["Login"])
|
||||
|
||||
|
||||
@router.post("/login/access-token")
|
||||
def login_access_token(
|
||||
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
||||
) -> Token:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
user = None
|
||||
user = user_crud.authenticate(
|
||||
session=session, email=form_data.username, password=form_data.password, shop_id=None
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
elif not user:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
)
|
||||
)
|
@ -1,14 +0,0 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes.shop import shop_login_routes, shop_user_routes
|
||||
|
||||
shop_router = APIRouter(
|
||||
prefix="/shop/{shop_uuid}",
|
||||
tags=["Shop"]
|
||||
)
|
||||
|
||||
|
||||
shop_router.include_router(shop_login_routes.router)
|
||||
shop_router.include_router(shop_user_routes.router)
|
@ -1,37 +0,0 @@
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.api.dependencies import SessionDep
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.crud import user_crud
|
||||
from app.schemas.user_schemas import Token
|
||||
|
||||
router = APIRouter(prefix="/login", tags=["Login"])
|
||||
|
||||
|
||||
@router.post("/access-token")
|
||||
def login_access_token(
|
||||
session: SessionDep,
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
||||
) -> Token:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
user = None
|
||||
user = user_crud.authenticate(
|
||||
session=session, email=form_data.username, password=form_data.password, shop_id=None
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
elif not user:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
)
|
||||
)
|
@ -1,33 +0,0 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Path
|
||||
|
||||
from app.api.dependencies import SessionDep
|
||||
from app.crud.user_crud import create_user
|
||||
from app.database.models.user_model import UserRole
|
||||
from app.schemas.user_schemas import UserRegister
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/user"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/delete", summary="Delete user")
|
||||
async def delete_user(shop_uuid=Annotated[uuid.UUID, Path(title="UUID of the shop")]):
|
||||
raise NotImplementedError("delete_user() needs to be implemented.")
|
||||
|
||||
|
||||
@router.delete("/logout", summary="User logout")
|
||||
async def logout():
|
||||
raise NotImplementedError("logout() needs to be implemented.")
|
||||
|
||||
|
||||
@router.post("/register", summary="Register new user")
|
||||
async def register(session: SessionDep, user_data: UserRegister, shop_uuid=Annotated[uuid.UUID, Path(title="UUID of the shop")]):
|
||||
create_user(session, user_data, shop_uuid, UserRole.CUSTOMER)
|
||||
|
||||
|
||||
@router.put("/update", summary="Update user details")
|
||||
async def update_user(data: dict = Body(...)):
|
||||
raise NotImplementedError("update_user() needs to be implemented.")
|
@ -1,58 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.dependencies import SessionDep
|
||||
from app.crud import user_crud
|
||||
from app.database.models.user_model import UserRole
|
||||
from app.schemas.user_schemas import UserRegister
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/user",
|
||||
tags=["User"]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/delete", summary="Delete user")
|
||||
async def delete_user():
|
||||
raise NotImplementedError("delete_user() needs to be implemented.")
|
||||
|
||||
|
||||
@router.delete("/logout", summary="User logout")
|
||||
async def logout():
|
||||
raise NotImplementedError("logout() needs to be implemented.")
|
||||
|
||||
|
||||
@router.post("/register", summary="Register new user")
|
||||
async def register(session: SessionDep, user_data: UserRegister):
|
||||
try:
|
||||
user_crud.create_user(session, user_data, None, UserRole.OWNER)
|
||||
return {"detail": "Registered succeesfully"}
|
||||
except IntegrityError as e:
|
||||
field_mapping = {"uuid": "email"} # If a UUID is duplicate, it means email is in use
|
||||
|
||||
constraint = e.orig.diag.constraint_name
|
||||
column_name = re.sub(r"^user_(\w+)_key$", r"\1", constraint) if constraint else None
|
||||
|
||||
if column_name == "uuid":
|
||||
column_name = "email"
|
||||
|
||||
detail = f"{field_mapping.get(column_name, column_name or 'Entry').capitalize()} already in use"
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=detail
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to register")
|
||||
|
||||
|
||||
@router.put("/update", summary="Update user details")
|
||||
async def update_user(data: dict = Body(...)):
|
||||
raise NotImplementedError("update_user() needs to be implemented.")
|
@ -1,25 +0,0 @@
|
||||
import logging
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from fastapi import APIRouter
|
||||
from app.api.dependencies import SessionDep
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/utils", tags=["utils"])
|
||||
|
||||
|
||||
@router.get("/health-check/")
|
||||
async def health_check() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/test-db/")
|
||||
async def test_db(session: SessionDep) -> bool:
|
||||
try:
|
||||
session.exec(select(1))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return False
|
@ -1,107 +0,0 @@
|
||||
import secrets
|
||||
import warnings
|
||||
import logging
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
AnyUrl,
|
||||
BeforeValidator,
|
||||
EmailStr,
|
||||
PostgresDsn,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import MultiHostUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
def parse_cors(v: Any) -> list[str] | str:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",")]
|
||||
elif isinstance(v, list | str):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file="../.env",
|
||||
env_ignore_empty=True,
|
||||
extra="ignore",
|
||||
)
|
||||
PORT: int = 8000
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
|
||||
VERBOSITY: Literal[*logging.getLevelNamesMapping().keys()] = "DEBUG"
|
||||
|
||||
# 60 minutes * 24 hours * 8 days = 8 days
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||
FRONTEND_HOST: str
|
||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
||||
|
||||
BACKEND_CORS_ORIGINS: Annotated[
|
||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
||||
] = []
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def all_cors_origins(self) -> list[str]:
|
||||
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [self.FRONTEND_HOST]
|
||||
|
||||
POSTGRES_SERVER: str
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str
|
||||
POSTGRES_PASSWORD: str = ""
|
||||
POSTGRES_DB: str = ""
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
|
||||
return MultiHostUrl.build(
|
||||
scheme='postgresql+psycopg2',
|
||||
username=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_SERVER,
|
||||
port=self.POSTGRES_PORT,
|
||||
path=self.POSTGRES_DB,
|
||||
)
|
||||
|
||||
SMTP_TLS: bool = True
|
||||
SMTP_SSL: bool = False
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_USER: str | None = None
|
||||
SMTP_PASSWORD: str | None = None
|
||||
EMAILS_FROM_EMAIL: EmailStr | None = None
|
||||
EMAILS_FROM_NAME: EmailStr | None = None
|
||||
|
||||
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def emails_enabled(self) -> bool:
|
||||
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
|
||||
|
||||
EMAIL_TEST_USER: EmailStr = "test@example.com"
|
||||
FIRST_SUPERUSER: EmailStr
|
||||
FIRST_SUPERUSER_PASSWORD: str
|
||||
|
||||
def _check_default_secret(self, var_name: str, value: str | None) -> None:
|
||||
if value == "changethis":
|
||||
message = f'The value of {var_name} is "changethis", '"for security, please change it, at least for deployments."
|
||||
if self.ENVIRONMENT == "local":
|
||||
warnings.warn(message, stacklevel=1)
|
||||
else:
|
||||
raise ValueError(message)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _enforce_non_default_secrets(self) -> Self:
|
||||
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
||||
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
|
||||
self._check_default_secret("FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
settings = Settings()
|
@ -1,13 +0,0 @@
|
||||
class SwagShopError(Exception):
|
||||
"""
|
||||
Generic error to be raised throughout the entire app.
|
||||
All other custom errors extend this class
|
||||
"""
|
||||
|
||||
|
||||
class RepositoryError(SwagShopError):
|
||||
"""Generic error occuring in a repository"""
|
||||
|
||||
|
||||
class CrudError(SwagShopError):
|
||||
"""Generic error occuring in a service"""
|
@ -1,28 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
@ -1,11 +0,0 @@
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database.models.shop_model import Shop
|
||||
|
||||
def get_shop_id_from_uuid(session: Session, shop_id: int) -> Optional[UUID]:
|
||||
stmt = select(Shop).where(Shop.id == shop_id)
|
||||
db_shop = session.exec(stmt).one_or_none()
|
||||
return db_shop
|
@ -1,58 +0,0 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.crud.shop_crud import get_shop_id_from_uuid
|
||||
from app.database.models.user_model import User, UserRole
|
||||
from app.schemas.user_schemas import UserRegister
|
||||
from app.utils.models import generate_user_uuid5
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_user_by_generated_uuid(session: Session, email: str, shop_uuid: Optional[UUID]) -> Optional[User]:
|
||||
logger.debug("Getting shop id by UUID - %s", shop_uuid)
|
||||
shop_id = get_shop_id_from_uuid(session, shop_uuid)
|
||||
logger.debug("Generating user UUID5")
|
||||
user_uuid = generate_user_uuid5(email, shop_id)
|
||||
stmt = select(User).where(User.uuid == user_uuid)
|
||||
logger.debug("Executing select query")
|
||||
db_user = session.exec(stmt).one_or_none()
|
||||
return db_user
|
||||
|
||||
def create_user(session: Session, user_register: UserRegister, shop_uuid: Optional[UUID], user_role: UserRole):
|
||||
logger.debug("Getting shop id by UUID - %s", shop_uuid)
|
||||
shop_id = get_shop_id_from_uuid(session, shop_uuid)
|
||||
logger.debug("Generating user UUID5")
|
||||
user_uuid = generate_user_uuid5(user_register.email, shop_id)
|
||||
logger.debug("Hashing password")
|
||||
hashed_password = get_password_hash(user_register.password)
|
||||
new_user = User(
|
||||
uuid=user_uuid,
|
||||
shop_id=shop_id,
|
||||
email=user_register.email,
|
||||
username=user_register.username,
|
||||
phone_number=user_register.phone_number,
|
||||
user_role=user_role,
|
||||
password=hashed_password
|
||||
)
|
||||
logger.debug("Inserting new user")
|
||||
session.add(new_user)
|
||||
session.commit()
|
||||
|
||||
|
||||
def authenticate(session: Session, email: str, password: str, shop_uuid: Optional[int]) -> Optional[User]:
|
||||
logger.debug("Getting shop id by UUID - %s", shop_uuid)
|
||||
shop_id = get_shop_id_from_uuid(session, shop_uuid)
|
||||
logger.debug("Fetching user from db by email - %s", email)
|
||||
db_user = get_user_by_generated_uuid(session, email, shop_id)
|
||||
if db_user is None:
|
||||
logger.warn("Didn't find User with email=%s for shop=%s", email, shop_uuid)
|
||||
return None
|
||||
if not verify_password(plain_password=password, hashed_password=db_user.password):
|
||||
logger.warn("Found user with email=%s for shop=%s", email, shop_uuid)
|
||||
return None
|
||||
return db_user
|
@ -1,20 +0,0 @@
|
||||
class DatabaseError(Exception):
|
||||
# Inspired by OSError which also uses errno's
|
||||
# It's a better approach than using a class for each error
|
||||
UNKNOWN_ERROR = -1
|
||||
|
||||
CONNECTION_ERROR = 1
|
||||
EMPTY_CONFIG = 2
|
||||
DUPLICATE_ENTRY = 3
|
||||
|
||||
def __init__(self, message: str, errno: int, **kwargs):
|
||||
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.errno = errno
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
__all__ = ["DatabaseError"]
|
@ -1,41 +0,0 @@
|
||||
import logging
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy.exc import DatabaseError as SqlAlchemyError
|
||||
from sqlmodel import Session, create_engine, select, SQLModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
from app.database.exceptions import DatabaseError
|
||||
|
||||
import app.database.models # pylint: disable=unused-import
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger.info("Creating engine")
|
||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def test_connection():
|
||||
logger.debug("Testing database connection")
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.exec(select(1))
|
||||
logger.debug("Database connection successful")
|
||||
except SqlAlchemyError as e:
|
||||
logger.critical("Database connection failed: %s", e)
|
||||
raise DatabaseError("Database connection failed", DatabaseError.CONNECTION_ERROR) from e
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
logger.debug("Closing connection")
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
test_connection()
|
@ -1,6 +0,0 @@
|
||||
from . import user_model, shop_model
|
||||
|
||||
__all__ = [
|
||||
*user_model.__all__,
|
||||
*shop_model.__all__
|
||||
]
|
@ -1,15 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, Float, TIMESTAMP, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Cart(Base):
|
||||
__tablename__ = "cart"
|
||||
|
||||
cart_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True)
|
||||
total = Column(Float, nullable=False)
|
||||
last_updated = Column(TIMESTAMP, onupdate=func.now, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="cart", foreign_keys=[cart_id])
|
||||
cart_entries = relationship("CartEntry", back_populates="cart")
|
@ -1,18 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, TIMESTAMP, Float, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Purchase(Base):
|
||||
__tablename__ = "purchase"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
|
||||
used_coupon_id = Column(Integer, ForeignKey("coupon.id"), nullable=True)
|
||||
date_purchased = Column(TIMESTAMP, nullable=True)
|
||||
total = Column(Float, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="purchases", foreign_keys=[user_id])
|
||||
coupon = relationship("Coupon", back_populates="purchases", foreign_keys=[used_coupon_id])
|
||||
purchase_entries = relationship("PurchaseEntry", back_populates="purchase")
|
@ -1,15 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, Float, TIMESTAMP, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from .base_model import Base
|
||||
|
||||
|
||||
class Wishlist(Base):
|
||||
__tablename__ = "wishlist"
|
||||
|
||||
id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True)
|
||||
total = Column(Float, nullable=True)
|
||||
last_updated = Column(TIMESTAMP, onupdate=func.now, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="wishlist", foreign_keys=[id])
|
||||
wishlist_entries = relationship("WishlistEntry", back_populates="wishlist")
|
@ -1,77 +0,0 @@
|
||||
from datetime import datetime, time
|
||||
from enum import Enum as PyEnum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, constr
|
||||
from sqlalchemy import CheckConstraint, Column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Enum, Field, Relationship, SQLModel
|
||||
|
||||
|
||||
class ShopStatus(PyEnum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
|
||||
class ShopLinkEntry(BaseModel):
|
||||
name: constr(strip_whitespace=True, min_length=1)
|
||||
url: constr(strip_whitespace=True, min_length=1)
|
||||
|
||||
|
||||
class ShopLinks(BaseModel):
|
||||
links: list[ShopLinkEntry]
|
||||
|
||||
|
||||
class ShopBusinessHourEntry(BaseModel):
|
||||
day: constr(strip_whitespace=True, min_length=1)
|
||||
open_time: time
|
||||
close_time: time
|
||||
|
||||
|
||||
class ShopBusinessHours(BaseModel):
|
||||
hours: list[ShopBusinessHourEntry]
|
||||
|
||||
|
||||
class Shop(SQLModel, table=True):
|
||||
__tablename__ = 'shop'
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
uuid: UUID = Field(nullable=False, unique=True)
|
||||
owner_id: int = Field(foreign_key='user.id', nullable=False)
|
||||
name: str = Field(max_length=100, nullable=False, unique=True)
|
||||
description: str = Field(max_length=500, nullable=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
status: ShopStatus = Field(sa_column=Column(Enum(ShopStatus)))
|
||||
logo: Optional[str] = Field(max_length=100)
|
||||
contact_email: str = Field(max_length=128, nullable=False, unique=True)
|
||||
phone_number: str = Field(max_length=15, nullable=False)
|
||||
address: str = Field(nullable=False)
|
||||
currency: str = Field(max_length=3, nullable=False)
|
||||
|
||||
business_hours: ShopBusinessHours = Field(
|
||||
sa_column=Column(JSONB, nullable=False, default=lambda: {})
|
||||
)
|
||||
links: ShopLinks = Field(
|
||||
sa_column=Column(JSONB, nullable=False, default=lambda: {})
|
||||
)
|
||||
|
||||
owner: Optional["User"] = Relationship(
|
||||
back_populates='owned_shop',
|
||||
sa_relationship_kwargs={"foreign_keys": "[Shop.owner_id]"}
|
||||
)
|
||||
|
||||
registered_users: list["User"] = Relationship(
|
||||
back_populates='registered_shop',
|
||||
sa_relationship_kwargs={"foreign_keys": "[User.shop_id]"}
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("business_hours ? 'hours'", name="check_business_hours_keys"),
|
||||
CheckConstraint("links ? 'links'", name="check_links_keys"),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Shop", "ShopBusinessHours", "ShopLinks", "ShopStatus"]
|
@ -1,69 +0,0 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum as PyEnum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlmodel import Enum, Field, Relationship, SQLModel
|
||||
|
||||
|
||||
class UserRole(PyEnum):
|
||||
OWNER = "owner"
|
||||
CUSTOMER = "customer"
|
||||
EMPLOYEE = "employee"
|
||||
MANAGER = "manager"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
__tablename__ = "user"
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
uuid: UUID = Field(nullable=False, unique=True)
|
||||
user_role: UserRole = Field(sa_column=Column(Enum(UserRole), nullable=False))
|
||||
shop_id: Optional[int] = Field(foreign_key="shop.id")
|
||||
status: UserRole = Field(sa_column=Column(Enum(UserRole)))
|
||||
username: str = Field(max_length=64, nullable=False, unique=True)
|
||||
email: str = Field(max_length=128, nullable=False, unique=True)
|
||||
password: str = Field(max_length=60, nullable=False)
|
||||
first_name: Optional[str] = Field(max_length=64)
|
||||
last_name: Optional[str] = Field(max_length=64)
|
||||
phone_number: str = Field(max_length=15, nullable=False)
|
||||
profile_picture: Optional[str] = Field(max_length=100)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
|
||||
last_login: Optional[datetime] = Field(default_factory=datetime.utcnow)
|
||||
|
||||
owned_shop: "Shop" = Relationship(
|
||||
back_populates="owner",
|
||||
sa_relationship_kwargs={"foreign_keys": "[Shop.owner_id]"}
|
||||
)
|
||||
|
||||
registered_shop: Optional["Shop"] = Relationship(
|
||||
back_populates="registered_users",
|
||||
sa_relationship_kwargs={"foreign_keys": "[User.shop_id]"}
|
||||
)
|
||||
|
||||
preferences: Optional["UserPreferences"] = Relationship(back_populates="user")
|
||||
statistics: Optional["UserStatistics"] = Relationship(back_populates="user")
|
||||
|
||||
|
||||
|
||||
class UserPreferences(SQLModel, table=True):
|
||||
__tablename__ = "user_preferences"
|
||||
|
||||
user_id: int = Field(foreign_key="user.id", primary_key=True)
|
||||
|
||||
user: Optional["User"] = Relationship(back_populates="preferences")
|
||||
|
||||
|
||||
class UserStatistics(SQLModel, table=True):
|
||||
__tablename__ = "user_statistics"
|
||||
|
||||
user_id: int = Field(foreign_key="user.id", primary_key=True)
|
||||
total_spend: Optional[float] = Field(default=None)
|
||||
|
||||
user: Optional["User"] = Relationship(back_populates="statistics")
|
||||
|
||||
|
||||
__all__ = ["User", "UserPreferences", "UserRole"]
|
@ -1,31 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import api_router
|
||||
from app.core.config import settings
|
||||
from app.utils import logger
|
||||
|
||||
logger.setup_logger()
|
||||
|
||||
def custom_generate_unique_id(route: APIRoute) -> str:
|
||||
return f"{route.tags[0]}-{route.name}"
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SWAG Shop",
|
||||
version="0.0.1",
|
||||
generate_unique_id_function=custom_generate_unique_id
|
||||
)
|
||||
|
||||
|
||||
if settings.all_cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.all_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
@ -1,30 +0,0 @@
|
||||
import re
|
||||
from pydantic import EmailStr, model_validator
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class UserRegister(SQLModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
email: EmailStr = Field()
|
||||
phone_number: str = Field(min_length=2, max_length=16, schema_extra={"pattern": r'^\+[1-9]\d{1,14}$'})
|
||||
password: str = Field(min_length=8, max_length=128,
|
||||
description="Password must be at least 8 and at most 128 characters long, contain a lower case, upper case letter, a number and a special character (#?!@$ %^&*-)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_using_regex(self):
|
||||
self.__validate_password()
|
||||
return self
|
||||
|
||||
def __validate_password(self):
|
||||
password_regex = r"^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$ %^&*-]).{8,128}$"
|
||||
if not re.match(password_regex, self.password):
|
||||
raise ValueError("Password is too weak")
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenPayload(SQLModel):
|
||||
sub: str | None = None
|
@ -1,13 +0,0 @@
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
def setup_logger():
|
||||
verbosity = settings.VERBOSITY
|
||||
if verbosity == "DEBUG":
|
||||
log_format = "[ %(name)s / %(levelname)s ] - %(filename)s:%(lineno)d - %(message)s"
|
||||
else:
|
||||
log_format = "[ %(levelname)s ] - %(message)s"
|
||||
|
||||
logging.basicConfig(level=verbosity, format=log_format, stream=sys.stdout)
|
@ -1,6 +0,0 @@
|
||||
from typing import Optional
|
||||
from uuid import uuid5, UUID, NAMESPACE_DNS
|
||||
|
||||
def generate_user_uuid5(email: str, shop_id: Optional[int]) -> UUID:
|
||||
unique_string = f"{email}-{shop_id}" if shop_id else email
|
||||
return uuid5(NAMESPACE_DNS, unique_string)
|
@ -1,27 +0,0 @@
|
||||
from functools import wraps
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from sqlalchemy.exc import DatabaseError as SqlAlchemyDatabaseError
|
||||
|
||||
from app.core.errors import RepositoryError, ServiceError
|
||||
|
||||
|
||||
def propagate_db_error_to_service(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (SqlAlchemyDatabaseError, RepositoryError) as e:
|
||||
raise ServiceError(str(e)) from e
|
||||
return wrapper
|
||||
|
||||
|
||||
def propagate_service_errors_to_http_errors(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except ServiceError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
return wrapper
|
1478
backend/poetry.lock
generated
1478
backend/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,52 +0,0 @@
|
||||
[tool.poetry]
|
||||
name = "swag-shop"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Thastertyn <thastertyn@gmail.com>"]
|
||||
readme = "README.md"
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
fastapi = {extras = ["standard"], version = "^0.115.8"}
|
||||
sqlalchemy = "^2.0.37"
|
||||
python-dotenv = "^1.0.1"
|
||||
mysql-connector = "^2.2.9"
|
||||
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
|
||||
pyjwt = "^2.10.1"
|
||||
pydantic-settings = "^2.8.1"
|
||||
sqlmodel = "^0.0.24"
|
||||
psycopg2-binary = "^2.9.10"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"ARG001", # unused arguments in functions
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long, handled by black
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"W191", # indentation contains tabs
|
||||
"B904", # Allow raising exceptions without from e, for HTTPException
|
||||
]
|
||||
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pylint = "^3.3.4"
|
||||
autopep8 = "^2.3.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.autopep8]
|
||||
max_line_length = 200
|
||||
ignore = "E501"
|
||||
in_place = true
|
||||
recursive = true
|
@ -1,68 +0,0 @@
|
||||
services:
|
||||
|
||||
db:
|
||||
image: postgres:12
|
||||
restart: no
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
timeout: 10s
|
||||
volumes:
|
||||
- app-db-data:/var/lib/postgresql/data/pgdata
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
||||
- POSTGRES_DB=${POSTGRES_DB?Variable not set}
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: no
|
||||
networks:
|
||||
- default
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- ADMINER_DESIGN=pepa-linha-dark
|
||||
|
||||
backend:
|
||||
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
|
||||
restart: no
|
||||
networks:
|
||||
- default
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
|
||||
- ENVIRONMENT=${ENVIRONMENT}
|
||||
- BACKEND_CORS_ORIGINS=${BACKEND_CORS_ORIGINS}
|
||||
- SECRET_KEY=${SECRET_KEY?Variable not set}
|
||||
- FIRST_SUPERUSER=${FIRST_SUPERUSER?Variable not set}
|
||||
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD?Variable not set}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
|
||||
- MYSQL_SERVER=db
|
||||
- MYSQL_PORT=${MYSQL_PORT}
|
||||
- MYSQL_DB=${MYSQL_DB}
|
||||
- MYSQL_USER=${MYSQL_USER?Variable not set}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/utils/health-check/"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
build:
|
||||
context: ./backend
|
||||
|
||||
volumes:
|
||||
app-db-data:
|
24
frontend/.gitignore
vendored
24
frontend/.gitignore
vendored
@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"proseWrap": "always",
|
||||
"requireConfig": false,
|
||||
"useTabs": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": true
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Swag Shop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
7975
frontend/package-lock.json
generated
7975
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user