Compare commits

...

24 Commits

Author SHA1 Message Date
75b2b1e142 [rewrite] Added openapi dependency for generation of function 2025-03-12 14:01:36 +01:00
20ef2aa4e8 [rewrite] Implemented login and register 2025-03-12 14:01:13 +01:00
cd8fdb9c21 [rewrite] WIP login and register 2025-03-11 21:53:37 +01:00
f5547be799 [rewrite] Changes services to crud, modified routes abit 2025-03-11 15:21:30 +01:00
543aadf521 [rewrite] Disabled restart for docker-compose 2025-03-11 08:29:38 +01:00
272e765ca8 [rewrite] Minor fixes to models, session generator and example .env 2025-03-10 22:02:30 +01:00
71e916586e [rewrite] Major changes to code - switched to postgres, added initial backend docker files 2025-03-10 18:02:57 +01:00
c7e20fc935 [rewrite] Added a security module from a fastapi example 2025-02-24 22:32:49 +01:00
9e56eeca3f [rewrite] Work on user registration, slightly polished schemas structure 2025-02-24 19:02:39 +01:00
5d12391635 [rewrite] Updated backend structure a bit and started working on registration 2025-02-23 21:14:23 +01:00
f0c378b20f [rewrite] Updated models a bit, preparing to make first functional routes 2025-02-23 15:56:39 +01:00
414b84a2d4 [rewrite] Added tasks, more debug options to vscode and slightly updated frontends structure 2025-02-23 10:45:45 +01:00
c3502f9ede [rewrite] Updated main page 2025-02-22 23:00:44 +01:00
a9c85cefe1 [rewrite] Added first steps for authentication 2025-02-21 16:51:37 +01:00
073f01d070 [rewrite] Updated frontend structure and played with custom routes 2025-02-21 14:51:17 +01:00
43063dcf4e [rewrite] Added missing lib file, updated debug launch.json for frontend and updated .gitignore 2025-02-20 21:06:04 +01:00
6e9334ba0e [rewrite] Added shadcn ui to frontend and slightly tweaked backend 2025-02-20 15:16:06 +01:00
4c8817e853 [rewrite] Removed old code, updated .vscode, for real this time 2025-02-17 22:03:45 +01:00
3b0336d9b4 [rewrite] Removed old code, updated .vscode 2025-02-17 21:59:41 +01:00
3c005dbfa4 [rewrite] Added a config manager and some test code 2025-02-17 18:24:13 +01:00
807d23da51 [rewrite] Added poetry, and models for new database 2025-01-20 06:50:07 +01:00
3d00ae0aad [rewrite] Added initial route structure (WIP) 2024-12-02 21:15:25 +01:00
bb789a75e7 [rewrite] Updated gitignore and requirements.txt 2024-12-02 09:03:12 +01:00
40aa0295b1 [rewrite] Initial project structure with dashboard frontend and api 2024-12-02 08:45:08 +01:00
298 changed files with 25929 additions and 2407 deletions

53
.env-example Normal file
View 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

View File

@ -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
View File

@ -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
View File

@ -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
View File

@ -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
View 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
View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!"})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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()

View File

@ -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"}
}
}

View File

@ -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**!*"
}
}
}

View File

@ -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"}
}
}

View File

@ -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!"}
}
}
}
}
}

View File

@ -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"}},
}

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -1,4 +0,0 @@
class MessageContent:
def __init__(self, subject, body):
self.subject = subject
self.body = body

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",
)

View File

@ -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})"

View File

@ -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})"

View File

@ -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})"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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
View File

@ -0,0 +1,2 @@
[mypy-sqlalchemy.*]
follow_untyped_imports = True

25
backend/Dockerfile Normal file
View 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"]

View 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)

View 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)]

View File

View 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

View 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
)
)

View 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)

View 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
)
)

View 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.")

View 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.")

View 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

View File

107
backend/app/core/config.py Normal file
View 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()

View 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"""

View 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)

View 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

View 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

View File

View 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"]

View 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()

View File

@ -0,0 +1,6 @@
from . import user_model, shop_model
__all__ = [
*user_model.__all__,
*shop_model.__all__
]

View 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")

View 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")

View 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")

View 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"]

View 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
View 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)

View File

View 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

View 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)

View 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)

View 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

File diff suppressed because it is too large Load Diff

52
backend/pyproject.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

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