sync: migrate secure-online-shop to Gitea (2026-08-10)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Database package."""
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Script to create a shop account with an operator-supplied strong password.
|
||||
|
||||
Usage:
|
||||
python -m app.db.create_shop_account
|
||||
|
||||
Provide SHOP_ACCOUNT_PASSWORD for non-interactive execution,
|
||||
or enter the password securely via getpass().
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from getpass import getpass
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.db.session import SessionLocal, engine
|
||||
from app.models import Base, RoleEnum, User
|
||||
|
||||
SHOP_USERNAME = "shop"
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
|
||||
def _validate_password_strength(password: str) -> None:
|
||||
if len(password) < MIN_PASSWORD_LENGTH or len(password) > 72:
|
||||
raise ValueError("Password must be between 12 and 72 characters long.")
|
||||
has_upper = any(char.isupper() for char in password)
|
||||
has_lower = any(char.islower() for char in password)
|
||||
has_digit = any(char.isdigit() for char in password)
|
||||
has_special = any(not char.isalnum() for char in password)
|
||||
if not all((has_upper, has_lower, has_digit, has_special)):
|
||||
raise ValueError(
|
||||
"Password must include upper, lower, digit, and special characters."
|
||||
)
|
||||
|
||||
|
||||
def _get_shop_password() -> str:
|
||||
env_password = os.getenv("SHOP_ACCOUNT_PASSWORD", "").strip()
|
||||
if env_password:
|
||||
_validate_password_strength(env_password)
|
||||
return env_password
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
raise RuntimeError(
|
||||
"Interactive password input is unavailable. Set SHOP_ACCOUNT_PASSWORD to create the account safely."
|
||||
)
|
||||
|
||||
while True:
|
||||
password = getpass("Enter a strong password for the shop account: ")
|
||||
confirm_password = getpass("Confirm the password: ")
|
||||
if password != confirm_password:
|
||||
print("Passwords do not match. Please try again.", file=sys.stderr)
|
||||
continue
|
||||
_validate_password_strength(password)
|
||||
return password
|
||||
|
||||
|
||||
def create_shop_account() -> None:
|
||||
"""Create a shop account with a strong password if it doesn't exist."""
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
with SessionLocal() as db:
|
||||
existing = db.scalar(
|
||||
select(User).where(User.username == SHOP_USERNAME)
|
||||
)
|
||||
if existing is not None:
|
||||
print(f"Shop account '{SHOP_USERNAME}' already exists (id={existing.id}).")
|
||||
print("No new account was created.")
|
||||
return
|
||||
|
||||
password = _get_shop_password()
|
||||
password_hash = get_password_hash(password)
|
||||
|
||||
shop = User(
|
||||
username=SHOP_USERNAME,
|
||||
password_hash=password_hash,
|
||||
role=RoleEnum.shop,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(shop)
|
||||
db.commit()
|
||||
db.refresh(shop)
|
||||
|
||||
print("=" * 60)
|
||||
print("SHOP ACCOUNT CREATED SUCCESSFULLY")
|
||||
print("=" * 60)
|
||||
print(f"Username: {SHOP_USERNAME}")
|
||||
print("=" * 60)
|
||||
print("Password was accepted and hashed without being printed to stdout.")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
create_shop_account()
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.seed_demo import seed_demo_products
|
||||
from app.db.session import engine
|
||||
from app.models import Base
|
||||
|
||||
logger = logging.getLogger("app.db")
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("Database tables created successfully")
|
||||
settings = get_settings()
|
||||
if settings.demo_seed_products:
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
with SessionLocal() as db:
|
||||
seed_demo_products(db, settings.demo_product_count)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
initialize_database()
|
||||
print("Database initialized successfully.")
|
||||
print("To create a shop account, run: python -m app.db.create_shop_account")
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.product import Product
|
||||
|
||||
logger = logging.getLogger("app.db")
|
||||
|
||||
PRODUCT_TEMPLATES = (
|
||||
("Aurora Headphones", "Wireless headphones with active noise reduction", "189.90", 42),
|
||||
("Nimbus Laptop", "Thin laptop for study, work, and secure online payments", "1299.00", 18),
|
||||
("Pulse Smartwatch", "Fitness smartwatch with long battery life", "249.50", 55),
|
||||
("Volt Power Bank", "Compact 20000 mAh power bank with fast charging", "79.99", 90),
|
||||
("Axis Keyboard", "Mechanical keyboard with quiet tactile switches", "139.00", 36),
|
||||
("Orbit Backpack", "Water-resistant backpack with laptop compartment", "89.90", 64),
|
||||
("Brew Coffee Maker", "Programmable drip coffee maker for home offices", "119.95", 31),
|
||||
("Frame Desk Lamp", "Adjustable LED lamp with warm and cold light modes", "54.40", 83),
|
||||
("Focus Webcam", "Full HD webcam with privacy shutter", "74.99", 71),
|
||||
("Studio Speaker", "Bluetooth speaker with balanced stereo sound", "159.00", 27),
|
||||
("Terra Sneakers", "Lightweight everyday sneakers with cushioned soles", "109.90", 49),
|
||||
("Slate Tablet", "Portable tablet for browsing, media, and field work", "399.00", 22),
|
||||
)
|
||||
|
||||
|
||||
def seed_demo_products(db: Session, target_count: int) -> None:
|
||||
"""Populate the catalog with deterministic demo products for MVP deployment."""
|
||||
if target_count <= 0:
|
||||
return
|
||||
|
||||
existing_count = db.scalar(select(func.count(Product.id))) or 0
|
||||
products_to_create = target_count - existing_count
|
||||
if products_to_create <= 0:
|
||||
logger.info("Demo product seed skipped existing_count=%s", existing_count)
|
||||
return
|
||||
|
||||
products: list[Product] = []
|
||||
for offset in range(products_to_create):
|
||||
sequence = existing_count + offset + 1
|
||||
name, description, price, base_stock = PRODUCT_TEMPLATES[offset % len(PRODUCT_TEMPLATES)]
|
||||
batch = (offset // len(PRODUCT_TEMPLATES)) + 1
|
||||
products.append(
|
||||
Product(
|
||||
name=f"{name} {batch:02d}",
|
||||
description=description,
|
||||
price=Decimal(price),
|
||||
stock=base_stock + (sequence % 17),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
db.add_all(products)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Demo product seed completed created=%s target_count=%s",
|
||||
len(products),
|
||||
target_count,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
sqlite_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=sqlite_connect_args,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user