Compare commits
24 Commits
c018d2fb52
...
75b2b1e142
Author | SHA1 | Date | |
---|---|---|---|
75b2b1e142 | |||
20ef2aa4e8 | |||
cd8fdb9c21 | |||
f5547be799 | |||
543aadf521 | |||
272e765ca8 | |||
71e916586e | |||
c7e20fc935 | |||
9e56eeca3f | |||
5d12391635 | |||
f0c378b20f | |||
414b84a2d4 | |||
c3502f9ede | |||
a9c85cefe1 | |||
073f01d070 | |||
43063dcf4e | |||
6e9334ba0e | |||
4c8817e853 | |||
3b0336d9b4 | |||
3c005dbfa4 | |||
807d23da51 | |||
3d00ae0aad | |||
bb789a75e7 | |||
40aa0295b1 |
53
.env-example
Normal file
53
.env-example
Normal file
@ -0,0 +1,53 @@
|
||||
# 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
22
.env.example
@ -1,22 +0,0 @@
|
||||
# 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,2 +1,295 @@
|
||||
# ---> 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
|
||||
**/__pycache__/
|
||||
.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
|
63
.vscode/launch.json
vendored
63
.vscode/launch.json
vendored
@ -1,23 +1,60 @@
|
||||
{
|
||||
// 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": "Flask Shop",
|
||||
"name": "Backend",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/main.py",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"FLASK_APP": "main.py",
|
||||
"FLASK_ENV": "development"
|
||||
},
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
"module": "fastapi",
|
||||
"args": ["dev", "${cwd}/backend/app/main.py"],
|
||||
"console": "internalConsole",
|
||||
"autoReload": {"enable": true},
|
||||
"justMyCode": true
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
30
.vscode/settings.json
vendored
30
.vscode/settings.json
vendored
@ -1,19 +1,13 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"blocklist",
|
||||
"displayname",
|
||||
"dotenv",
|
||||
"gensalt",
|
||||
"hashpw",
|
||||
"checkpw",
|
||||
"jsonify",
|
||||
"lastrowid",
|
||||
"rtype",
|
||||
"flasgger"
|
||||
],
|
||||
"files.exclude": {
|
||||
"**/__pycache__/**": true,
|
||||
},
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
}
|
||||
// #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
|
||||
}
|
||||
|
14
.vscode/tasks.json
vendored
Normal file
14
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"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,216 +13,3 @@ 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).
|
||||
|
@ -1,29 +0,0 @@
|
||||
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
|
@ -1,9 +0,0 @@
|
||||
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
|
@ -1,15 +0,0 @@
|
||||
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
|
@ -1,79 +0,0 @@
|
||||
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
|
@ -1,38 +0,0 @@
|
||||
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
|
@ -1,12 +0,0 @@
|
||||
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!"})
|
@ -1,33 +0,0 @@
|
||||
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
|
@ -1,19 +0,0 @@
|
||||
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
|
@ -1,25 +0,0 @@
|
||||
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
|
@ -1,22 +0,0 @@
|
||||
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
|
@ -1,22 +0,0 @@
|
||||
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
|
@ -1,33 +0,0 @@
|
||||
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
|
@ -1,20 +0,0 @@
|
||||
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
|
@ -1,39 +0,0 @@
|
||||
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
|
@ -1,40 +0,0 @@
|
||||
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
|
||||
|
@ -1,42 +0,0 @@
|
||||
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")
|
@ -1,118 +0,0 @@
|
||||
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()
|
@ -1,79 +0,0 @@
|
||||
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()
|
@ -1,118 +0,0 @@
|
||||
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"}
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
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**!*"
|
||||
}
|
||||
}
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
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"}
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
root_swagger = {
|
||||
"methods": ["GET"],
|
||||
"responses":
|
||||
{
|
||||
"200":
|
||||
{
|
||||
"description": "A hello world json",
|
||||
"schema":
|
||||
{
|
||||
"type": "object",
|
||||
"properties":
|
||||
{
|
||||
"message": {"type": "string", "example": "Hello, Flask!"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
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"}},
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
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,
|
||||
)
|
@ -1,13 +0,0 @@
|
||||
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
|
@ -1,17 +0,0 @@
|
||||
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
|
@ -1,4 +0,0 @@
|
||||
class MessageContent:
|
||||
def __init__(self, subject, body):
|
||||
self.subject = subject
|
||||
self.body = body
|
@ -1,15 +0,0 @@
|
||||
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
|
@ -1,6 +0,0 @@
|
||||
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
|
@ -1,26 +0,0 @@
|
||||
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
|
@ -1,26 +0,0 @@
|
||||
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",
|
||||
)
|
@ -1,63 +0,0 @@
|
||||
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})"
|
@ -1,38 +0,0 @@
|
||||
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})"
|
@ -1,44 +0,0 @@
|
||||
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})"
|
@ -1,162 +0,0 @@
|
||||
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
|
@ -1,30 +0,0 @@
|
||||
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
|
@ -1,23 +0,0 @@
|
||||
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
|
@ -1,8 +0,0 @@
|
||||
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
|
@ -1,20 +0,0 @@
|
||||
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)
|
@ -1,29 +0,0 @@
|
||||
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)
|
@ -1,31 +0,0 @@
|
||||
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
|
@ -1,45 +0,0 @@
|
||||
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)
|
@ -1,31 +0,0 @@
|
||||
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
|
@ -1,64 +0,0 @@
|
||||
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
|
@ -1,72 +0,0 @@
|
||||
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)
|
@ -1,74 +0,0 @@
|
||||
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)
|
2
backend/.mypy.ini
Normal file
2
backend/.mypy.ini
Normal file
@ -0,0 +1,2 @@
|
||||
[mypy-sqlalchemy.*]
|
||||
follow_untyped_imports = True
|
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
# 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"]
|
11
backend/app/api/__init__.py
Normal file
11
backend/app/api/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
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)
|
50
backend/app/api/dependencies.py
Normal file
50
backend/app/api/dependencies.py
Normal file
@ -0,0 +1,50 @@
|
||||
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)]
|
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
38
backend/app/api/routes/cart_routes.py
Normal file
38
backend/app/api/routes/cart_routes.py
Normal file
@ -0,0 +1,38 @@
|
||||
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
|
36
backend/app/api/routes/login_routes.py
Normal file
36
backend/app/api/routes/login_routes.py
Normal file
@ -0,0 +1,36 @@
|
||||
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
|
||||
)
|
||||
)
|
14
backend/app/api/routes/shop/__init__.py
Normal file
14
backend/app/api/routes/shop/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
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)
|
37
backend/app/api/routes/shop/shop_login_routes.py
Normal file
37
backend/app/api/routes/shop/shop_login_routes.py
Normal file
@ -0,0 +1,37 @@
|
||||
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
|
||||
)
|
||||
)
|
0
backend/app/api/routes/shop/shop_routes.py
Normal file
0
backend/app/api/routes/shop/shop_routes.py
Normal file
33
backend/app/api/routes/shop/shop_user_routes.py
Normal file
33
backend/app/api/routes/shop/shop_user_routes.py
Normal file
@ -0,0 +1,33 @@
|
||||
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.")
|
58
backend/app/api/routes/user_routes.py
Normal file
58
backend/app/api/routes/user_routes.py
Normal file
@ -0,0 +1,58 @@
|
||||
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.")
|
25
backend/app/api/routes/utils_routes.py
Normal file
25
backend/app/api/routes/utils_routes.py
Normal file
@ -0,0 +1,25 @@
|
||||
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
|
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
107
backend/app/core/config.py
Normal file
107
backend/app/core/config.py
Normal file
@ -0,0 +1,107 @@
|
||||
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()
|
13
backend/app/core/errors.py
Normal file
13
backend/app/core/errors.py
Normal file
@ -0,0 +1,13 @@
|
||||
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"""
|
28
backend/app/core/security.py
Normal file
28
backend/app/core/security.py
Normal file
@ -0,0 +1,28 @@
|
||||
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)
|
11
backend/app/crud/shop_crud.py
Normal file
11
backend/app/crud/shop_crud.py
Normal file
@ -0,0 +1,11 @@
|
||||
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
|
58
backend/app/crud/user_crud.py
Normal file
58
backend/app/crud/user_crud.py
Normal file
@ -0,0 +1,58 @@
|
||||
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
|
0
backend/app/database/__init__.py
Normal file
0
backend/app/database/__init__.py
Normal file
20
backend/app/database/exceptions.py
Normal file
20
backend/app/database/exceptions.py
Normal file
@ -0,0 +1,20 @@
|
||||
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"]
|
41
backend/app/database/manager.py
Normal file
41
backend/app/database/manager.py
Normal file
@ -0,0 +1,41 @@
|
||||
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()
|
6
backend/app/database/models/__init__.py
Normal file
6
backend/app/database/models/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
from . import user_model, shop_model
|
||||
|
||||
__all__ = [
|
||||
*user_model.__all__,
|
||||
*shop_model.__all__
|
||||
]
|
15
backend/app/database/models/disabled/cart_model.py
Normal file
15
backend/app/database/models/disabled/cart_model.py
Normal file
@ -0,0 +1,15 @@
|
||||
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")
|
18
backend/app/database/models/disabled/purchase_model.py
Normal file
18
backend/app/database/models/disabled/purchase_model.py
Normal file
@ -0,0 +1,18 @@
|
||||
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")
|
15
backend/app/database/models/disabled/wishlist_model.py
Normal file
15
backend/app/database/models/disabled/wishlist_model.py
Normal file
@ -0,0 +1,15 @@
|
||||
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")
|
77
backend/app/database/models/shop_model.py
Normal file
77
backend/app/database/models/shop_model.py
Normal file
@ -0,0 +1,77 @@
|
||||
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"]
|
69
backend/app/database/models/user_model.py
Normal file
69
backend/app/database/models/user_model.py
Normal file
@ -0,0 +1,69 @@
|
||||
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"]
|
31
backend/app/main.py
Normal file
31
backend/app/main.py
Normal file
@ -0,0 +1,31 @@
|
||||
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)
|
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
30
backend/app/schemas/user_schemas.py
Normal file
30
backend/app/schemas/user_schemas.py
Normal file
@ -0,0 +1,30 @@
|
||||
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
|
13
backend/app/utils/logger.py
Normal file
13
backend/app/utils/logger.py
Normal file
@ -0,0 +1,13 @@
|
||||
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)
|
6
backend/app/utils/models.py
Normal file
6
backend/app/utils/models.py
Normal file
@ -0,0 +1,6 @@
|
||||
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)
|
27
backend/app/utils/propagate.py
Normal file
27
backend/app/utils/propagate.py
Normal file
@ -0,0 +1,27 @@
|
||||
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
Normal file
1478
backend/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
backend/pyproject.toml
Normal file
52
backend/pyproject.toml
Normal file
@ -0,0 +1,52 @@
|
||||
[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
|
68
docker-compose.yml
Normal file
68
docker-compose.yml
Normal file
@ -0,0 +1,68 @@
|
||||
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
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# 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?
|
10
frontend/.prettierrc
Normal file
10
frontend/.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"proseWrap": "always",
|
||||
"requireConfig": false,
|
||||
"useTabs": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSameLine": true
|
||||
}
|
50
frontend/README.md
Normal file
50
frontend/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 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,
|
||||
},
|
||||
})
|
||||
```
|
21
frontend/components.json
Normal file
21
frontend/components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
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 },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!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
Normal file
7975
frontend/package-lock.json
generated
Normal file
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